diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e4da1e72 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.lua] +indent_style = tab +indent_size = tab +tab_width = 4 + diff --git a/.vscode/settings.json b/.vscode/settings.json index dcc7a766..a21b5fe1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,9 @@ "json.schemas": [], "terminal.integrated.defaultProfile.linux": "bash", "[lua]": { - "editor.tabSize": 4 + "editor.tabSize": 4, + "editor.insertSpaces": false, + "editor.detectIndentation": false }, "Lua.diagnostics.workspaceDelay": -1, "Lua.diagnostics.enable": true, diff --git a/Makefile b/Makefile index 6f6b553e..e3960497 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ VENDOR_DIR := vendor BUILD_DIR := build TEST_DIR := tests LINTER := luacheck +LUA_INDENT_DIRS := $(SRC_DIR) $(TEST_DIR) examples tools UI_REPO ?= https://github.com/jangala-dev/local-ui.git UI_DIR ?= local-ui UI_WWW_DIR := $(SRC_DIR)/services/ui/www @@ -115,11 +116,18 @@ test-all: @rm -f $(VENDOR_DIR)/lua-bus/src/trie.lua @echo "All tests complete." -# Lint: Static analysis on source and test directories +# Check-Indent: Enforce tab-based indentation in first-party Lua files +.PHONY: check-indent +check-indent: + @echo "Checking Lua indentation..." + @luajit tools/check_lua_indentation.lua $(LUA_INDENT_DIRS) + @echo "Lua indentation check complete." + +# Lint: Indentation and static analysis on source and test directories .PHONY: lint -lint: +lint: check-indent @echo "Running linter..." - @$(LINTER) $(SRC_DIR) $(TEST_DIR) + @$(LINTER) $(SRC_DIR) $(TEST_DIR) tools @echo "Linting complete." # Clean: Remove the build directory @@ -143,6 +151,7 @@ help: @echo " env Pin vendor submodules to revisions in .env" @echo " test Run the devicecode test suite" @echo " test-all Run devicecode and all vendor test suites" - @echo " lint Run luacheck on $(SRC_DIR)/ and $(TEST_DIR)/" + @echo " check-indent Enforce tab-based indentation in first-party Lua files" + @echo " lint Run indentation checks and luacheck" @echo " clean Remove $(BUILD_DIR)/" @echo " help Show this message" diff --git a/examples/gpio_control.lua b/examples/gpio_control.lua index 0a9b32a1..7f89edb3 100644 --- a/examples/gpio_control.lua +++ b/examples/gpio_control.lua @@ -6,48 +6,48 @@ local fiber = require 'fibers.fiber' local gpio = require 'gpio' local map = { - { - {["1"] = false, ["7"] = false, ["8"] = true}, - {["1"] = false, ["7"] = true, ["8"] = true}, - {["1"] = true, ["7"] = false, ["8"] = false}, - {["1"] = false, ["7"] = true, ["8"] = false}, - {["1"] = true, ["7"] = true, ["8"] = true}, - {["1"] = true, ["7"] = false, ["8"] = true} - }, - { - {["24"] = false, ["23"] = false, ["18"] = true}, - {["24"] = false, ["23"] = true, ["18"] = true}, - {["24"] = true, ["23"] = false, ["18"] = false}, - {["24"] = false, ["23"] = true, ["18"] = false}, - {["24"] = true, ["23"] = true, ["18"] = true}, - {["24"] = true, ["23"] = false, ["18"] = true} - } + { + {["1"] = false, ["7"] = false, ["8"] = true}, + {["1"] = false, ["7"] = true, ["8"] = true}, + {["1"] = true, ["7"] = false, ["8"] = false}, + {["1"] = false, ["7"] = true, ["8"] = false}, + {["1"] = true, ["7"] = true, ["8"] = true}, + {["1"] = true, ["7"] = false, ["8"] = true} + }, + { + {["24"] = false, ["23"] = false, ["18"] = true}, + {["24"] = false, ["23"] = true, ["18"] = true}, + {["24"] = true, ["23"] = false, ["18"] = false}, + {["24"] = false, ["23"] = true, ["18"] = false}, + {["24"] = true, ["23"] = true, ["18"] = true}, + {["24"] = true, ["23"] = false, ["18"] = true} + } } local function map_modem_to_sim(modem_index, sim_index) - local pin_states = map[modem_index][sim_index] - for pin_no, value in pairs(pin_states) do - local pin = gpio.new_pin(pin_no) - assert(pin:export()) - assert(pin:set_out()) - if value then assert(pin:write_high()) else assert(pin:write_low()) end - end + local pin_states = map[modem_index][sim_index] + for pin_no, value in pairs(pin_states) do + local pin = gpio.new_pin(pin_no) + assert(pin:export()) + assert(pin:set_out()) + if value then assert(pin:write_high()) else assert(pin:write_low()) end + end end fiber.spawn(function () - gpio.initialize_gpio() + gpio.initialize_gpio() - --set both en's to low - local ens = {25, 12} - for _, v in ipairs(ens) do - local pin = gpio.new_pin(v) - assert(pin:export()) - assert(pin:set_out()) - assert(pin:write_low()) - end - map_modem_to_sim(2, 2) + --set both en's to low + local ens = {25, 12} + for _, v in ipairs(ens) do + local pin = gpio.new_pin(v) + assert(pin:export()) + assert(pin:set_out()) + assert(pin:write_low()) + end + map_modem_to_sim(2, 2) - fiber.stop() + fiber.stop() end) fiber.main() diff --git a/examples/gpio_detect.lua b/examples/gpio_detect.lua index 1dd5accf..5c6282bf 100644 --- a/examples/gpio_detect.lua +++ b/examples/gpio_detect.lua @@ -6,38 +6,38 @@ local fiber = require 'fibers.fiber' local gpio = require 'gpio' local function setup(pin) - assert(pin:export()) - assert(pin:pull_up()) - assert(pin:set_in()) - assert(pin:edge_both()) - return pin + assert(pin:export()) + assert(pin:pull_up()) + assert(pin:set_in()) + assert(pin:edge_both()) + return pin end fiber.spawn(function () - gpio.initialize_gpio() + gpio.initialize_gpio() - local p1 = setup(gpio.new_pin(5)) - local p2 = setup(gpio.new_pin(6)) - local p3 = setup(gpio.new_pin(13)) - local p4 = setup(gpio.new_pin(19)) + local p1 = setup(gpio.new_pin(5)) + local p2 = setup(gpio.new_pin(6)) + local p3 = setup(gpio.new_pin(13)) + local p4 = setup(gpio.new_pin(19)) - while true do - local status = op.choice( - p1:watch_op():wrap(function (status) - return status=="0" and "1: inserted" or "1: removed" - end), - p2:watch_op():wrap(function (status) - return status=="0" and "2: inserted" or "2: removed" - end), - p3:watch_op():wrap(function (status) - return status=="0" and "3: inserted" or "3: removed" - end), - p4:watch_op():wrap(function (status) - return status=="0" and "4: inserted" or "4: removed" - end) - ):perform() - print(status) - end + while true do + local status = op.choice( + p1:watch_op():wrap(function (status) + return status=="0" and "1: inserted" or "1: removed" + end), + p2:watch_op():wrap(function (status) + return status=="0" and "2: inserted" or "2: removed" + end), + p3:watch_op():wrap(function (status) + return status=="0" and "3: inserted" or "3: removed" + end), + p4:watch_op():wrap(function (status) + return status=="0" and "4: inserted" or "4: removed" + end) + ):perform() + print(status) + end end) fiber.main() @@ -49,38 +49,38 @@ local fiber = require 'fibers.fiber' local gpio = require 'gpio' local function setup(pin) - assert(pin:export()) - assert(pin:pull_up()) - assert(pin:set_in()) - assert(pin:edge_both()) - return pin + assert(pin:export()) + assert(pin:pull_up()) + assert(pin:set_in()) + assert(pin:edge_both()) + return pin end fiber.spawn(function () - gpio.initialize_gpio() + gpio.initialize_gpio() - local p1 = setup(gpio.new_pin(5)) - local p2 = setup(gpio.new_pin(6)) - local p3 = setup(gpio.new_pin(13)) - local p4 = setup(gpio.new_pin(19)) + local p1 = setup(gpio.new_pin(5)) + local p2 = setup(gpio.new_pin(6)) + local p3 = setup(gpio.new_pin(13)) + local p4 = setup(gpio.new_pin(19)) - while true do - local status = op.choice( - p1:watch_op():wrap(function (status) - return status=="0" and "1: inserted" or "1: removed" - end), - p2:watch_op():wrap(function (status) - return status=="0" and "2: inserted" or "2: removed" - end), - p3:watch_op():wrap(function (status) - return status=="0" and "3: inserted" or "3: removed" - end), - p4:watch_op():wrap(function (status) - return status=="0" and "4: inserted" or "4: removed" - end) - ):perform() - print(status) - end + while true do + local status = op.choice( + p1:watch_op():wrap(function (status) + return status=="0" and "1: inserted" or "1: removed" + end), + p2:watch_op():wrap(function (status) + return status=="0" and "2: inserted" or "2: removed" + end), + p3:watch_op():wrap(function (status) + return status=="0" and "3: inserted" or "3: removed" + end), + p4:watch_op():wrap(function (status) + return status=="0" and "4: inserted" or "4: removed" + end) + ):perform() + print(status) + end end) fiber.main() diff --git a/src/devicecode/main.lua b/src/devicecode/main.lua index b85b9e61..735fd742 100644 --- a/src/devicecode/main.lua +++ b/src/devicecode/main.lua @@ -15,299 +15,299 @@ local EXIT_GRACE_PERIOD = 60.0 local M = {} local function require_env(name) - local v = os.getenv(name) - if not v or v == '' then - error(('missing required environment variable %s'):format(name), 2) - end - return v + local v = os.getenv(name) + if not v or v == '' then + error(('missing required environment variable %s'):format(name), 2) + end + return v end local function parse_csv(s) - local out = {} - for part in tostring(s):gmatch('[^,%s]+') do - out[#out + 1] = part - end - return out + local out = {} + for part in tostring(s):gmatch('[^,%s]+') do + out[#out + 1] = part + end + return out end local function assert_unique_services(names) - local seen = {} - for i = 1, #names do - local name = names[i] - if seen[name] then - error(('DEVICECODE_SERVICES contains duplicate service name %s'):format(tostring(name)), 2) - end - seen[name] = true - end + local seen = {} + for i = 1, #names do + local name = names[i] + if seen[name] then + error(('DEVICECODE_SERVICES contains duplicate service name %s'):format(tostring(name)), 2) + end + seen[name] = true + end end local function move_to_front(list, wanted) - local out = {} - for i = 1, #list do - if list[i] == wanted then - out[#out + 1] = list[i] - end - end - for i = 1, #list do - if list[i] ~= wanted then - out[#out + 1] = list[i] - end - end - return out + local out = {} + for i = 1, #list do + if list[i] == wanted then + out[#out + 1] = list[i] + end + end + for i = 1, #list do + if list[i] ~= wanted then + out[#out + 1] = list[i] + end + end + return out end local function cleanup_child_scope(child, reason) - if not child then return end - child:cancel(reason or 'cleanup') + if not child then return end + child:cancel(reason or 'cleanup') end local function spawn_service(child, bus, name, mod, env, extra_opts) - return child:spawn(function() - local conn = bus:connect({ - principal = authz.service_principal(name), - }) - - local function connect_as(principal) - return bus:connect({ - principal = principal, - }) - end - - mod.start(conn, { - name = name, - env = env, - connect = connect_as, - services = extra_opts and extra_opts.services or nil, - run_http = extra_opts and extra_opts.run_http or nil, - verify_login = extra_opts and extra_opts.verify_login or nil, - }) - - error(('service returned unexpectedly: %s'):format(tostring(name)), 0) - end) + return child:spawn(function() + local conn = bus:connect({ + principal = authz.service_principal(name), + }) + + local function connect_as(principal) + return bus:connect({ + principal = principal, + }) + end + + mod.start(conn, { + name = name, + env = env, + connect = connect_as, + services = extra_opts and extra_opts.services or nil, + run_http = extra_opts and extra_opts.run_http or nil, + verify_login = extra_opts and extra_opts.verify_login or nil, + }) + + error(('service returned unexpectedly: %s'):format(tostring(name)), 0) + end) end local function build_bus() - return busmod.new({ - q_length = 10, - full = 'drop_oldest', - s_wild = '+', - m_wild = '#', - authoriser = authz.new(), - }) + return busmod.new({ + q_length = 10, + full = 'drop_oldest', + s_wild = '+', + m_wild = '#', + authoriser = authz.new(), + }) end local function now() - return fibers.now() + return fibers.now() end local function retain_main_state(conn, status, fields) - local payload = { - status = status, - t = now(), - } - if fields then - for k, v in pairs(fields) do - payload[k] = v - end - end - conn:retain({ 'obs', 'state', 'main' }, payload) + local payload = { + status = status, + t = now(), + } + if fields then + for k, v in pairs(fields) do + payload[k] = v + end + end + conn:retain({ 'obs', 'state', 'main' }, payload) end local function retain_service_state(conn, name, status, fields) - local payload = { - service = name, - status = status, - t = now(), - } - if fields then - for k, v in pairs(fields) do - payload[k] = v - end - end - conn:retain({ 'obs', 'state', 'service', name }, payload) + local payload = { + service = name, + status = status, + t = now(), + } + if fields then + for k, v in pairs(fields) do + payload[k] = v + end + end + conn:retain({ 'obs', 'state', 'service', name }, payload) end local function load_service(service_loader, name) - local ok, mod = safe.pcall(service_loader, name) - if not ok then - return nil, mod - end - if type(mod) ~= 'table' or type(mod.start) ~= 'function' then - return nil, 'service module must export start(conn, opts)' - end - return mod, nil + local ok, mod = safe.pcall(service_loader, name) + if not ok then + return nil, mod + end + if type(mod) ~= 'table' or type(mod.start) ~= 'function' then + return nil, 'service module must export start(conn, opts)' + end + return mod, nil end local function fail_boot(main_conn, service_name, what, err, extra) - if service_name then - retain_service_state(main_conn, service_name, 'failed', { - what = what, - err = tostring(err), - }) - end - - local payload = { - what = what, - err = tostring(err), - } - if service_name then - payload.service = service_name - end - if extra then - for k, v in pairs(extra) do - payload[k] = v - end - end - - retain_main_state(main_conn, 'failed', payload) - main_conn:publish({ 'obs', 'log', 'main', 'error' }, payload) - - if service_name then - error(('boot failed for service %s: %s'):format(tostring(service_name), tostring(err)), 0) - else - error(('boot failed: %s'):format(tostring(err)), 0) - end + if service_name then + retain_service_state(main_conn, service_name, 'failed', { + what = what, + err = tostring(err), + }) + end + + local payload = { + what = what, + err = tostring(err), + } + if service_name then + payload.service = service_name + end + if extra then + for k, v in pairs(extra) do + payload[k] = v + end + end + + retain_main_state(main_conn, 'failed', payload) + main_conn:publish({ 'obs', 'log', 'main', 'error' }, payload) + + if service_name then + error(('boot failed for service %s: %s'):format(tostring(service_name), tostring(err)), 0) + else + error(('boot failed: %s'):format(tostring(err)), 0) + end end function M.run(scope, params) - params = params or {} - - local env = params.env or (os.getenv('DEVICECODE_ENV') or 'dev') - local exit_grace_period = params.exit_grace_period or EXIT_GRACE_PERIOD - - local service_names = parse_csv(params.services_csv or require_env('DEVICECODE_SERVICES')) - if #service_names == 0 then - error('DEVICECODE_SERVICES must contain at least one service name', 2) - end - - assert_unique_services(service_names) - - -- Start monitor first if it is present. - service_names = move_to_front(service_names, 'monitor') - - local bus = params.bus or build_bus() - local service_loader = params.service_loader or function(name) - return require('services.' .. name) - end - local service_opts = params.service_opts or {} - - local main_conn = bus:connect({ - principal = authz.service_principal('main'), - }) - - retain_main_state(main_conn, 'starting', { - env = env, - services = service_names, - }) - - main_conn:publish({ 'obs', 'log', 'main', 'info' }, { - what = 'starting', - env = env, - }) - - local services = {} - - for i = 1, #service_names do - local name = service_names[i] - - retain_service_state(main_conn, name, 'starting') - main_conn:publish({ 'obs', 'event', 'main', 'spawn' }, { service = name, t = now() }) - - local mod, lerr = load_service(service_loader, name) - if not mod then - fail_boot(main_conn, name, 'load_failed', lerr) - end - - local child, cerr = scope:child() - if not child then - fail_boot(main_conn, name, 'child_scope_failed', cerr) - end - - local ok_spawn, serr = spawn_service(child, bus, name, mod, env, service_opts[name]) - if not ok_spawn then - cleanup_child_scope(child, 'spawn_failed') - fail_boot(main_conn, name, 'spawn_failed', serr) - end - - retain_service_state(main_conn, name, 'running') - services[#services + 1] = { name = name, scope = child } - end - - retain_main_state(main_conn, 'running', { - env = env, - services = service_names, - }) - - scope:spawn(function() - local ev = nil - - for i = 1, #services do - local rec = services[i] - local one = rec.scope:not_ok_op():wrap(function(st, primary) - return rec, st, primary - end) - ev = ev and op.choice(ev, one) or one - end - - if not ev then - retain_main_state(main_conn, 'failed', { - what = 'no_services_started', - }) - scope:cancel('no_services_started') - return - end - - local rec, _, _ = fibers.perform(ev) - local svc = rec.name - - local jst, report, jprimary = fibers.perform(rec.scope:join_op()) - - retain_service_state(main_conn, svc, jst, { - primary = tostring(jprimary), - report = report, - }) - - retain_main_state(main_conn, 'failed', { - what = 'service_not_ok', - service = svc, - status = jst, - primary = tostring(jprimary), - }) - - main_conn:publish({ 'obs', 'event', 'main', 'service_exit' }, { - service = svc, - status = jst, - primary = tostring(jprimary), - report = report, - t = now(), - }) - - main_conn:publish({ 'obs', 'log', 'main', (jst == 'failed') and 'error' or 'warn' }, { - what = 'service_not_ok', - service = svc, - status = jst, - primary = tostring(jprimary), - }) - - sleep.sleep(exit_grace_period) + params = params or {} + + local env = params.env or (os.getenv('DEVICECODE_ENV') or 'dev') + local exit_grace_period = params.exit_grace_period or EXIT_GRACE_PERIOD + + local service_names = parse_csv(params.services_csv or require_env('DEVICECODE_SERVICES')) + if #service_names == 0 then + error('DEVICECODE_SERVICES must contain at least one service name', 2) + end + + assert_unique_services(service_names) + + -- Start monitor first if it is present. + service_names = move_to_front(service_names, 'monitor') + + local bus = params.bus or build_bus() + local service_loader = params.service_loader or function(name) + return require('services.' .. name) + end + local service_opts = params.service_opts or {} + + local main_conn = bus:connect({ + principal = authz.service_principal('main'), + }) + + retain_main_state(main_conn, 'starting', { + env = env, + services = service_names, + }) + + main_conn:publish({ 'obs', 'log', 'main', 'info' }, { + what = 'starting', + env = env, + }) + + local services = {} + + for i = 1, #service_names do + local name = service_names[i] + + retain_service_state(main_conn, name, 'starting') + main_conn:publish({ 'obs', 'event', 'main', 'spawn' }, { service = name, t = now() }) + + local mod, lerr = load_service(service_loader, name) + if not mod then + fail_boot(main_conn, name, 'load_failed', lerr) + end + + local child, cerr = scope:child() + if not child then + fail_boot(main_conn, name, 'child_scope_failed', cerr) + end + + local ok_spawn, serr = spawn_service(child, bus, name, mod, env, service_opts[name]) + if not ok_spawn then + cleanup_child_scope(child, 'spawn_failed') + fail_boot(main_conn, name, 'spawn_failed', serr) + end + + retain_service_state(main_conn, name, 'running') + services[#services + 1] = { name = name, scope = child } + end + + retain_main_state(main_conn, 'running', { + env = env, + services = service_names, + }) + + scope:spawn(function() + local ev = nil + + for i = 1, #services do + local rec = services[i] + local one = rec.scope:not_ok_op():wrap(function(st, primary) + return rec, st, primary + end) + ev = ev and op.choice(ev, one) or one + end + + if not ev then + retain_main_state(main_conn, 'failed', { + what = 'no_services_started', + }) + scope:cancel('no_services_started') + return + end + + local rec, _, _ = fibers.perform(ev) + local svc = rec.name + + local jst, report, jprimary = fibers.perform(rec.scope:join_op()) + + retain_service_state(main_conn, svc, jst, { + primary = tostring(jprimary), + report = report, + }) + + retain_main_state(main_conn, 'failed', { + what = 'service_not_ok', + service = svc, + status = jst, + primary = tostring(jprimary), + }) + + main_conn:publish({ 'obs', 'event', 'main', 'service_exit' }, { + service = svc, + status = jst, + primary = tostring(jprimary), + report = report, + t = now(), + }) + + main_conn:publish({ 'obs', 'log', 'main', (jst == 'failed') and 'error' or 'warn' }, { + what = 'service_not_ok', + service = svc, + status = jst, + primary = tostring(jprimary), + }) + + sleep.sleep(exit_grace_period) scope:cancel(('service_not_ok:%s'):format(tostring(svc))) - end) - - main_conn:publish({ 'obs', 'log', 'main', 'info' }, { - what = 'services_spawned', - n = #services, - }) - - local n = 0 - while true do - n = n + 1 - main_conn:publish({ 'obs', 'event', 'main', 'tick' }, { - n = n, - t = now(), - }) - sleep.sleep(10.0) - end + end) + + main_conn:publish({ 'obs', 'log', 'main', 'info' }, { + what = 'services_spawned', + n = #services, + }) + + local n = 0 + while true do + n = n + 1 + main_conn:publish({ 'obs', 'event', 'main', 'tick' }, { + n = n, + t = now(), + }) + sleep.sleep(10.0) + end end return M diff --git a/src/devicecode/signal_bridge.lua b/src/devicecode/signal_bridge.lua index e7f7538f..da676450 100644 --- a/src/devicecode/signal_bridge.lua +++ b/src/devicecode/signal_bridge.lua @@ -3,108 +3,108 @@ local sleep = require 'fibers.sleep' local M = {} local SIGNAL_NUMBERS = { - TERM = 15, - INT = 2, + TERM = 15, + INT = 2, } local function normalise_name(name) - name = tostring(name or ''):upper():gsub('^SIG', '') - return name + name = tostring(name or ''):upper():gsub('^SIG', '') + return name end local function load_posix_backend() - local ok_sig, psig = pcall(require, 'posix.signal') - if not ok_sig or type(psig) ~= 'table' or type(psig.signal) ~= 'function' then - return nil - end - - local ok_unistd, unistd = pcall(require, 'posix.unistd') - if not ok_unistd or type(unistd) ~= 'table' then - unistd = {} - end - - local function signum(name) - return psig['SIG' .. name] or SIGNAL_NUMBERS[name] - end - - local function set_handler(name, handler) - local old, err, eno = psig.signal(signum(name), handler) - if not old and err then - return nil, tostring(err) .. (eno and (':' .. tostring(eno)) or '') - end - return old or true - end - - local function restore(name, old) - if old == true then - old = 'default' - end - pcall(psig.signal, signum(name), old) - end - - local function hard_exit(name) - local sig = signum(name) - pcall(psig.signal, sig, 'default') - if type(psig.kill) == 'function' and type(unistd.getpid) == 'function' then - pcall(psig.kill, unistd.getpid(), sig) - end - os.exit(128 + (sig or 0)) - end - - return { - name = 'posix.signal', - set_handler = set_handler, - restore = restore, - hard_exit = hard_exit, - } + local ok_sig, psig = pcall(require, 'posix.signal') + if not ok_sig or type(psig) ~= 'table' or type(psig.signal) ~= 'function' then + return nil + end + + local ok_unistd, unistd = pcall(require, 'posix.unistd') + if not ok_unistd or type(unistd) ~= 'table' then + unistd = {} + end + + local function signum(name) + return psig['SIG' .. name] or SIGNAL_NUMBERS[name] + end + + local function set_handler(name, handler) + local old, err, eno = psig.signal(signum(name), handler) + if not old and err then + return nil, tostring(err) .. (eno and (':' .. tostring(eno)) or '') + end + return old or true + end + + local function restore(name, old) + if old == true then + old = 'default' + end + pcall(psig.signal, signum(name), old) + end + + local function hard_exit(name) + local sig = signum(name) + pcall(psig.signal, sig, 'default') + if type(psig.kill) == 'function' and type(unistd.getpid) == 'function' then + pcall(psig.kill, unistd.getpid(), sig) + end + os.exit(128 + (sig or 0)) + end + + return { + name = 'posix.signal', + set_handler = set_handler, + restore = restore, + hard_exit = hard_exit, + } end local function load_nixio_backend() - local ok, nixio = pcall(require, 'nixio') - if not ok or type(nixio) ~= 'table' or type(nixio.signal) ~= 'function' then - return nil - end - - local function signum(name) - return nixio['SIG' .. name] or SIGNAL_NUMBERS[name] - end - - local function set_handler(name, handler) - -- The documented nixio API only guarantees 'ign' and 'dfl'. Some - -- builds may accept Lua callbacks; use that only when it succeeds. - local ok2, old_or_err, eno = pcall(nixio.signal, signum(name), handler) - if not ok2 or old_or_err == nil or old_or_err == false then - return nil, tostring(old_or_err) .. (eno and (':' .. tostring(eno)) or '') - end - return old_or_err or true - end - - local function restore(name, old) - if old == true then - old = 'dfl' - end - pcall(nixio.signal, signum(name), old) - end - - local function hard_exit(name) - local sig = signum(name) - pcall(nixio.signal, sig, 'dfl') - if type(nixio.kill) == 'function' and type(nixio.getpid) == 'function' then - pcall(nixio.kill, nixio.getpid(), sig) - end - os.exit(128 + (sig or 0)) - end - - return { - name = 'nixio.signal', - set_handler = set_handler, - restore = restore, - hard_exit = hard_exit, - } + local ok, nixio = pcall(require, 'nixio') + if not ok or type(nixio) ~= 'table' or type(nixio.signal) ~= 'function' then + return nil + end + + local function signum(name) + return nixio['SIG' .. name] or SIGNAL_NUMBERS[name] + end + + local function set_handler(name, handler) + -- The documented nixio API only guarantees 'ign' and 'dfl'. Some + -- builds may accept Lua callbacks; use that only when it succeeds. + local ok2, old_or_err, eno = pcall(nixio.signal, signum(name), handler) + if not ok2 or old_or_err == nil or old_or_err == false then + return nil, tostring(old_or_err) .. (eno and (':' .. tostring(eno)) or '') + end + return old_or_err or true + end + + local function restore(name, old) + if old == true then + old = 'dfl' + end + pcall(nixio.signal, signum(name), old) + end + + local function hard_exit(name) + local sig = signum(name) + pcall(nixio.signal, sig, 'dfl') + if type(nixio.kill) == 'function' and type(nixio.getpid) == 'function' then + pcall(nixio.kill, nixio.getpid(), sig) + end + os.exit(128 + (sig or 0)) + end + + return { + name = 'nixio.signal', + set_handler = set_handler, + restore = restore, + hard_exit = hard_exit, + } end local function choose_backend() - return load_posix_backend() or load_nixio_backend() + return load_posix_backend() or load_nixio_backend() end ---Install a TERM/INT bridge from process signals to root-scope cancellation. @@ -115,70 +115,70 @@ end ---@return boolean ok ---@return string? err function M.install(scope, signals) - assert(scope and type(scope.spawn) == 'function', 'signal_bridge.install: scope required') - - signals = signals or { TERM = true, INT = true } - - local backend = choose_backend() - if not backend then - return false, 'no supported signal backend' - end - - local pending - local seen = {} - local installed = {} - - for name, enabled in pairs(signals) do - name = normalise_name(name) - if enabled then - local function handler() - if seen[name] then - backend.hard_exit(name) - return - end - seen[name] = true - pending = name - end - - if jit and jit.off then - pcall(jit.off, handler, true) - end - - local old, err = backend.set_handler(name, handler) - if not old then - for installed_name, old_handler in pairs(installed) do - backend.restore(installed_name, old_handler) - end - return false, 'failed to install signal handler for ' .. name .. ': ' .. tostring(err) - end - installed[name] = old - end - end - - scope:finally(function() - for name, old in pairs(installed) do - backend.restore(name, old) - end - end) - - local spawned, spawn_err = scope:spawn(function() - while true do - if pending then - scope:cancel('signal:' .. tostring(pending)) - return - end - sleep.sleep(0.1) - end - end) - - if not spawned then - for name, old in pairs(installed) do - backend.restore(name, old) - end - return false, 'failed to start signal watcher: ' .. tostring(spawn_err) - end - - return true, backend.name + assert(scope and type(scope.spawn) == 'function', 'signal_bridge.install: scope required') + + signals = signals or { TERM = true, INT = true } + + local backend = choose_backend() + if not backend then + return false, 'no supported signal backend' + end + + local pending + local seen = {} + local installed = {} + + for name, enabled in pairs(signals) do + name = normalise_name(name) + if enabled then + local function handler() + if seen[name] then + backend.hard_exit(name) + return + end + seen[name] = true + pending = name + end + + if jit and jit.off then + pcall(jit.off, handler, true) + end + + local old, err = backend.set_handler(name, handler) + if not old then + for installed_name, old_handler in pairs(installed) do + backend.restore(installed_name, old_handler) + end + return false, 'failed to install signal handler for ' .. name .. ': ' .. tostring(err) + end + installed[name] = old + end + end + + scope:finally(function() + for name, old in pairs(installed) do + backend.restore(name, old) + end + end) + + local spawned, spawn_err = scope:spawn(function() + while true do + if pending then + scope:cancel('signal:' .. tostring(pending)) + return + end + sleep.sleep(0.1) + end + end) + + if not spawned then + for name, old in pairs(installed) do + backend.restore(name, old) + end + return false, 'failed to start signal watcher: ' .. tostring(spawn_err) + end + + return true, backend.name end return M diff --git a/src/devicecode/support/wake_probe.lua b/src/devicecode/support/wake_probe.lua index 97cba8e3..4e4fe979 100644 --- a/src/devicecode/support/wake_probe.lua +++ b/src/devicecode/support/wake_probe.lua @@ -12,149 +12,149 @@ local Probe = {} Probe.__index = Probe local function env_truthy(name) - local v = os.getenv(name) - if v == nil or v == '' then return false end - v = tostring(v):lower() - return not (v == '0' or v == 'false' or v == 'no' or v == 'off') + local v = os.getenv(name) + if v == nil or v == '' then return false end + v = tostring(v):lower() + return not (v == '0' or v == 'false' or v == 'no' or v == 'off') end local function number_opt(v, default) - local n = tonumber(v) - if n == nil then return default end - return n + local n = tonumber(v) + if n == nil then return default end + return n end local function bool_opt(v, default) - if v == nil then return default end - if type(v) == 'boolean' then return v end - local s = tostring(v):lower() - if s == '1' or s == 'true' or s == 'yes' or s == 'on' then return true end - if s == '0' or s == 'false' or s == 'no' or s == 'off' then return false end - return default + if v == nil then return default end + if type(v) == 'boolean' then return v end + local s = tostring(v):lower() + if s == '1' or s == 'true' or s == 'yes' or s == 'on' then return true end + if s == '0' or s == 'false' or s == 'no' or s == 'off' then return false end + return default end function M.enabled(opts) - opts = opts or {} - if opts.wake_probe ~= nil then return bool_opt(opts.wake_probe, false) end - if opts.wake_probe_enabled ~= nil then return bool_opt(opts.wake_probe_enabled, false) end - return env_truthy('DEVICECODE_WAKE_PROBE') + opts = opts or {} + if opts.wake_probe ~= nil then return bool_opt(opts.wake_probe, false) end + if opts.wake_probe_enabled ~= nil then return bool_opt(opts.wake_probe_enabled, false) end + return env_truthy('DEVICECODE_WAKE_PROBE') end local function event_detail(ev) - if type(ev) ~= 'table' then return tostring(ev) end - return tostring(ev.kind or ev.type or ev.what or ev.source or ev.event or 'table') + if type(ev) ~= 'table' then return tostring(ev) end + return tostring(ev.kind or ev.type or ev.what or ev.source or ev.event or 'table') end local function add_top(out, key, count, elapsed) - out[#out + 1] = { - key = key, - count = count, - rate_per_s = elapsed > 0 and (count / elapsed) or count, - } + out[#out + 1] = { + key = key, + count = count, + rate_per_s = elapsed > 0 and (count / elapsed) or count, + } end local function sort_top(top) - table.sort(top, function (a, b) - if a.count ~= b.count then return a.count > b.count end - return tostring(a.key) < tostring(b.key) - end) + table.sort(top, function (a, b) + if a.count ~= b.count then return a.count > b.count end + return tostring(a.key) < tostring(b.key) + end) end function M.new(svc, opts) - opts = opts or {} - local enabled = M.enabled(opts) - local now = runtime.now() - return setmetatable({ - enabled = enabled, - svc = svc, - conn = opts.conn or (svc and svc.conn), - service = opts.service or (svc and svc.name) or opts.name or 'service', - name = opts.metric_name or opts.wake_probe_metric or 'wake_probe', - report_interval_s = number_opt(opts.report_interval_s or os.getenv('DEVICECODE_WAKE_PROBE_INTERVAL_S'), 10.0), - warn_rate_per_s = number_opt(opts.warn_rate_per_s or os.getenv('DEVICECODE_WAKE_PROBE_WARN_RATE'), 0), - max_top = math.max(1, math.floor(number_opt(opts.max_top or os.getenv('DEVICECODE_WAKE_PROBE_TOP'), 16))), - counts = {}, - total = 0, - since = now, - next_report = now + math.max(1.0, number_opt(opts.report_interval_s or os.getenv('DEVICECODE_WAKE_PROBE_INTERVAL_S'), 10.0)), - }, Probe) + opts = opts or {} + local enabled = M.enabled(opts) + local now = runtime.now() + return setmetatable({ + enabled = enabled, + svc = svc, + conn = opts.conn or (svc and svc.conn), + service = opts.service or (svc and svc.name) or opts.name or 'service', + name = opts.metric_name or opts.wake_probe_metric or 'wake_probe', + report_interval_s = number_opt(opts.report_interval_s or os.getenv('DEVICECODE_WAKE_PROBE_INTERVAL_S'), 10.0), + warn_rate_per_s = number_opt(opts.warn_rate_per_s or os.getenv('DEVICECODE_WAKE_PROBE_WARN_RATE'), 0), + max_top = math.max(1, math.floor(number_opt(opts.max_top or os.getenv('DEVICECODE_WAKE_PROBE_TOP'), 16))), + counts = {}, + total = 0, + since = now, + next_report = now + math.max(1.0, number_opt(opts.report_interval_s or os.getenv('DEVICECODE_WAKE_PROBE_INTERVAL_S'), 10.0)), + }, Probe) end function Probe:active() - return self.enabled == true + return self.enabled == true end function Probe:record(source, detail, n) - if self.enabled ~= true then return false end - source = tostring(source or 'wake') - local key = detail ~= nil and (source .. ':' .. tostring(detail)) or source - n = tonumber(n) or 1 - if n <= 0 then return false end - self.counts[key] = (self.counts[key] or 0) + n - self.total = self.total + n - return true + if self.enabled ~= true then return false end + source = tostring(source or 'wake') + local key = detail ~= nil and (source .. ':' .. tostring(detail)) or source + n = tonumber(n) or 1 + if n <= 0 then return false end + self.counts[key] = (self.counts[key] or 0) + n + self.total = self.total + n + return true end function Probe:record_event(source, ev) - if self.enabled ~= true then return false end - return self:record(source, event_detail(ev), 1) + if self.enabled ~= true then return false end + return self:record(source, event_detail(ev), 1) end function Probe:record_choice(which, ev) - if self.enabled ~= true then return false end - return self:record(tostring(which or 'choice'), event_detail(ev), 1) + if self.enabled ~= true then return false end + return self:record(tostring(which or 'choice'), event_detail(ev), 1) end function Probe:report_if_due(extra) - if self.enabled ~= true then return false end - local now = runtime.now() - if now < self.next_report then return false end - - local elapsed = now - self.since - if elapsed <= 0 then elapsed = 0.000001 end - - local top = {} - for key, count in pairs(self.counts) do add_top(top, key, count, elapsed) end - sort_top(top) - while #top > self.max_top do top[#top] = nil end - - local payload = { - service = self.service, - interval_s = elapsed, - total_wakes = self.total, - rate_per_s = self.total / elapsed, - top = top, - } - if type(extra) == 'table' then - for k, v in pairs(extra) do payload[k] = v end - end - - if self.svc and type(self.svc.obs_metric) == 'function' then - self.svc:obs_metric(self.name, payload) - elseif self.conn and type(self.conn.retain) == 'function' then - self.conn:retain({ 'obs', 'v1', self.service, 'metric', self.name }, payload) - elseif self.svc and type(self.svc.obs_log) == 'function' then - self.svc:obs_log('debug', payload) - end - - if self.warn_rate_per_s > 0 and payload.rate_per_s >= self.warn_rate_per_s - and self.svc and type(self.svc.obs_log) == 'function' then - local warn_payload = {} - for k, v in pairs(payload) do warn_payload[k] = v end - warn_payload.what = 'wake_probe_high_rate' - self.svc:obs_log('warn', warn_payload) - end - - self.counts = {} - self.total = 0 - self.since = now - self.next_report = now + math.max(1.0, self.report_interval_s) - return true + if self.enabled ~= true then return false end + local now = runtime.now() + if now < self.next_report then return false end + + local elapsed = now - self.since + if elapsed <= 0 then elapsed = 0.000001 end + + local top = {} + for key, count in pairs(self.counts) do add_top(top, key, count, elapsed) end + sort_top(top) + while #top > self.max_top do top[#top] = nil end + + local payload = { + service = self.service, + interval_s = elapsed, + total_wakes = self.total, + rate_per_s = self.total / elapsed, + top = top, + } + if type(extra) == 'table' then + for k, v in pairs(extra) do payload[k] = v end + end + + if self.svc and type(self.svc.obs_metric) == 'function' then + self.svc:obs_metric(self.name, payload) + elseif self.conn and type(self.conn.retain) == 'function' then + self.conn:retain({ 'obs', 'v1', self.service, 'metric', self.name }, payload) + elseif self.svc and type(self.svc.obs_log) == 'function' then + self.svc:obs_log('debug', payload) + end + + if self.warn_rate_per_s > 0 and payload.rate_per_s >= self.warn_rate_per_s + and self.svc and type(self.svc.obs_log) == 'function' then + local warn_payload = {} + for k, v in pairs(payload) do warn_payload[k] = v end + warn_payload.what = 'wake_probe_high_rate' + self.svc:obs_log('warn', warn_payload) + end + + self.counts = {} + self.total = 0 + self.since = now + self.next_report = now + math.max(1.0, self.report_interval_s) + return true end function Probe:record_and_report(source, detail, extra) - self:record(source, detail, 1) - return self:report_if_due(extra) + self:record(source, detail, 1) + return self:report_if_due(extra) end M.Probe = Probe diff --git a/src/devices/bigbox-ss.lua b/src/devices/bigbox-ss.lua index 9bb1b8bc..ca0d7e6a 100644 --- a/src/devices/bigbox-ss.lua +++ b/src/devices/bigbox-ss.lua @@ -1,16 +1,16 @@ -- Big Box v0.9 device configuration return { - services = { - "log", - "config", - "hal", - "gsm", - "time", - "net", - "metrics", - "system", - "ui" - -- ... other services specific to this device version - } + services = { + "log", + "config", + "hal", + "gsm", + "time", + "net", + "metrics", + "system", + "ui" + -- ... other services specific to this device version + } } diff --git a/src/devices/bigbox-v1-cm.lua b/src/devices/bigbox-v1-cm.lua index 0dd62664..2c98d190 100644 --- a/src/devices/bigbox-v1-cm.lua +++ b/src/devices/bigbox-v1-cm.lua @@ -1,18 +1,18 @@ -- Big Box CM v1.0 device configuration return { - services = { - "log", - "config", - "hal", - "gsm", - "time", - "net", - "metrics", - "system", - "ui", - "wifi", - "mcu_bridge", - "wired" - } + services = { + "log", + "config", + "hal", + "gsm", + "time", + "net", + "metrics", + "system", + "ui", + "wifi", + "mcu_bridge", + "wired" + } } diff --git a/src/devices/bigbox_tera.lua b/src/devices/bigbox_tera.lua index f9f94503..5762230e 100644 --- a/src/devices/bigbox_tera.lua +++ b/src/devices/bigbox_tera.lua @@ -1,18 +1,18 @@ -- Big Box v0.9 device configuration return { - services = { - "log", - "config", - "hal", - "gsm", - "time", - "net", - "metrics", - "system", - "ui", - "wifi", - "mcu_bridge" - -- ... other services specific to this device version - } + services = { + "log", + "config", + "hal", + "gsm", + "time", + "net", + "metrics", + "system", + "ui", + "wifi", + "mcu_bridge" + -- ... other services specific to this device version + } } diff --git a/src/devices/bigbox_v1-pr1.lua b/src/devices/bigbox_v1-pr1.lua index e68b5426..b218a9b7 100644 --- a/src/devices/bigbox_v1-pr1.lua +++ b/src/devices/bigbox_v1-pr1.lua @@ -1,10 +1,10 @@ -- Big Box v0.9 device configuration return { - services = { - "log", - "config", - "wired", - -- ... other services specific to this device version - } + services = { + "log", + "config", + "wired", + -- ... other services specific to this device version + } } diff --git a/src/devices/rich_bb_test.lua b/src/devices/rich_bb_test.lua index 7a9b3647..35df1980 100644 --- a/src/devices/rich_bb_test.lua +++ b/src/devices/rich_bb_test.lua @@ -1,12 +1,12 @@ -- Big Box v0.9 device configuration return { - services = { - "config", - "gsm", - "geo", - "time", - -- ... other services specific to this device version - } + services = { + "config", + "gsm", + "geo", + "time", + -- ... other services specific to this device version + } } diff --git a/src/devices/ryan-bb-dev.lua b/src/devices/ryan-bb-dev.lua index d1204f8d..20425d6f 100644 --- a/src/devices/ryan-bb-dev.lua +++ b/src/devices/ryan-bb-dev.lua @@ -1,12 +1,12 @@ -- Big Box v0.9 device configuration return { - services = { - "config", - "gsm", - "hal", - "metrics", - "system" - -- ... other services specific to this device version - } + services = { + "config", + "gsm", + "hal", + "metrics", + "system" + -- ... other services specific to this device version + } } diff --git a/src/rxilog.lua b/src/rxilog.lua index 47a64ef7..43847a35 100644 --- a/src/rxilog.lua +++ b/src/rxilog.lua @@ -16,81 +16,81 @@ log.level = "trace" local modes = { - { name = "trace", color = "\27[34m", }, - { name = "debug", color = "\27[36m", }, - { name = "info", color = "\27[32m", }, - { name = "warn", color = "\27[33m", }, - { name = "error", color = "\27[31m", }, - { name = "fatal", color = "\27[35m", }, + { name = "trace", color = "\27[34m", }, + { name = "debug", color = "\27[36m", }, + { name = "info", color = "\27[32m", }, + { name = "warn", color = "\27[33m", }, + { name = "error", color = "\27[31m", }, + { name = "fatal", color = "\27[35m", }, } local levels = {} for i, v in ipairs(modes) do - levels[v.name] = i + levels[v.name] = i end local round = function(x, increment) - increment = increment or 1 - x = x / increment - return (x > 0 and math.floor(x + .5) or math.ceil(x - .5)) * increment + increment = increment or 1 + x = x / increment + return (x > 0 and math.floor(x + .5) or math.ceil(x - .5)) * increment end local _tostring = tostring local tostring = function(...) - local t = {} - for i = 1, select('#', ...) do - local x = select(i, ...) - if type(x) == "number" then - x = round(x, .01) - end - t[#t + 1] = _tostring(x) - end - return table.concat(t, " ") + local t = {} + for i = 1, select('#', ...) do + local x = select(i, ...) + if type(x) == "number" then + x = round(x, .01) + end + t[#t + 1] = _tostring(x) + end + return table.concat(t, " ") end local format_log_message = function(level, lineinfo, msg) - return string.format("[%-6s%s] %s: %s", - level:upper(), - os.date(), - lineinfo, - msg) + return string.format("[%-6s%s] %s: %s", + level:upper(), + os.date(), + lineinfo, + msg) end for i, x in ipairs(modes) do - local nameupper = x.name:upper() - log[x.name] = function(...) - - -- Return early if we're below the log level - if i < levels[log.level] then - return - end - - local msg = tostring(...) - -- Set to three levels up the call stack i.e avoid this file and the log service - local info = debug.getinfo(3, "Sl") - local lineinfo = info.short_src .. ":" .. info.currentline - - -- Output to console - print(string.format("%s[%-6s%s]%s %s: %s", - log.usecolor and x.color or "", - nameupper, - os.date("%H:%M:%S"), - log.usecolor and "\27[0m" or "", - lineinfo, - msg)) - - -- Output to log file - if log.outfile then - local fp = io.open(log.outfile, "a") - local str = format_log_message(nameupper, lineinfo, msg) - fp:write(str) - fp:close() - end - end + local nameupper = x.name:upper() + log[x.name] = function(...) + + -- Return early if we're below the log level + if i < levels[log.level] then + return + end + + local msg = tostring(...) + -- Set to three levels up the call stack i.e avoid this file and the log service + local info = debug.getinfo(3, "Sl") + local lineinfo = info.short_src .. ":" .. info.currentline + + -- Output to console + print(string.format("%s[%-6s%s]%s %s: %s", + log.usecolor and x.color or "", + nameupper, + os.date("%H:%M:%S"), + log.usecolor and "\27[0m" or "", + lineinfo, + msg)) + + -- Output to log file + if log.outfile then + local fp = io.open(log.outfile, "a") + local str = format_log_message(nameupper, lineinfo, msg) + fp:write(str) + fp:close() + end + end end log.modes = modes diff --git a/src/services/gsm/apn.lua b/src/services/gsm/apn.lua index c1d9b241..9ffb5c52 100644 --- a/src/services/gsm/apn.lua +++ b/src/services/gsm/apn.lua @@ -10,128 +10,128 @@ g_authtypes["2"] = "chap" g_authtypes["3"] = "pap|chap" local function normalise_mnc(mnc) - if mnc == nil then return nil end - mnc = tostring(mnc) - if #mnc == 1 then return "0" .. mnc end - return mnc + if mnc == nil then return nil end + mnc = tostring(mnc) + if #mnc == 1 then return "0" .. mnc end + return mnc end -- deserialise the bundled APN database and return APNs for the SIM. local function get_apns(mcc, mnc) - local ok, apndb = pcall(function () return binser.r("etc/apns")[1] end) - if not ok or type(apndb) ~= 'table' then return {} end - local by_mcc = apndb[tostring(mcc or '')] - if type(by_mcc) ~= 'table' then return {} end - local apns = by_mcc[normalise_mnc(mnc) or tostring(mnc or '')] - if type(apns) ~= 'table' then return {} end - return copy(apns) + local ok, apndb = pcall(function () return binser.r("etc/apns")[1] end) + if not ok or type(apndb) ~= 'table' then return {} end + local by_mcc = apndb[tostring(mcc or '')] + if type(by_mcc) ~= 'table' then return {} end + local apns = by_mcc[normalise_mnc(mnc) or tostring(mnc or '')] + if type(apns) ~= 'table' then return {} end + return copy(apns) end local function build_connection_string(apn, roaming_allow) - if not apn or next(apn) == nil then return nil, "apn table empty" end - local a = {} - for k,v in pairs(apn) do - if k == "apn" then table.insert(a, "apn="..v) - elseif k == "user" then table.insert(a, "user="..v) - elseif k == "password" then table.insert(a, "password="..v) - elseif k == "authtype" and g_authtypes[tostring(v)] then table.insert(a, "allowed-auth="..g_authtypes[tostring(v)]) - end - end - if roaming_allow then table.insert(a, "allow-roaming=true") end - local conn_string = table.concat(a,",") - return conn_string, nil + if not apn or next(apn) == nil then return nil, "apn table empty" end + local a = {} + for k,v in pairs(apn) do + if k == "apn" then table.insert(a, "apn="..v) + elseif k == "user" then table.insert(a, "user="..v) + elseif k == "password" then table.insert(a, "password="..v) + elseif k == "authtype" and g_authtypes[tostring(v)] then table.insert(a, "allowed-auth="..g_authtypes[tostring(v)]) + end + end + if roaming_allow then table.insert(a, "allow-roaming=true") end + local conn_string = table.concat(a,",") + return conn_string, nil end local function mvno_rank(apn, imsi, spn, gid1, ranks) - ranks = ranks or { match = 1, plain = 2, mismatch = 4 } - if apn.mvno_type then - local match_data = tostring(apn.mvno_match_data or '') - if apn.mvno_type == "spn" and spn and string.find(tostring(spn), match_data, 1, true) then - return ranks.match - elseif apn.mvno_type == "gid" and gid1 and string.find(tostring(gid1), match_data, 1, true) then - return ranks.match - elseif apn.mvno_type == "imsi" and imsi and string.find(tostring(imsi), match_data, 1, true) then - return ranks.match - else - return ranks.mismatch - end - end - return ranks.plain + ranks = ranks or { match = 1, plain = 2, mismatch = 4 } + if apn.mvno_type then + local match_data = tostring(apn.mvno_match_data or '') + if apn.mvno_type == "spn" and spn and string.find(tostring(spn), match_data, 1, true) then + return ranks.match + elseif apn.mvno_type == "gid" and gid1 and string.find(tostring(gid1), match_data, 1, true) then + return ranks.match + elseif apn.mvno_type == "imsi" and imsi and string.find(tostring(imsi), match_data, 1, true) then + return ranks.match + else + return ranks.mismatch + end + end + return ranks.plain end local function rank(apns, imsi, spn, gid1, prefix, ranks) - local out_apns = {} - local rankings = {} - prefix = prefix or '' - for k, v in pairs(apns or {}) do - if type(v) == 'table' then - local name = prefix .. tostring(k) - out_apns[name] = copy(v) - table.insert(rankings, { name = name, rank = mvno_rank(v, imsi, spn, gid1, ranks) }) - end - end - return out_apns, rankings + local out_apns = {} + local rankings = {} + prefix = prefix or '' + for k, v in pairs(apns or {}) do + if type(v) == 'table' then + local name = prefix .. tostring(k) + out_apns[name] = copy(v) + table.insert(rankings, { name = name, rank = mvno_rank(v, imsi, spn, gid1, ranks) }) + end + end + return out_apns, rankings end local function custom_matches(apn, mcc, mnc) - if type(apn) ~= 'table' then return false end - return tostring(apn.mcc or '') == tostring(mcc or '') - and normalise_mnc(apn.mnc) == normalise_mnc(mnc) + if type(apn) ~= 'table' then return false end + return tostring(apn.mcc or '') == tostring(mcc or '') + and normalise_mnc(apn.mnc) == normalise_mnc(mnc) end local function custom_apns_for(custom_records, mcc, mnc) - local out = {} - if type(custom_records) ~= 'table' then return out end - for i, apn in ipairs(custom_records) do - if custom_matches(apn, mcc, mnc) then - out['custom-' .. tostring(i)] = apn - end - end - return out + local out = {} + if type(custom_records) ~= 'table' then return out end + for i, apn in ipairs(custom_records) do + if custom_matches(apn, mcc, mnc) then + out['custom-' .. tostring(i)] = apn + end + end + return out end local function add_default(apns, rankings) - apns.default = { apn = 'internet' } - table.insert(rankings, { name = 'default', rank = 4 }) + apns.default = { apn = 'internet' } + table.insert(rankings, { name = 'default', rank = 4 }) end local function merge_into(dst_apns, dst_rankings, src_apns, src_rankings) - for name, apn in pairs(src_apns or {}) do dst_apns[name] = apn end - for _, r in ipairs(src_rankings or {}) do dst_rankings[#dst_rankings + 1] = r end + for name, apn in pairs(src_apns or {}) do dst_apns[name] = apn end + for _, r in ipairs(src_rankings or {}) do dst_rankings[#dst_rankings + 1] = r end end local function get_ranked_apns(mcc, mnc, imsi, spn, gid1, custom_records) - if mnc == nil then return {}, {} end - mnc = normalise_mnc(mnc) - - local ranked_apns, rankings = {}, {} - - local custom_map = custom_apns_for(custom_records, mcc, mnc) - local custom_apns, custom_rankings = rank(custom_map, imsi, spn, gid1, '', { - match = 1, - plain = 1, - mismatch = 5, - }) - merge_into(ranked_apns, rankings, custom_apns, custom_rankings) - - local builtin = get_apns(mcc, mnc) - local builtin_apns, builtin_rankings = rank(builtin, imsi, spn, gid1, 'builtin-', { - match = 2, - plain = 3, - mismatch = 5, - }) - merge_into(ranked_apns, rankings, builtin_apns, builtin_rankings) - - add_default(ranked_apns, rankings) - table.sort(rankings, function (k1, k2) - if k1.rank == k2.rank then return tostring(k1.name) < tostring(k2.name) end - return k1.rank < k2.rank - end) - return ranked_apns, rankings + if mnc == nil then return {}, {} end + mnc = normalise_mnc(mnc) + + local ranked_apns, rankings = {}, {} + + local custom_map = custom_apns_for(custom_records, mcc, mnc) + local custom_apns, custom_rankings = rank(custom_map, imsi, spn, gid1, '', { + match = 1, + plain = 1, + mismatch = 5, + }) + merge_into(ranked_apns, rankings, custom_apns, custom_rankings) + + local builtin = get_apns(mcc, mnc) + local builtin_apns, builtin_rankings = rank(builtin, imsi, spn, gid1, 'builtin-', { + match = 2, + plain = 3, + mismatch = 5, + }) + merge_into(ranked_apns, rankings, builtin_apns, builtin_rankings) + + add_default(ranked_apns, rankings) + table.sort(rankings, function (k1, k2) + if k1.rank == k2.rank then return tostring(k1.name) < tostring(k2.name) end + return k1.rank < k2.rank + end) + return ranked_apns, rankings end return { - get_ranked_apns = get_ranked_apns, - build_connection_string = build_connection_string, - get_apns = get_apns, + get_ranked_apns = get_ranked_apns, + build_connection_string = build_connection_string, + get_apns = get_apns, } diff --git a/src/services/hal/backends/band/contract.lua b/src/services/hal/backends/band/contract.lua index 4decc87b..a9949cf4 100644 --- a/src/services/hal/backends/band/contract.lua +++ b/src/services/hal/backends/band/contract.lua @@ -2,8 +2,8 @@ ---All band backends must implement the functions listed in BAND_BACKEND_FUNCTIONS. local BAND_BACKEND_FUNCTIONS = { - 'clear', - 'apply', + 'clear', + 'apply', } ---Validate that a backend table implements all required functions. @@ -11,18 +11,18 @@ local BAND_BACKEND_FUNCTIONS = { ---@return boolean ok ---@return string err Empty string on success. local function validate(backend) - if type(backend) ~= 'table' then - return false, "backend must be a table" - end - for _, fn_name in ipairs(BAND_BACKEND_FUNCTIONS) do - if type(backend[fn_name]) ~= 'function' then - return false, "backend missing required function: " .. fn_name - end - end - return true, "" + if type(backend) ~= 'table' then + return false, "backend must be a table" + end + for _, fn_name in ipairs(BAND_BACKEND_FUNCTIONS) do + if type(backend[fn_name]) ~= 'function' then + return false, "backend missing required function: " .. fn_name + end + end + return true, "" end return { - BAND_BACKEND_FUNCTIONS = BAND_BACKEND_FUNCTIONS, - validate = validate, + BAND_BACKEND_FUNCTIONS = BAND_BACKEND_FUNCTIONS, + validate = validate, } diff --git a/src/services/hal/backends/band/provider.lua b/src/services/hal/backends/band/provider.lua index e452f051..f24f194b 100644 --- a/src/services/hal/backends/band/provider.lua +++ b/src/services/hal/backends/band/provider.lua @@ -4,28 +4,28 @@ local contract = require "services.hal.backends.band.contract" local BACKENDS = { - "services.hal.backends.band.providers.openwrt-dawn", + "services.hal.backends.band.providers.openwrt-dawn", } ---Instantiate a new backend from the first supported provider. ---@return table|nil backend instance ---@return string err "" on success local function new() - for _, path in ipairs(BACKENDS) do - local ok, provider = pcall(require, path) - if ok and provider.is_supported and provider.is_supported() then - local backend = provider.backend.new() - local valid, verr = contract.validate(backend) - if valid then - return backend, "" - else - return nil, "backend " .. path .. " failed contract check: " .. verr - end - end - end - return nil, "no supported band backend found on this device" + for _, path in ipairs(BACKENDS) do + local ok, provider = pcall(require, path) + if ok and provider.is_supported and provider.is_supported() then + local backend = provider.backend.new() + local valid, verr = contract.validate(backend) + if valid then + return backend, "" + else + return nil, "backend " .. path .. " failed contract check: " .. verr + end + end + end + return nil, "no supported band backend found on this device" end return { - new = new, + new = new, } diff --git a/src/services/hal/backends/band/providers/openwrt-dawn/impl.lua b/src/services/hal/backends/band/providers/openwrt-dawn/impl.lua index 0417bf8e..5ae57bbd 100644 --- a/src/services/hal/backends/band/providers/openwrt-dawn/impl.lua +++ b/src/services/hal/backends/band/providers/openwrt-dawn/impl.lua @@ -4,45 +4,45 @@ local uci = require "services.hal.backends.common.uci" local BAND_SECTION = { - ['2G'] = '802_11g', - ['5G'] = '802_11a', + ['2G'] = '802_11g', + ['5G'] = '802_11a', } local KICK_MODE_ID = { - none = 0, - compare = 1, - absolute = 2, - both = 3, + none = 0, + compare = 1, + absolute = 2, + both = 3, } local NETWORKING_METHOD_ID = { - broadcast = 0, - ['tcp+umdns'] = 2, - multicast = 2, - tcp = 3, + broadcast = 0, + ['tcp+umdns'] = 2, + multicast = 2, + tcp = 3, } local BAND_KICKING_KEYS = { - rssi_center = 'rssi_center', - rssi_reward_threshold = 'rssi_val', - rssi_reward = 'rssi', - rssi_penalty_threshold = 'low_rssi_val', - rssi_penalty = 'low_rssi', - rssi_weight = 'rssi_weight', - channel_util_reward_threshold = 'chan_util_val', - channel_util_reward = 'chan_util', - channel_util_penalty_threshold = 'max_chan_util_val', - channel_util_penalty = 'max_chan_util', + rssi_center = 'rssi_center', + rssi_reward_threshold = 'rssi_val', + rssi_reward = 'rssi', + rssi_penalty_threshold = 'low_rssi_val', + rssi_penalty = 'low_rssi', + rssi_weight = 'rssi_weight', + channel_util_reward_threshold = 'chan_util_val', + channel_util_reward = 'chan_util', + channel_util_penalty_threshold = 'max_chan_util_val', + channel_util_penalty = 'max_chan_util', } -- Fixed sections required in a clean DAWN config local REQUIRED_SECTIONS = { - { name = 'global', type = 'metric' }, - { name = '802_11g', type = 'metric' }, - { name = '802_11a', type = 'metric' }, - { name = 'gbltime', type = 'times' }, - { name = 'gblnet', type = 'network' }, - { name = 'localcfg', type = 'local' }, + { name = 'global', type = 'metric' }, + { name = '802_11g', type = 'metric' }, + { name = '802_11a', type = 'metric' }, + { name = 'gbltime', type = 'times' }, + { name = 'gblnet', type = 'network' }, + { name = 'localcfg', type = 'local' }, } ------------------------------------------------------------------------ @@ -55,7 +55,7 @@ BandBackend.__index = BandBackend ---@return BandBackend function BandBackend.new() - return setmetatable({}, BandBackend) + return setmetatable({}, BandBackend) end ---Reset DAWN's UCI config to a clean base state. @@ -63,170 +63,170 @@ end ---@return boolean ok ---@return string err function BandBackend:clear() - uci.ensure_started() - local session = uci.new_session() - - -- Delete each known managed section only if it already exists - for _, sec in ipairs(REQUIRED_SECTIONS) do - if uci.section_exists('dawn', sec.name) then - session:delete('dawn', sec.name) - end - end - - -- Re-create required sections with their type using named-section creation - for _, sec in ipairs(REQUIRED_SECTIONS) do - session:set('dawn', sec.name, sec.type) - end - - local ok, err = session:commit('dawn') - if not ok then - return false, "failed to clear DAWN config: " .. tostring(err) - end - - return true, "" + uci.ensure_started() + local session = uci.new_session() + + -- Delete each known managed section only if it already exists + for _, sec in ipairs(REQUIRED_SECTIONS) do + if uci.section_exists('dawn', sec.name) then + session:delete('dawn', sec.name) + end + end + + -- Re-create required sections with their type using named-section creation + for _, sec in ipairs(REQUIRED_SECTIONS) do + session:set('dawn', sec.name, sec.type) + end + + local ok, err = session:commit('dawn') + if not ok then + return false, "failed to clear DAWN config: " .. tostring(err) + end + + return true, "" end ---Apply the staged band config table to DAWN UCI and restart the daemon. ---@param staged table Band staged config as accumulated by the driver function BandBackend:apply(staged) - uci.ensure_started() - local session = uci.new_session() - - -- log_level - if staged.log_level ~= nil then - session:set('dawn', 'localcfg', 'log_level', staged.log_level) - end - - -- kicking - if staged.kicking then - local k = staged.kicking - if k.mode ~= nil then - session:set('dawn', 'global', 'kicking', - tostring(KICK_MODE_ID[k.mode] or 0)) - end - if k.bandwidth_threshold ~= nil then - session:set('dawn', 'global', 'bandwidth_threshold', k.bandwidth_threshold) - end - if k.kicking_threshold ~= nil then - session:set('dawn', 'global', 'kicking_threshold', k.kicking_threshold) - end - if k.evals_before_kick ~= nil then - session:set('dawn', 'global', 'min_number_to_kick', k.evals_before_kick) - end - end - - -- station counting - if staged.station_counting then - local sc = staged.station_counting - if sc.use_station_count ~= nil then - session:set('dawn', 'global', 'use_station_count', - sc.use_station_count and '1' or '0') - end - if sc.max_station_diff ~= nil then - session:set('dawn', 'global', 'max_station_diff', sc.max_station_diff) - end - end - - -- rrm_mode - if staged.rrm_mode ~= nil then - session:set('dawn', 'global', 'rrm_mode', staged.rrm_mode) - end - - -- neighbour reports - if staged.neighbour_reports then - local nr = staged.neighbour_reports - if nr.dyn_report_num ~= nil then - session:set('dawn', 'global', 'set_hostapd_nr', nr.dyn_report_num) - end - if nr.disassoc_report_len ~= nil then - session:set('dawn', 'global', 'disassoc_nr_length', nr.disassoc_report_len) - end - end - - -- legacy options (flat key-value under global) - if staged.legacy then - for key, value in pairs(staged.legacy) do - session:set('dawn', 'global', key, value) - end - end - - -- band priorities and kicking params - for band_key, section_name in pairs(BAND_SECTION) do - local bp = staged.band_priorities and staged.band_priorities[band_key] - if bp then - if bp.initial_score ~= nil then - session:set('dawn', section_name, 'initial_score', bp.initial_score) - end - end - - local bk = staged.band_kicking and staged.band_kicking[band_key] - if bk then - for driver_key, uci_key in pairs(BAND_KICKING_KEYS) do - if bk[driver_key] ~= nil then - session:set('dawn', section_name, uci_key, bk[driver_key]) - end - end - end - - local sb = staged.support_bonus and staged.support_bonus[band_key] - if sb then - for _, support in ipairs({'ht', 'vht'}) do - if sb[support] ~= nil then - session:set('dawn', section_name, support .. '_support', sb[support]) - end - end - end - end - - -- update frequencies - if staged.update_freq then - for key, freq in pairs(staged.update_freq) do - session:set('dawn', 'gbltime', 'update_' .. key, freq) - end - end - - -- client inactive kickoff (con_timeout) - if staged.con_timeout ~= nil then - session:set('dawn', 'gbltime', 'con_timeout', staged.con_timeout) - end - - -- cleanup timeouts - if staged.cleanup then - for key, timeout in pairs(staged.cleanup) do - session:set('dawn', 'gbltime', 'remove_' .. key, timeout) - end - end - - -- networking - if staged.networking then - local net = staged.networking - if net.method ~= nil then - local method_id = NETWORKING_METHOD_ID[net.method] - if method_id ~= nil then - session:set('dawn', 'gblnet', 'method', tostring(method_id)) - end - end - if net.ip ~= nil then - session:set('dawn', 'gblnet', 'tcp_ip', net.ip) - end - if net.port ~= nil then - session:set('dawn', 'gblnet', 'tcp_port', net.port) - end - if net.broadcast_port ~= nil then - session:set('dawn', 'gblnet', 'broadcast_port', net.broadcast_port) - end - if net.enable_encryption ~= nil then - session:set('dawn', 'gblnet', 'use_symm_enc', - net.enable_encryption and '1' or '0') - end - end - - local ok, err = session:commit('dawn', { { 'service', 'dawn', 'restart' } }) - if not ok then - error('apply commit failed: ' .. tostring(err)) - end + uci.ensure_started() + local session = uci.new_session() + + -- log_level + if staged.log_level ~= nil then + session:set('dawn', 'localcfg', 'log_level', staged.log_level) + end + + -- kicking + if staged.kicking then + local k = staged.kicking + if k.mode ~= nil then + session:set('dawn', 'global', 'kicking', + tostring(KICK_MODE_ID[k.mode] or 0)) + end + if k.bandwidth_threshold ~= nil then + session:set('dawn', 'global', 'bandwidth_threshold', k.bandwidth_threshold) + end + if k.kicking_threshold ~= nil then + session:set('dawn', 'global', 'kicking_threshold', k.kicking_threshold) + end + if k.evals_before_kick ~= nil then + session:set('dawn', 'global', 'min_number_to_kick', k.evals_before_kick) + end + end + + -- station counting + if staged.station_counting then + local sc = staged.station_counting + if sc.use_station_count ~= nil then + session:set('dawn', 'global', 'use_station_count', + sc.use_station_count and '1' or '0') + end + if sc.max_station_diff ~= nil then + session:set('dawn', 'global', 'max_station_diff', sc.max_station_diff) + end + end + + -- rrm_mode + if staged.rrm_mode ~= nil then + session:set('dawn', 'global', 'rrm_mode', staged.rrm_mode) + end + + -- neighbour reports + if staged.neighbour_reports then + local nr = staged.neighbour_reports + if nr.dyn_report_num ~= nil then + session:set('dawn', 'global', 'set_hostapd_nr', nr.dyn_report_num) + end + if nr.disassoc_report_len ~= nil then + session:set('dawn', 'global', 'disassoc_nr_length', nr.disassoc_report_len) + end + end + + -- legacy options (flat key-value under global) + if staged.legacy then + for key, value in pairs(staged.legacy) do + session:set('dawn', 'global', key, value) + end + end + + -- band priorities and kicking params + for band_key, section_name in pairs(BAND_SECTION) do + local bp = staged.band_priorities and staged.band_priorities[band_key] + if bp then + if bp.initial_score ~= nil then + session:set('dawn', section_name, 'initial_score', bp.initial_score) + end + end + + local bk = staged.band_kicking and staged.band_kicking[band_key] + if bk then + for driver_key, uci_key in pairs(BAND_KICKING_KEYS) do + if bk[driver_key] ~= nil then + session:set('dawn', section_name, uci_key, bk[driver_key]) + end + end + end + + local sb = staged.support_bonus and staged.support_bonus[band_key] + if sb then + for _, support in ipairs({'ht', 'vht'}) do + if sb[support] ~= nil then + session:set('dawn', section_name, support .. '_support', sb[support]) + end + end + end + end + + -- update frequencies + if staged.update_freq then + for key, freq in pairs(staged.update_freq) do + session:set('dawn', 'gbltime', 'update_' .. key, freq) + end + end + + -- client inactive kickoff (con_timeout) + if staged.con_timeout ~= nil then + session:set('dawn', 'gbltime', 'con_timeout', staged.con_timeout) + end + + -- cleanup timeouts + if staged.cleanup then + for key, timeout in pairs(staged.cleanup) do + session:set('dawn', 'gbltime', 'remove_' .. key, timeout) + end + end + + -- networking + if staged.networking then + local net = staged.networking + if net.method ~= nil then + local method_id = NETWORKING_METHOD_ID[net.method] + if method_id ~= nil then + session:set('dawn', 'gblnet', 'method', tostring(method_id)) + end + end + if net.ip ~= nil then + session:set('dawn', 'gblnet', 'tcp_ip', net.ip) + end + if net.port ~= nil then + session:set('dawn', 'gblnet', 'tcp_port', net.port) + end + if net.broadcast_port ~= nil then + session:set('dawn', 'gblnet', 'broadcast_port', net.broadcast_port) + end + if net.enable_encryption ~= nil then + session:set('dawn', 'gblnet', 'use_symm_enc', + net.enable_encryption and '1' or '0') + end + end + + local ok, err = session:commit('dawn', { { 'service', 'dawn', 'restart' } }) + if not ok then + error('apply commit failed: ' .. tostring(err)) + end end return { - new = BandBackend.new, + new = BandBackend.new, } diff --git a/src/services/hal/backends/band/providers/openwrt-dawn/init.lua b/src/services/hal/backends/band/providers/openwrt-dawn/init.lua index 853e8c2f..18b648ce 100644 --- a/src/services/hal/backends/band/providers/openwrt-dawn/init.lua +++ b/src/services/hal/backends/band/providers/openwrt-dawn/init.lua @@ -4,12 +4,12 @@ local file = require "fibers.io.file" ---Check whether DAWN is installed and its UCI config is accessible. ---@return boolean local function is_supported() - local f, _ = file.open('/etc/config/dawn', 'r') - if f then f:close() return true end - return false + local f, _ = file.open('/etc/config/dawn', 'r') + if f then f:close() return true end + return false end return { - is_supported = is_supported, - backend = impl, + is_supported = is_supported, + backend = impl, } diff --git a/src/services/hal/backends/modem/at.lua b/src/services/hal/backends/modem/at.lua index c640d588..20881c9b 100644 --- a/src/services/hal/backends/modem/at.lua +++ b/src/services/hal/backends/modem/at.lua @@ -33,10 +33,10 @@ local scope = require 'fibers.scope' -- end local function trim(input) - -- Pattern matches non-printable characters and spaces at the start and end of the string - -- %c matches control characters, %s matches all whitespace characters - -- %z matches the character with representation 0x00 (NUL byte) - return (input:gsub("^[%c%s%z]+", ""):gsub("[%c%s%z]+$", "")) + -- Pattern matches non-printable characters and spaces at the start and end of the string + -- %c matches control characters, %s matches all whitespace characters + -- %z matches the character with representation 0x00 (NUL byte) + return (input:gsub("^[%c%s%z]+", ""):gsub("[%c%s%z]+$", "")) end ---@class AT @@ -62,65 +62,65 @@ local at = {} ---@param opts ATSendOpts? ---@return Op -- yields (ATResponseLine[]?, string?) function at.send_op(port, command, opts) - local terminal_patterns = (opts and opts.terminal_patterns) or {} - - return scope.run_op(function(s) - local reader, rd_err = file.open(port, "r") - if not reader then - return nil, "error opening AT read port: " .. rd_err - end - - -- Centralised cleanup: reader is closed however this scope exits - -- (success, AT error, unhandled error, or cancelled by losing a race). - s:finally(function() reader:close() end) - - local writer, wr_err = file.open(port, "w") - if not writer then - return nil, "error opening AT write port: " .. wr_err - end - - writer:write(command .. '\r') - writer:close() - - local res = {} - while true do - local line, read_err = s:perform(reader:read_line_op()) - - if not line then - return nil, read_err or "unknown error" - end - - line = trim(line) - - -- Built-in terminals - if line:find("^OK$") then - return res, nil - elseif line:find("^ERROR$") then - return res, "error" - else - local error_code = line:match("^%+CME ERROR: (%d+)$") - or line:match("^%+CMS ERROR: (%d+)$") - if error_code then - return res, error_code - end - end - - -- User-supplied terminal patterns - for _, tp in ipairs(terminal_patterns) do - if line:find(tp.pattern) then - table.insert(res, line) - return res, tp.is_error and line or nil - end - end - - if #line > 0 then table.insert(res, line) end - end - end):wrap(function(st, _report, a, b) - if st == 'ok' then return a, b end - if st == 'cancelled' then return nil, 'cancelled' end - -- 'failed': a is the primary error string - return nil, a or "AT command failed" - end) + local terminal_patterns = (opts and opts.terminal_patterns) or {} + + return scope.run_op(function(s) + local reader, rd_err = file.open(port, "r") + if not reader then + return nil, "error opening AT read port: " .. rd_err + end + + -- Centralised cleanup: reader is closed however this scope exits + -- (success, AT error, unhandled error, or cancelled by losing a race). + s:finally(function() reader:close() end) + + local writer, wr_err = file.open(port, "w") + if not writer then + return nil, "error opening AT write port: " .. wr_err + end + + writer:write(command .. '\r') + writer:close() + + local res = {} + while true do + local line, read_err = s:perform(reader:read_line_op()) + + if not line then + return nil, read_err or "unknown error" + end + + line = trim(line) + + -- Built-in terminals + if line:find("^OK$") then + return res, nil + elseif line:find("^ERROR$") then + return res, "error" + else + local error_code = line:match("^%+CME ERROR: (%d+)$") + or line:match("^%+CMS ERROR: (%d+)$") + if error_code then + return res, error_code + end + end + + -- User-supplied terminal patterns + for _, tp in ipairs(terminal_patterns) do + if line:find(tp.pattern) then + table.insert(res, line) + return res, tp.is_error and line or nil + end + end + + if #line > 0 then table.insert(res, line) end + end + end):wrap(function(st, _report, a, b) + if st == 'ok' then return a, b end + if st == 'cancelled' then return nil, 'cancelled' end + -- 'failed': a is the primary error string + return nil, a or "AT command failed" + end) end return at diff --git a/src/services/hal/backends/modem/contract.lua b/src/services/hal/backends/modem/contract.lua index 33c123a4..1943b82a 100644 --- a/src/services/hal/backends/modem/contract.lua +++ b/src/services/hal/backends/modem/contract.lua @@ -1,96 +1,96 @@ local function list_to_map(list) - local map = {} - for _, item in ipairs(list) do - map[item] = true - end - return map + local map = {} + for _, item in ipairs(list) do + map[item] = true + end + return map end local BACKEND_FUNCTIONS = list_to_map { - -- Grouped reads - "read_identity", - "read_ports", - "read_sim_info", - "read_network_info", - "read_signal", - "read_traffic", + -- Grouped reads + "read_identity", + "read_ports", + "read_sim_info", + "read_network_info", + "read_signal", + "read_traffic", - -- Private reads - "_read_firmware", + -- Private reads + "_read_firmware", - -- State monitoring - "start_state_monitor", - "stop_state_monitor_op", - "stop_state_monitor", - "monitor_state_op", + -- State monitoring + "start_state_monitor", + "stop_state_monitor_op", + "stop_state_monitor", + "monitor_state_op", - -- SIM monitoring - "start_sim_presence_monitor", - "stop_sim_presence_monitor_op", - "stop_sim_presence_monitor", - "wait_for_sim_present_op", - "wait_for_sim_present", - "is_sim_present", - "trigger_sim_presence_check", + -- SIM monitoring + "start_sim_presence_monitor", + "stop_sim_presence_monitor_op", + "stop_sim_presence_monitor", + "wait_for_sim_present_op", + "wait_for_sim_present", + "is_sim_present", + "trigger_sim_presence_check", - -- Control operations - "enable", - "disable", - "reset", - "connect", - "disconnect", - "inhibit", - "uninhibit", - "uninhibit_op", - "shutdown_op", - "shutdown", - "terminate", - "set_signal_update_interval" + -- Control operations + "enable", + "disable", + "reset", + "connect", + "disconnect", + "inhibit", + "uninhibit", + "uninhibit_op", + "shutdown_op", + "shutdown", + "terminate", + "set_signal_update_interval" } local MONITOR_FUNCTIONS = list_to_map { - "next_event_op", - "shutdown_op", - "terminate", + "next_event_op", + "shutdown_op", + "terminate", } --- Check that a modem monitor provides all required functions and no extras. ---@param monitor ModemMonitor ---@return string error local function validate_monitor(monitor) - for func in pairs(MONITOR_FUNCTIONS) do - if type(monitor[func]) ~= "function" then - return "Missing required function: " .. func - end - end - for key, value in pairs(monitor) do - if type(value) == "function" and not MONITOR_FUNCTIONS[key] then - return "Monitor provides unsupported function: " .. key - end - end - return "" + for func in pairs(MONITOR_FUNCTIONS) do + if type(monitor[func]) ~= "function" then + return "Missing required function: " .. func + end + end + for key, value in pairs(monitor) do + if type(value) == "function" and not MONITOR_FUNCTIONS[key] then + return "Monitor provides unsupported function: " .. key + end + end + return "" end --- Check that a modem backend provides all required functions and no more ---@param backend ModemBackend ---@return string error local function validate(backend) - for func, _ in pairs(BACKEND_FUNCTIONS) do - if type(backend[func]) ~= "function" then - return "Missing required function: " .. func - end - end + for func, _ in pairs(BACKEND_FUNCTIONS) do + if type(backend[func]) ~= "function" then + return "Missing required function: " .. func + end + end - for key, value in pairs(backend) do - if type(value) == "function" and not BACKEND_FUNCTIONS[key] then - return "Backend provides unsupported function: " .. key - end - end + for key, value in pairs(backend) do + if type(value) == "function" and not BACKEND_FUNCTIONS[key] then + return "Backend provides unsupported function: " .. key + end + end - return "" + return "" end return { - validate = validate, - validate_monitor = validate_monitor, + validate = validate, + validate_monitor = validate_monitor, } diff --git a/src/services/hal/backends/modem/models/fibocom.lua b/src/services/hal/backends/modem/models/fibocom.lua index 1510e57f..b4888691 100644 --- a/src/services/hal/backends/modem/models/fibocom.lua +++ b/src/services/hal/backends/modem/models/fibocom.lua @@ -2,5 +2,5 @@ local function add_model_funcs(_, _) end return { - add_model_funcs = add_model_funcs + add_model_funcs = add_model_funcs } diff --git a/src/services/hal/backends/modem/models/quectel.lua b/src/services/hal/backends/modem/models/quectel.lua index 578d7cd9..850fa3af 100644 --- a/src/services/hal/backends/modem/models/quectel.lua +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -8,35 +8,35 @@ local at = require "services.hal.backends.modem.at" local SIM_POLL_INTERVAL = 5 local function firmware_version_string(fwversion) - if not fwversion then - return nil - end - local version_string = string.match(fwversion, "^%w+_(%w+%.%w+)") - return version_string or fwversion + if not fwversion then + return nil + end + local version_string = string.match(fwversion, "^%w+_(%w+%.%w+)") + return version_string or fwversion end local function firmware_version_code(version_str) - local major, minor = string.match(version_str or "", "^(%w+)%.(%w+)") - local major_num = tonumber(major, 16) - local minor_num = tonumber(minor) - if major_num and minor_num then - return major_num * 1000 + minor_num - end - return nil, "Invalid firmware version format" + local major, minor = string.match(version_str or "", "^(%w+)%.(%w+)") + local major_num = tonumber(major, 16) + local minor_num = tonumber(minor) + if major_num and minor_num then + return major_num * 1000 + minor_num + end + return nil, "Invalid firmware version format" end ---@param out string ---@return boolean present ---@return string error local function parse_card_status(out) - if not out or out == "" then - return false, "Empty output" - end - local card_state = out:match("Card state:%s*'([^']+)'") - if not card_state then - return false, "Could not parse card state from --uim-get-card-status output" - end - return card_state == "present", "" + if not out or out == "" then + return false, "Empty output" + end + local card_state = out:match("Card state:%s*'([^']+)'") + if not card_state then + return false, "Could not parse card state from --uim-get-card-status output" + end + return card_state == "present", "" end --- Helper for checking if a modem runs "legacy" firmware @@ -44,194 +44,194 @@ end ---@param firmware_fn function ---@return boolean local function is_legacy_modem(model, firmware_fn) - --- all em06 devices are legacy - if model == 'em06' then - return true - end - --- only em06 and eg25 devices have capability to be legacy - if model ~= 'eg25' then - return false - end + --- all em06 devices are legacy + if model == 'em06' then + return true + end + --- only em06 and eg25 devices have capability to be legacy + if model ~= 'eg25' then + return false + end - local firmware_version = firmware_fn() - if not firmware_version or firmware_version == "" then - return false - end + local firmware_version = firmware_fn() + if not firmware_version or firmware_version == "" then + return false + end - local version_code = firmware_version_code(firmware_version_string(firmware_version)) - return version_code ~= nil and version_code <= firmware_version_code("01.002") + local version_code = firmware_version_code(firmware_version_string(firmware_version)) + return version_code ~= nil and version_code <= firmware_version_code("01.002") end local funcs = { - { - name = '_read_firmware', - conditionals = { - function(_, identity_info) - return identity_info.model == 'rm520n' - end - }, - func = function(identity) - local AT_TIMEOUT = 10 - local at_send_op = at.send_op(identity.at_port, "AT+QGMR", { - terminal_patterns = { - { pattern = "^%w+_%w+%.%w+%.%w+%.%w+$", is_error = false } - } - }) - local source, resp, err = fibers.perform(op.named_choice({ - at = at_send_op, - timeout = sleep.sleep_op(AT_TIMEOUT) - })) + { + name = '_read_firmware', + conditionals = { + function(_, identity_info) + return identity_info.model == 'rm520n' + end + }, + func = function(identity) + local AT_TIMEOUT = 10 + local at_send_op = at.send_op(identity.at_port, "AT+QGMR", { + terminal_patterns = { + { pattern = "^%w+_%w+%.%w+%.%w+%.%w+$", is_error = false } + } + }) + local source, resp, err = fibers.perform(op.named_choice({ + at = at_send_op, + timeout = sleep.sleep_op(AT_TIMEOUT) + })) - if err then - return nil, "Failed to get firmware version: " .. err - end + if err then + return nil, "Failed to get firmware version: " .. err + end - if source == "timeout" then - return nil, "Timed out while getting firmware version" - elseif source == "at" and resp[#resp] then - local firmware_version = string.match(resp[#resp], "([%w]+_[%w]+%.[%w]+%.[%w]+%.[%w]+)") - if firmware_version then - return firmware_version, "" - else - return nil, "Firmware version not found in AT response" - end - end - end - }, - { - name = 'connect', - conditionals = { - function(backend, identity_info) - local model = identity_info and identity_info.model or nil - return model == 'rm520n' and backend.base == "linux_mm" - end - }, - func = function(backend, connection_string) - local st, _, result_or_err = fibers.run_scope(function() - local cmd_clear = exec.command { - "mmcli", "-m", backend.identity.address, "--3gpp-set-initial-eps-bearer-settings=apn=", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, code, _, err = fibers.perform(cmd_clear:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error(err or "Failed to clear initial bearer settings") - end - local connect_cmd = exec.command { - "mmcli", "-m", backend.identity.address, "--simple-connect=" .. connection_string, - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, conn_status, conn_code, _, conn_err = fibers.perform(connect_cmd:combined_output_op()) - if conn_status ~= "exited" or conn_code ~= 0 then - error(conn_err or "Failed to connect") - end - return out - end) - if st == "ok" then - return result_or_err, "" - end - return false, result_or_err or "Command failed" - end - }, - { - name = 'wait_for_sim_present_op', - -- NOTE: _read_firmware must be listed before this hook in funcs so that any - -- model-specific override is already installed when this conditional runs. - conditionals = { - function(backend, identity_info) - return is_legacy_modem( - identity_info.model, - function() - return backend._read_firmware(backend.identity) - end - ) - end - }, - func = function(backend) - ---@cast backend ModemBackend - return op.guard(function() - return scope.run_op(function(s) - s:perform(sleep.sleep_op(SIM_POLL_INTERVAL)) - local present, err = backend:is_sim_present() - if err ~= "" then - return false, "Failed to poll SIM presence: " .. err - end + if source == "timeout" then + return nil, "Timed out while getting firmware version" + elseif source == "at" and resp[#resp] then + local firmware_version = string.match(resp[#resp], "([%w]+_[%w]+%.[%w]+%.[%w]+%.[%w]+)") + if firmware_version then + return firmware_version, "" + else + return nil, "Firmware version not found in AT response" + end + end + end + }, + { + name = 'connect', + conditionals = { + function(backend, identity_info) + local model = identity_info and identity_info.model or nil + return model == 'rm520n' and backend.base == "linux_mm" + end + }, + func = function(backend, connection_string) + local st, _, result_or_err = fibers.run_scope(function() + local cmd_clear = exec.command { + "mmcli", "-m", backend.identity.address, "--3gpp-set-initial-eps-bearer-settings=apn=", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, code, _, err = fibers.perform(cmd_clear:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error(err or "Failed to clear initial bearer settings") + end + local connect_cmd = exec.command { + "mmcli", "-m", backend.identity.address, "--simple-connect=" .. connection_string, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, conn_status, conn_code, _, conn_err = fibers.perform(connect_cmd:combined_output_op()) + if conn_status ~= "exited" or conn_code ~= 0 then + error(conn_err or "Failed to connect") + end + return out + end) + if st == "ok" then + return result_or_err, "" + end + return false, result_or_err or "Command failed" + end + }, + { + name = 'wait_for_sim_present_op', + -- NOTE: _read_firmware must be listed before this hook in funcs so that any + -- model-specific override is already installed when this conditional runs. + conditionals = { + function(backend, identity_info) + return is_legacy_modem( + identity_info.model, + function() + return backend._read_firmware(backend.identity) + end + ) + end + }, + func = function(backend) + ---@cast backend ModemBackend + return op.guard(function() + return scope.run_op(function(s) + s:perform(sleep.sleep_op(SIM_POLL_INTERVAL)) + local present, err = backend:is_sim_present() + if err ~= "" then + return false, "Failed to poll SIM presence: " .. err + end - return present, "" - end) - :wrap(function(st, _, ...) - if st == 'ok' then - return ... - elseif st == 'cancelled' then - return false, "cancelled" - else - return false, (... or "SIM poll failed") - end - end) - end) - end - }, - { - name = 'is_sim_present', - conditionals = { - function(backend, identity_info) - ---@cast backend ModemBackend - ---@cast identity_info ModemIdentityInfo - local is_legacy = is_legacy_modem( - identity_info.model, - function() - local firmware = backend._read_firmware(backend.identity) - return firmware - end - ) and identity_info.mode == "qmi" - return is_legacy - end - }, - func = function(backend) - ---@cast backend ModemBackend - local st, _, present_or_err = fibers.run_scope(function() - local cmd = exec.command { - "qmicli", "-p", "-d", backend.identity.mode_port, "--uim-get-card-status", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error("Failed to execute qmicli --uim-get-card-status: " .. tostring(err)) - end + return present, "" + end) + :wrap(function(st, _, ...) + if st == 'ok' then + return ... + elseif st == 'cancelled' then + return false, "cancelled" + else + return false, (... or "SIM poll failed") + end + end) + end) + end + }, + { + name = 'is_sim_present', + conditionals = { + function(backend, identity_info) + ---@cast backend ModemBackend + ---@cast identity_info ModemIdentityInfo + local is_legacy = is_legacy_modem( + identity_info.model, + function() + local firmware = backend._read_firmware(backend.identity) + return firmware + end + ) and identity_info.mode == "qmi" + return is_legacy + end + }, + func = function(backend) + ---@cast backend ModemBackend + local st, _, present_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", backend.identity.mode_port, "--uim-get-card-status", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("Failed to execute qmicli --uim-get-card-status: " .. tostring(err)) + end - local present, parse_err = parse_card_status(out) - if parse_err ~= "" then - error("Failed to parse qmicli output: " .. tostring(parse_err)) - end - return present - end) + local present, parse_err = parse_card_status(out) + if parse_err ~= "" then + error("Failed to parse qmicli output: " .. tostring(parse_err)) + end + return present + end) - if st == "ok" then - return present_or_err, "" - end - return false, present_or_err or "Unknown error" - end - }, + if st == "ok" then + return present_or_err, "" + end + return false, present_or_err or "Unknown error" + end + }, } ---@param backend ModemBackend ---@param identity_info ModemIdentityInfo local function add_model_funcs(backend, identity_info) - for _, func_def in ipairs(funcs) do - for _, cond in ipairs(func_def.conditionals) do - if cond(backend, identity_info) then - backend[func_def.name] = func_def.func - break - end - end - end + for _, func_def in ipairs(funcs) do + for _, cond in ipairs(func_def.conditionals) do + if cond(backend, identity_info) then + backend[func_def.name] = func_def.func + break + end + end + end end return { - add_model_funcs = add_model_funcs + add_model_funcs = add_model_funcs } diff --git a/src/services/hal/backends/modem/modes/mbim.lua b/src/services/hal/backends/modem/modes/mbim.lua index b783107f..67924195 100644 --- a/src/services/hal/backends/modem/modes/mbim.lua +++ b/src/services/hal/backends/modem/modes/mbim.lua @@ -2,5 +2,5 @@ local function add_mode_funcs(_) end return { - add_mode_funcs = add_mode_funcs + add_mode_funcs = add_mode_funcs } diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua index 97db6d4f..92087807 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -32,16 +32,16 @@ local process_flags = require "services.hal.backends.modem.process_flags" ---@return string card_status ---@return string error local function parse_slot_status(status) - if not status or status == "" then - return "", "Command closed" - end - for card_status, slot_status in status:gmatch("Card status:%s*(%S+).-Slot status:%s*(%S+)") do - if slot_status == "active" then - return card_status, "" - end - end - - return "", 'could not parse (no active slot or invalid string format)' + if not status or status == "" then + return "", "Command closed" + end + for card_status, slot_status in status:gmatch("Card status:%s*(%S+).-Slot status:%s*(%S+)") do + if slot_status == "active" then + return card_status, "" + end + end + + return "", 'could not parse (no active slot or invalid string format)' end ---@param identity ModemIdentity @@ -49,91 +49,91 @@ end ---@return string? ---@return string error local function read_home_network_info(identity) - local st, _, result_or_err = fibers.run_scope(function() - local cmd = exec.command { - "qmicli", "-p", "-d", identity.mode_port, "--nas-get-home-network", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error("Failed to execute qmicli command: --nas-get-home-network, reason: " .. tostring(err)) - end - - local mcc = out:match("MCC:%s+'(%d+)'") - local mnc = out:match("MNC:%s+'(%d+)'") - if not mcc or not mnc then - error("Failed to parse qmicli output: " .. tostring(out)) - end - - return { mcc = mcc, mnc = mnc } - end) - - if st == "ok" then - return result_or_err.mcc, result_or_err.mnc, "" - end - return nil, nil, result_or_err or "Unknown error" + local st, _, result_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", identity.mode_port, "--nas-get-home-network", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("Failed to execute qmicli command: --nas-get-home-network, reason: " .. tostring(err)) + end + + local mcc = out:match("MCC:%s+'(%d+)'") + local mnc = out:match("MNC:%s+'(%d+)'") + if not mcc or not mnc then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + return { mcc = mcc, mnc = mnc } + end) + + if st == "ok" then + return result_or_err.mcc, result_or_err.mnc, "" + end + return nil, nil, result_or_err or "Unknown error" end ---@param identity ModemIdentity ---@return string? ---@return string error local function read_gid1(identity) - local st, _, gid1_or_err = fibers.run_scope(function() - local cmd = exec.command { - "qmicli", "-p", "-d", identity.mode_port, "--uim-read-transparent=0x3F00,0x7FFF,0x6F3E", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error("Failed to execute qmicli command: --uim-read-transparent, reason: " .. tostring(err)) - end - - local gid1 = out:match("%s+(%S+)%s*$") - if not gid1 then - error("Failed to parse qmicli output: " .. tostring(out)) - end - - return gid1:gsub(":", "") - end) - - if st == "ok" then - return gid1_or_err, "" - end - return nil, gid1_or_err or "Unknown error" + local st, _, gid1_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", identity.mode_port, "--uim-read-transparent=0x3F00,0x7FFF,0x6F3E", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("Failed to execute qmicli command: --uim-read-transparent, reason: " .. tostring(err)) + end + + local gid1 = out:match("%s+(%S+)%s*$") + if not gid1 then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + return gid1:gsub(":", "") + end) + + if st == "ok" then + return gid1_or_err, "" + end + return nil, gid1_or_err or "Unknown error" end ---@param identity ModemIdentity ---@return string? ---@return string error local function read_rf_band_info(identity) - local st, _, band_or_err = fibers.run_scope(function() - local cmd = exec.command { - "qmicli", "-p", "-d", identity.mode_port, "--nas-get-rf-band-info", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error("Failed to execute qmicli command: --nas-get-rf-band-info, reason: " .. tostring(err)) - end - - local active_band_class = out:match("Active Band Class:%s*'([^']+)'") - if not active_band_class then - error("Failed to parse qmicli output: " .. tostring(out)) - end - - return active_band_class - end) - - if st == "ok" then - return band_or_err, "" - end - return nil, band_or_err or "Unknown error" + local st, _, band_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", identity.mode_port, "--nas-get-rf-band-info", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("Failed to execute qmicli command: --nas-get-rf-band-info, reason: " .. tostring(err)) + end + + local active_band_class = out:match("Active Band Class:%s*'([^']+)'") + if not active_band_class then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + return active_band_class + end) + + if st == "ok" then + return band_or_err, "" + end + return nil, band_or_err or "Unknown error" end ---@param info ModemSimInfo @@ -142,241 +142,241 @@ end ---@return ModemSimInfo? ---@return string error local function sim_info_with_gid1(info, gid1, gid_err) - if gid_err ~= "" then - gid1 = nil - end - - return modem_types.new.ModemSimInfo( - info.sim, - info.iccid, - info.imsi, - gid1, - info.sim_lock, - info.sim_lock_retries, - info.modem_state - ) + if gid_err ~= "" then + gid1 = nil + end + + return modem_types.new.ModemSimInfo( + info.sim, + info.iccid, + info.imsi, + gid1, + info.sim_lock, + info.sim_lock_retries, + info.modem_state + ) end local function add_mode_funcs(ModemBackend) - ---@cast ModemBackend ModemBackend - local base_read_network_info = ModemBackend.read_network_info - local base_read_sim_info = ModemBackend.read_sim_info - - ---@return boolean ok - ---@return string error - function ModemBackend:start_sim_presence_monitor() - if self.sim_present then - return false, "Already monitoring sim presence" - end - - local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-monitor-slot-status", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } - - local stdout, err = cmd:stdout_stream() - if not stdout then - return false, "Failed to start QMI monitor: " .. tostring(err) - end - self.sim_present = { - cmd = cmd, - stdout = stdout - } - return true, "" - end - - ---Stop the long-running QMI SIM presence monitor. - ---@param timeout number? - ---@return Op - function ModemBackend:stop_sim_presence_monitor_op(timeout) - return op.guard(function() - local rec = self.sim_present - self.sim_present = nil - - if not rec then - return op.always(true, "") - end - - if rec.stdout then - pcall(function() rec.stdout:terminate("modem_sim_monitor_stop") end) - end - - if not rec.cmd then - return op.always(true, "") - end - - return rec.cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) - if status == "exited" or status == "signalled" then - return true, "" - end - return false, err or ("SIM monitor shutdown failed: " .. tostring(status)) - end) - end) - end - - ---@param timeout number? - ---@return boolean ok - ---@return string error - function ModemBackend:stop_sim_presence_monitor(timeout) - return fibers.perform(self:stop_sim_presence_monitor_op(timeout)) - end - - ---@return Op - function ModemBackend:wait_for_sim_present_op() - return op.guard(function() - return scope.run_op(function(s) - if not self.sim_present then - return false, "Sim presence monitor not started" - end - while true do - local chunk = s:perform(self.sim_present.stdout:read_line_op({ - terminator = "Slot status: active", - keep_terminator = true, - })) - - if not chunk then - return false, "Stream closed" - end - - local card_status, parse_err = parse_slot_status(chunk) - if parse_err == "" and card_status ~= "" then - local sim_present = card_status == "present" - return sim_present, "" - end - end - end) - :wrap(function(st, _, ...) - if st == 'ok' then - return ... - elseif st == 'cancelled' then - return false, "cancelled" - else - return false, (... or "QMI monitor failed") - end - end) - end) - end - - ---@return boolean sim_present - function ModemBackend:wait_for_sim_present() - return fibers.perform(self:wait_for_sim_present_op()) - end - - ---@return boolean sim_present - ---@return string error - function ModemBackend:is_sim_present() - local st, _, present_or_err = fibers.run_scope(function() - local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-get-slot-status", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error("Failed to execute qmicli command: " .. tostring(err)) - end - - local state, parse_err = parse_slot_status(out) - if parse_err ~= "" then - error("Failed to parse qmicli output: " .. tostring(parse_err)) - end - return state == 'present' - end) - - if st == "ok" then - return present_or_err, "" - end - return false, present_or_err or "Unknown error" - end - - ---@param cooldown number? - ---@return boolean ok - ---@return string error - function ModemBackend:trigger_sim_presence_check(cooldown) - local st, _, err = fibers.run_scope(function() - local errors = {} - cooldown = cooldown or 1 - local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-off=1", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } - local _, status, code, _, power_err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - table.insert(errors, "Failed to execute qmicli power off command: " .. tostring(power_err)) - end - - sleep.sleep(cooldown) - - local cmd_on = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-on=1", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } - local _, status_on, code_on, _, err_on = fibers.perform(cmd_on:combined_output_op()) - if status_on ~= "exited" or code_on ~= 0 then - table.insert(errors, "Failed to execute qmicli power on command: " .. tostring(err_on)) - end - - if #errors > 0 then - error(table.concat(errors, ";\n")) - end - end) - - return st == "ok", err or "" - end - - ---@return ModemNetworkInfo? - ---@return string error - function ModemBackend:read_network_info() - local info, err = base_read_network_info(self) - if not info then - return nil, err - end - - local mcc, mnc, network_err = read_home_network_info(self.identity) - if network_err ~= "" then - return nil, network_err - end - - local active_band_class, band_err = read_rf_band_info(self.identity) - if band_err ~= "" then - return nil, band_err - end - - return modem_types.new.ModemNetworkInfo( - info.operator, - info.access_techs, - mcc, - mnc, - active_band_class - ) - end - - ---@return ModemSimInfo? - ---@return string error - function ModemBackend:read_sim_info() - local info, err = base_read_sim_info(self) - if not info then - return nil, err - end - - local gid1, gid_err = read_gid1(self.identity) - return sim_info_with_gid1(info, gid1, gid_err) - end + ---@cast ModemBackend ModemBackend + local base_read_network_info = ModemBackend.read_network_info + local base_read_sim_info = ModemBackend.read_sim_info + + ---@return boolean ok + ---@return string error + function ModemBackend:start_sim_presence_monitor() + if self.sim_present then + return false, "Already monitoring sim presence" + end + + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-monitor-slot-status", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } + + local stdout, err = cmd:stdout_stream() + if not stdout then + return false, "Failed to start QMI monitor: " .. tostring(err) + end + self.sim_present = { + cmd = cmd, + stdout = stdout + } + return true, "" + end + + ---Stop the long-running QMI SIM presence monitor. + ---@param timeout number? + ---@return Op + function ModemBackend:stop_sim_presence_monitor_op(timeout) + return op.guard(function() + local rec = self.sim_present + self.sim_present = nil + + if not rec then + return op.always(true, "") + end + + if rec.stdout then + pcall(function() rec.stdout:terminate("modem_sim_monitor_stop") end) + end + + if not rec.cmd then + return op.always(true, "") + end + + return rec.cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) + if status == "exited" or status == "signalled" then + return true, "" + end + return false, err or ("SIM monitor shutdown failed: " .. tostring(status)) + end) + end) + end + + ---@param timeout number? + ---@return boolean ok + ---@return string error + function ModemBackend:stop_sim_presence_monitor(timeout) + return fibers.perform(self:stop_sim_presence_monitor_op(timeout)) + end + + ---@return Op + function ModemBackend:wait_for_sim_present_op() + return op.guard(function() + return scope.run_op(function(s) + if not self.sim_present then + return false, "Sim presence monitor not started" + end + while true do + local chunk = s:perform(self.sim_present.stdout:read_line_op({ + terminator = "Slot status: active", + keep_terminator = true, + })) + + if not chunk then + return false, "Stream closed" + end + + local card_status, parse_err = parse_slot_status(chunk) + if parse_err == "" and card_status ~= "" then + local sim_present = card_status == "present" + return sim_present, "" + end + end + end) + :wrap(function(st, _, ...) + if st == 'ok' then + return ... + elseif st == 'cancelled' then + return false, "cancelled" + else + return false, (... or "QMI monitor failed") + end + end) + end) + end + + ---@return boolean sim_present + function ModemBackend:wait_for_sim_present() + return fibers.perform(self:wait_for_sim_present_op()) + end + + ---@return boolean sim_present + ---@return string error + function ModemBackend:is_sim_present() + local st, _, present_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-get-slot-status", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local out, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("Failed to execute qmicli command: " .. tostring(err)) + end + + local state, parse_err = parse_slot_status(out) + if parse_err ~= "" then + error("Failed to parse qmicli output: " .. tostring(parse_err)) + end + return state == 'present' + end) + + if st == "ok" then + return present_or_err, "" + end + return false, present_or_err or "Unknown error" + end + + ---@param cooldown number? + ---@return boolean ok + ---@return string error + function ModemBackend:trigger_sim_presence_check(cooldown) + local st, _, err = fibers.run_scope(function() + local errors = {} + cooldown = cooldown or 1 + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-off=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } + local _, status, code, _, power_err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + table.insert(errors, "Failed to execute qmicli power off command: " .. tostring(power_err)) + end + + sleep.sleep(cooldown) + + local cmd_on = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-on=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } + local _, status_on, code_on, _, err_on = fibers.perform(cmd_on:combined_output_op()) + if status_on ~= "exited" or code_on ~= 0 then + table.insert(errors, "Failed to execute qmicli power on command: " .. tostring(err_on)) + end + + if #errors > 0 then + error(table.concat(errors, ";\n")) + end + end) + + return st == "ok", err or "" + end + + ---@return ModemNetworkInfo? + ---@return string error + function ModemBackend:read_network_info() + local info, err = base_read_network_info(self) + if not info then + return nil, err + end + + local mcc, mnc, network_err = read_home_network_info(self.identity) + if network_err ~= "" then + return nil, network_err + end + + local active_band_class, band_err = read_rf_band_info(self.identity) + if band_err ~= "" then + return nil, band_err + end + + return modem_types.new.ModemNetworkInfo( + info.operator, + info.access_techs, + mcc, + mnc, + active_band_class + ) + end + + ---@return ModemSimInfo? + ---@return string error + function ModemBackend:read_sim_info() + local info, err = base_read_sim_info(self) + if not info then + return nil, err + end + + local gid1, gid_err = read_gid1(self.identity) + return sim_info_with_gid1(info, gid1, gid_err) + end end return { - add_mode_funcs = add_mode_funcs, - _test = { - sim_info_with_gid1 = sim_info_with_gid1, - }, + add_mode_funcs = add_mode_funcs, + _test = { + sim_info_with_gid1 = sim_info_with_gid1, + }, } diff --git a/src/services/hal/backends/modem/process_flags.lua b/src/services/hal/backends/modem/process_flags.lua index 05d8da48..1ecf289e 100644 --- a/src/services/hal/backends/modem/process_flags.lua +++ b/src/services/hal/backends/modem/process_flags.lua @@ -8,15 +8,15 @@ local M = {} ---It may be native on pidfd/FFI backends or reaper-backed on OpenWrt plain Lua. ---@return table flags function M.owned_monitor_flags() - local flags = { - process_group = true, - } + local flags = { + process_group = true, + } - if type(exec.supports) == "function" and exec.supports("parent_death_signal") then - flags.parent_death_signal = "TERM" - end + if type(exec.supports) == "function" and exec.supports("parent_death_signal") then + flags.parent_death_signal = "TERM" + end - return flags + return flags end return M diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua index 77554b42..2eb2c6a0 100644 --- a/src/services/hal/backends/modem/provider.lua +++ b/src/services/hal/backends/modem/provider.lua @@ -1,21 +1,21 @@ local contract = require "services.hal.backends.modem.contract" local MODEL_INFO = { - quectel = { - -- these are ordered, as eg25gl should match before eg25g - { mod_string = "UNKNOWN", rev_string = "eg25gl", model = "eg25", model_variant = "gl" }, - { mod_string = "UNKNOWN", rev_string = "eg25g", model = "eg25", model_variant = "g" }, - { mod_string = "UNKNOWN", rev_string = "ec25e", model = "ec25", model_variant = "e" }, - { mod_string = "em06-e", rev_string = "em06e", model = "em06", model_variant = "e" }, - { mod_string = "rm520n-gl", rev_string = "rm520ngl", model = "rm520n", model_variant = "gl" }, - { mod_string = "em12-g", rev_string = "em12g", model = "em12", model_variant = "g" }, - -- more quectel models here - }, - fibocom = {} + quectel = { + -- these are ordered, as eg25gl should match before eg25g + { mod_string = "UNKNOWN", rev_string = "eg25gl", model = "eg25", model_variant = "gl" }, + { mod_string = "UNKNOWN", rev_string = "eg25g", model = "eg25", model_variant = "g" }, + { mod_string = "UNKNOWN", rev_string = "ec25e", model = "ec25", model_variant = "e" }, + { mod_string = "em06-e", rev_string = "em06e", model = "em06", model_variant = "e" }, + { mod_string = "rm520n-gl", rev_string = "rm520ngl", model = "rm520n", model_variant = "gl" }, + { mod_string = "em12-g", rev_string = "em12g", model = "em12", model_variant = "g" }, + -- more quectel models here + }, + fibocom = {} } local BACKENDS = { - "linux_mm" + "linux_mm" } --- Utility function to check if a string starts with a given prefix (case-insensitive) @@ -23,96 +23,96 @@ local BACKENDS = { ---@param start string ---@return boolean local function starts_with(str, start) - if str == nil or start == nil then return false end - str, start = str:lower(), start:lower() - -- Use string.sub to get the prefix of mainString that is equal in length to startString - return string.sub(str, 1, string.len(start)) == start + if str == nil or start == nil then return false end + str, start = str:lower(), start:lower() + -- Use string.sub to get the prefix of mainString that is equal in length to startString + return string.sub(str, 1, string.len(start)) == start end --- Select the first supported provider for the current environment. ---@return table provider local function get_provider() - for _, backend_name in ipairs(BACKENDS) do - local ok, mod = pcall(require, "services.hal.backends.modem.providers." .. backend_name .. ".init") - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - return mod - end - end - error("No supported modem provider found") + for _, backend_name in ipairs(BACKENDS) do + local ok, mod = pcall(require, "services.hal.backends.modem.providers." .. backend_name .. ".init") + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + return mod + end + end + error("No supported modem provider found") end local function new(address) - local provider = get_provider() - local impl = provider.backend - local backend = impl.new(address) - ---@cast backend ModemBackend - local identity_info, identity_err = backend:read_identity() - if not identity_info then - error("Failed to read modem identity: " .. tostring(identity_err)) - end + local provider = get_provider() + local impl = provider.backend + local backend = impl.new(address) + ---@cast backend ModemBackend + local identity_info, identity_err = backend:read_identity() + if not identity_info then + error("Failed to read modem identity: " .. tostring(identity_err)) + end - local mode = identity_info.mode + local mode = identity_info.mode - if mode then - local ok, driver_mod = pcall(require, "services.hal.backends.modem.modes." .. mode) - if ok and type(driver_mod) == "table" and driver_mod.add_mode_funcs then - driver_mod.add_mode_funcs(backend) - end - end + if mode then + local ok, driver_mod = pcall(require, "services.hal.backends.modem.modes." .. mode) + if ok and type(driver_mod) == "table" and driver_mod.add_mode_funcs then + driver_mod.add_mode_funcs(backend) + end + end - local plugin = identity_info.plugin or "" - local model = identity_info.model or "" - local revision = identity_info.revision or "" + local plugin = identity_info.plugin or "" + local model = identity_info.model or "" + local revision = identity_info.revision or "" - local model_funcs_loaded = false - for manufacturer, models in pairs(MODEL_INFO) do - if string.match(plugin:lower(), manufacturer) then - for _, details in ipairs(models) do - if details.mod_string == model:lower() - or starts_with(revision, details.rev_string) then - model = details.model - local model_variant = details.model_variant - identity_info.model = model - identity_info.model_variant = model_variant - local ok, model_mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) - if ok and type(model_mod) == "table" and model_mod.add_model_funcs then - model_mod.add_model_funcs(backend, identity_info) - model_funcs_loaded = true - end - break - end - end - end - if model_funcs_loaded then break end - end + local model_funcs_loaded = false + for manufacturer, models in pairs(MODEL_INFO) do + if string.match(plugin:lower(), manufacturer) then + for _, details in ipairs(models) do + if details.mod_string == model:lower() + or starts_with(revision, details.rev_string) then + model = details.model + local model_variant = details.model_variant + identity_info.model = model + identity_info.model_variant = model_variant + local ok, model_mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) + if ok and type(model_mod) == "table" and model_mod.add_model_funcs then + model_mod.add_model_funcs(backend, identity_info) + model_funcs_loaded = true + end + break + end + end + end + if model_funcs_loaded then break end + end - local iface_err = contract.validate(backend) - if iface_err ~= "" then - error("Modem backend does not implement required interface: " .. tostring(iface_err)) - end + local iface_err = contract.validate(backend) + if iface_err ~= "" then + error("Modem backend does not implement required interface: " .. tostring(iface_err)) + end - return backend + return backend end --- Create a new ModemMonitor using the selected provider. ---@return ModemMonitor monitor local function new_monitor() - local provider = get_provider() - if not provider.new_monitor then - error("Selected modem provider does not support modem monitoring") - end - local monitor, err = provider.new_monitor() - if not monitor then - error("Failed to create modem monitor: " .. tostring(err)) - end - local validate_err = contract.validate_monitor(monitor) - if validate_err ~= "" then - error("Modem monitor does not satisfy required interface: " .. validate_err) - end - return monitor + local provider = get_provider() + if not provider.new_monitor then + error("Selected modem provider does not support modem monitoring") + end + local monitor, err = provider.new_monitor() + if not monitor then + error("Failed to create modem monitor: " .. tostring(err)) + end + local validate_err = contract.validate_monitor(monitor) + if validate_err ~= "" then + error("Modem monitor does not satisfy required interface: " .. validate_err) + end + return monitor end return { - new = new, - new_monitor = new_monitor, + new = new, + new_monitor = new_monitor, } diff --git a/src/services/hal/backends/modem/providers/linux_mm/impl.lua b/src/services/hal/backends/modem/providers/linux_mm/impl.lua index 8f268903..6b75e372 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/impl.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -12,205 +12,205 @@ local json = require "cjson.safe" local process_flags = require "services.hal.backends.modem.process_flags" local function terminate_command(cmd, sig) - if not cmd or type(cmd.kill) ~= 'function' then return true, nil end - local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) - if not ok then return nil, tostring(a) end - if a == false or a == nil then return nil, tostring(b or 'command kill failed') end - return true, nil + if not cmd or type(cmd.kill) ~= 'function' then return true, nil end + local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) + if not ok then return nil, tostring(a) end + if a == false or a == nil then return nil, tostring(b or 'command kill failed') end + return true, nil end local MODEM_INFO_PATHS = { - imei = { "generic", "equipment-identifier" }, - device = { "generic", "device" }, - primary_port = { "generic", "primary-port" }, - ports = { "generic", "ports" }, - access_techs = { "generic", "access-technologies" }, - sim = { "generic", "sim" }, - drivers = { "generic", "drivers" }, - plugin = { "generic", "plugin" }, - model = { "generic", "model" }, - revision = { "generic", "revision" }, - operator = { "3gpp", "operator-name" }, - modem_state = { "generic", "state" }, - sim_lock = { "generic", "unlock-required" }, - sim_lock_retries = { "generic", "unlock-retries" }, + imei = { "generic", "equipment-identifier" }, + device = { "generic", "device" }, + primary_port = { "generic", "primary-port" }, + ports = { "generic", "ports" }, + access_techs = { "generic", "access-technologies" }, + sim = { "generic", "sim" }, + drivers = { "generic", "drivers" }, + plugin = { "generic", "plugin" }, + model = { "generic", "model" }, + revision = { "generic", "revision" }, + operator = { "3gpp", "operator-name" }, + modem_state = { "generic", "state" }, + sim_lock = { "generic", "unlock-required" }, + sim_lock_retries = { "generic", "unlock-retries" }, } local SIM_INFO_PATHS = { - iccid = { "properties", "iccid" }, - imsi = { "properties", "imsi" }, + iccid = { "properties", "iccid" }, + imsi = { "properties", "imsi" }, } local VALID_SIGNAL_RANGES = { - ["5g"] = { - rssi = { low = -125, high = -30 }, -- dBm - rsrp = { low = -156, high = -31 }, -- dBm - rsrq = { low = -43, high = 20 }, -- dB - snr = { low = -23, high = 40 }, -- dB - }, - - cdma1x = { - rssi = { low = -125, high = -30 }, -- dBm - ecio = { low = -31.5, high = 0 }, -- dB - }, - - evdo = { - rssi = { low = -125, high = -30 }, -- dBm - ecio = { low = -31.5, high = 0 }, -- dB - sinr = { low = -9, high = 9 }, -- dB - io = { low = -125, high = -30 }, -- dBm - }, - - gsm = { - rssi = { low = -125, high = -30 }, -- dBm - }, - - lte = { - rssi = { low = -125, high = -30 }, -- dBm - rsrp = { low = -140, high = -44 }, -- dBm - rsrq = { low = -20, high = -3 }, -- dB - snr = { low = -20, high = 30 }, -- dB - }, - - umts = { - rssi = { low = -125, high = -30 }, -- dBm - rscp = { low = -120, high = -25 }, -- dBm - ecio = { low = -24, high = 0 }, -- dB - }, + ["5g"] = { + rssi = { low = -125, high = -30 }, -- dBm + rsrp = { low = -156, high = -31 }, -- dBm + rsrq = { low = -43, high = 20 }, -- dB + snr = { low = -23, high = 40 }, -- dB + }, + + cdma1x = { + rssi = { low = -125, high = -30 }, -- dBm + ecio = { low = -31.5, high = 0 }, -- dB + }, + + evdo = { + rssi = { low = -125, high = -30 }, -- dBm + ecio = { low = -31.5, high = 0 }, -- dB + sinr = { low = -9, high = 9 }, -- dB + io = { low = -125, high = -30 }, -- dBm + }, + + gsm = { + rssi = { low = -125, high = -30 }, -- dBm + }, + + lte = { + rssi = { low = -125, high = -30 }, -- dBm + rsrp = { low = -140, high = -44 }, -- dBm + rsrq = { low = -20, high = -3 }, -- dB + snr = { low = -20, high = 30 }, -- dB + }, + + umts = { + rssi = { low = -125, high = -30 }, -- dBm + rscp = { low = -120, high = -25 }, -- dBm + ecio = { low = -24, high = 0 }, -- dB + }, } ---@param signal number ---@param range { low: number, high: number } ---@return boolean local function is_signal_valid(signal, range) - return type(signal) == 'number' - and signal == signal - and signal ~= math.huge - and signal ~= -math.huge - and type(range) == 'table' - and type(range.low) == 'number' - and type(range.high) == 'number' - and signal >= range.low - and signal <= range.high + return type(signal) == 'number' + and signal == signal + and signal ~= math.huge + and signal ~= -math.huge + and type(range) == 'table' + and type(range.low) == 'number' + and type(range.high) == 'number' + and signal >= range.low + and signal <= range.high end ---@param nested table ---@param key_paths table ---@return table local function nested_to_flat(nested, key_paths) - local flat = {} - for key, path in pairs(key_paths) do - ---@type any - local value = nested - for _, p in ipairs(path) do - if type(value) ~= 'table' then - value = nil - break - end - value = value[p] - if value == nil then - break - end - end - if value ~= nil then - flat[key] = value - end - end - return flat + local flat = {} + for key, path in pairs(key_paths) do + ---@type any + local value = nested + for _, p in ipairs(path) do + if type(value) ~= 'table' then + value = nil + break + end + value = value[p] + if value == nil then + break + end + end + if value ~= nil then + flat[key] = value + end + end + return flat end ---@param ports string[] ---@return table local function format_ports(ports) - local formatted = { - at_ports = {}, - qmi_ports = {}, - gps_ports = {}, - net_ports = {}, - mbim_ports = {}, - } - for _, port in ipairs(ports) do - local name, port_type = port:match("^(.*) %((.*)%)$") - if name and port_type then - local key = port_type .. "_ports" - if formatted[key] then - table.insert(formatted[key], name) - end - end - end - return formatted + local formatted = { + at_ports = {}, + qmi_ports = {}, + gps_ports = {}, + net_ports = {}, + mbim_ports = {}, + } + for _, port in ipairs(ports) do + local name, port_type = port:match("^(.*) %((.*)%)$") + if name and port_type then + local key = port_type .. "_ports" + if formatted[key] then + table.insert(formatted[key], name) + end + end + end + return formatted end ---@param value any ---@return string[] local function normalize_string_list(value) - if type(value) == 'table' then - local result = {} - for _, entry in ipairs(value) do - if type(entry) == 'string' and entry ~= '' then - table.insert(result, entry) - end - end - return result - end - if type(value) == 'string' and value ~= '' and value ~= "--" then - return { value } - end - return {} + if type(value) == 'table' then + local result = {} + for _, entry in ipairs(value) do + if type(entry) == 'string' and entry ~= '' then + table.insert(result, entry) + end + end + return result + end + if type(value) == 'string' and value ~= '' and value ~= "--" then + return { value } + end + return {} end ---@param value any ---@return string? local function normalize_optional_string(value) - if type(value) ~= 'string' then return nil end - if value == '' or value == '--' then return nil end - return value + if type(value) ~= 'string' then return nil end + if value == '' or value == '--' then return nil end + return value end ---@param value any ---@return table? local function normalize_unlock_retries(value) - if type(value) ~= 'table' then return nil end - local out = {} - for _, entry in ipairs(value) do - if type(entry) == 'string' then - local key, retries = entry:match("^%s*([^%(]-)%s*%((%d+)%)%s*$") - if key and retries then - key = key:match("^%s*(.-)%s*$") - if key ~= '' then - out[key] = tonumber(retries) - end - end - end - end - if next(out) == nil then return nil end - return out + if type(value) ~= 'table' then return nil end + local out = {} + for _, entry in ipairs(value) do + if type(entry) == 'string' then + local key, retries = entry:match("^%s*([^%(]-)%s*%((%d+)%)%s*$") + if key and retries then + key = key:match("^%s*(.-)%s*$") + if key ~= '' then + out[key] = tonumber(retries) + end + end + end + end + if next(out) == nil then return nil end + return out end ---@param output string ---@return string local function parse_firmware_version(output) - for line in output:gmatch("[^\r\n]+") do - local version = line:match("version:%s*(%S+)") - if version and version ~= '' then - return version - end - end - return "" + for line in output:gmatch("[^\r\n]+") do + local version = line:match("version:%s*(%S+)") + if version and version ~= '' then + return version + end + end + return "" end ---@param drivers string[] ---@param ports table ---@return string? local function derive_mode(drivers, ports) - local drivers_str = table.concat(drivers or {}, ",") - if drivers_str:match("cdc_mbim") or #(ports.mbim_ports or {}) > 0 then - return "mbim" - end - if drivers_str:match("qmi_wwan") or #(ports.qmi_ports or {}) > 0 then - return "qmi" - end - return nil + local drivers_str = table.concat(drivers or {}, ",") + if drivers_str:match("cdc_mbim") or #(ports.mbim_ports or {}) > 0 then + return "mbim" + end + if drivers_str:match("qmi_wwan") or #(ports.qmi_ports or {}) > 0 then + return "qmi" + end + return nil end ---@param address string @@ -218,153 +218,153 @@ end ---@return string? output ---@return string error local function run_command(address, args) - local st, _, output_or_err = fibers.run_scope(function() - local cmd_args = { - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - for _, arg in ipairs(args) do - table.insert(cmd_args, arg) - end - local cmd = exec.command(cmd_args) - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - error(table.concat(args, " ") .. " failed for modem " .. tostring(address) .. ": " .. tostring(err) - .. ", output: " .. tostring(output)) - end - return output - end) - - if st == "ok" then - return output_or_err, "" - end - return nil, output_or_err or "unknown command error" + local st, _, output_or_err = fibers.run_scope(function() + local cmd_args = { + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + for _, arg in ipairs(args) do + table.insert(cmd_args, arg) + end + local cmd = exec.command(cmd_args) + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error(table.concat(args, " ") .. " failed for modem " .. tostring(address) .. ": " .. tostring(err) + .. ", output: " .. tostring(output)) + end + return output + end) + + if st == "ok" then + return output_or_err, "" + end + return nil, output_or_err or "unknown command error" end ---@param address string ---@return table? ---@return string error local function parse_modem_info_json(output) - local data, json_err = json.decode(output) - if not data then - return nil, "Failed to decode mmcli info output as JSON: " - .. tostring(json_err) .. ", output: " .. tostring(output) - end - if type(data.modem) ~= 'table' then - return nil, "No modem info found in mmcli output" - end - - local flat = nested_to_flat(data.modem, MODEM_INFO_PATHS) - local ports = format_ports(flat.ports or {}) - flat.ports = nil - for key, value in pairs(ports) do - flat[key] = value - end - flat.drivers = normalize_string_list(flat.drivers) - flat.access_techs = normalize_string_list(flat.access_techs) - flat.modem_state = normalize_optional_string(flat.modem_state) - flat.sim_lock = normalize_optional_string(flat.sim_lock) - flat.sim_lock_retries = normalize_unlock_retries(flat.sim_lock_retries) - return flat, "" + local data, json_err = json.decode(output) + if not data then + return nil, "Failed to decode mmcli info output as JSON: " + .. tostring(json_err) .. ", output: " .. tostring(output) + end + if type(data.modem) ~= 'table' then + return nil, "No modem info found in mmcli output" + end + + local flat = nested_to_flat(data.modem, MODEM_INFO_PATHS) + local ports = format_ports(flat.ports or {}) + flat.ports = nil + for key, value in pairs(ports) do + flat[key] = value + end + flat.drivers = normalize_string_list(flat.drivers) + flat.access_techs = normalize_string_list(flat.access_techs) + flat.modem_state = normalize_optional_string(flat.modem_state) + flat.sim_lock = normalize_optional_string(flat.sim_lock) + flat.sim_lock_retries = normalize_unlock_retries(flat.sim_lock_retries) + return flat, "" end ---@param address string ---@return table? ---@return string error local function read_modem_info(address) - local output, err = run_command(address, { "mmcli", "-J", "-m", address }) - if not output then - return nil, err - end + local output, err = run_command(address, { "mmcli", "-J", "-m", address }) + if not output then + return nil, err + end - return parse_modem_info_json(output) + return parse_modem_info_json(output) end ---@param identity ModemIdentity ---@return string? ---@return string error local function read_firmware_version(identity) - local output, err = run_command(identity.address, { "mmcli", "-m", identity.address, "--firmware-status" }) - if not output then - return nil, err - end + local output, err = run_command(identity.address, { "mmcli", "-m", identity.address, "--firmware-status" }) + if not output then + return nil, err + end - local version = parse_firmware_version(output) - if version == "" then - return nil, "Failed to parse firmware version from mmcli --firmware-status" - end - return version, "" + local version = parse_firmware_version(output) + if version == "" then + return nil, "Failed to parse firmware version from mmcli --firmware-status" + end + return version, "" end ---@param output string ---@return ModemSignalInfo? ---@return string error local function parse_signal_info_json(output) - local data, json_err = json.decode(output) - if not data then - return nil, "Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) - end - - local signal_techs = data.modem and data.modem.signal or nil - if type(signal_techs) ~= 'table' then - return nil, "No signal info found in mmcli output" - end - - local valid_signals = {} - for tech, signals in pairs(signal_techs) do - local expected_signals = VALID_SIGNAL_RANGES[tech] - if expected_signals and type(signals) == 'table' then - local filtered_fields = {} - for signal_name, range in pairs(expected_signals) do - local numeric_value = tonumber(signals[signal_name]) - if is_signal_valid(numeric_value, range) then - filtered_fields[signal_name] = numeric_value - end - end - if next(filtered_fields) ~= nil then - valid_signals[tech] = filtered_fields - end - end - end - - -- An empty table is a successful observation that no valid signal is - -- currently available. The driver emits it so consumers can clear stale - -- retained signal state instead of treating it as a failed modem read. - return modem_types.new.ModemSignalInfo(valid_signals) + local data, json_err = json.decode(output) + if not data then + return nil, "Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) + end + + local signal_techs = data.modem and data.modem.signal or nil + if type(signal_techs) ~= 'table' then + return nil, "No signal info found in mmcli output" + end + + local valid_signals = {} + for tech, signals in pairs(signal_techs) do + local expected_signals = VALID_SIGNAL_RANGES[tech] + if expected_signals and type(signals) == 'table' then + local filtered_fields = {} + for signal_name, range in pairs(expected_signals) do + local numeric_value = tonumber(signals[signal_name]) + if is_signal_valid(numeric_value, range) then + filtered_fields[signal_name] = numeric_value + end + end + if next(filtered_fields) ~= nil then + valid_signals[tech] = filtered_fields + end + end + end + + -- An empty table is a successful observation that no valid signal is + -- currently available. The driver emits it so consumers can clear stale + -- retained signal state instead of treating it as a failed modem read. + return modem_types.new.ModemSignalInfo(valid_signals) end ---@param identity ModemIdentity ---@return ModemSignalInfo? ---@return string error local function read_signal_info(identity) - local output, err = run_command(identity.address, { "mmcli", "-J", "-m", identity.address, "--signal-get" }) - if not output then - return nil, err - end + local output, err = run_command(identity.address, { "mmcli", "-J", "-m", identity.address, "--signal-get" }) + if not output then + return nil, err + end - return parse_signal_info_json(output) + return parse_signal_info_json(output) end ---@param sim_path string ---@return table? ---@return string error local function read_sim_payload(sim_path) - local output, err = run_command(sim_path, { "mmcli", "-J", "-i", sim_path }) - if not output then - return nil, err - end + local output, err = run_command(sim_path, { "mmcli", "-J", "-i", sim_path }) + if not output then + return nil, err + end - local data, json_err = json.decode(output) - if not data then - return nil, - "Failed to decode mmcli SIM output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) - end - if type(data.sim) ~= 'table' then - return nil, "No SIM info found in mmcli output" - end + local data, json_err = json.decode(output) + if not data then + return nil, + "Failed to decode mmcli SIM output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) + end + if type(data.sim) ~= 'table' then + return nil, "No SIM info found in mmcli output" + end - return nested_to_flat(data.sim, SIM_INFO_PATHS), "" + return nested_to_flat(data.sim, SIM_INFO_PATHS), "" end ---@param net_port string @@ -372,94 +372,94 @@ end ---@return integer ---@return string local function read_net_stat(net_port, stat) - local st, _, value_or_err = fibers.run_scope(function() - local path = "/sys/class/net/" .. net_port .. "/statistics/" .. stat - local file = io.open(path, "r") - if not file then - error("Failed to open file: " .. tostring(path)) - end - local content = file:read("*a") - file:close() - if not content then - error("Failed to read file: " .. tostring(path)) - end - local value = tonumber(content) - if not value then - error("Failed to parse net stat: " .. tostring(content)) - end - return value - end) - - if st == "ok" then - return value_or_err, "" - end - return -1, value_or_err or "Unknown error" + local st, _, value_or_err = fibers.run_scope(function() + local path = "/sys/class/net/" .. net_port .. "/statistics/" .. stat + local file = io.open(path, "r") + if not file then + error("Failed to open file: " .. tostring(path)) + end + local content = file:read("*a") + file:close() + if not content then + error("Failed to read file: " .. tostring(path)) + end + local value = tonumber(content) + if not value then + error("Failed to parse net stat: " .. tostring(content)) + end + return value + end) + + if st == "ok" then + return value_or_err, "" + end + return -1, value_or_err or "Unknown error" end ---@param address string ---@return ModemIdentity local function get_identity(address) - local modem_info, err = read_modem_info(address) - if not modem_info then - error("Failed to fetch modem info: " .. tostring(err)) - end - - local qmi_port = modem_info.qmi_ports and modem_info.qmi_ports[1] or nil - local mbim_port = modem_info.mbim_ports and modem_info.mbim_ports[1] or nil - local selected_mode_port = mbim_port or qmi_port - if not selected_mode_port then - error("Failed to determine modem control port") - end - - local at_port = modem_info.at_ports and modem_info.at_ports[1] or nil - if not at_port then - error("Failed to determine modem AT port") - end - - local net_port = modem_info.net_ports and modem_info.net_ports[1] or nil - if not net_port then - error("Failed to determine modem network port") - end - - local identity, id_err = modem_types.new.ModemIdentity( - modem_info.imei, - address, - "/dev/" .. selected_mode_port, - "/dev/" .. at_port, - net_port, - modem_info.device - ) - if not identity then - error("Failed to get modem identity: " .. tostring(id_err)) - end - return identity + local modem_info, err = read_modem_info(address) + if not modem_info then + error("Failed to fetch modem info: " .. tostring(err)) + end + + local qmi_port = modem_info.qmi_ports and modem_info.qmi_ports[1] or nil + local mbim_port = modem_info.mbim_ports and modem_info.mbim_ports[1] or nil + local selected_mode_port = mbim_port or qmi_port + if not selected_mode_port then + error("Failed to determine modem control port") + end + + local at_port = modem_info.at_ports and modem_info.at_ports[1] or nil + if not at_port then + error("Failed to determine modem AT port") + end + + local net_port = modem_info.net_ports and modem_info.net_ports[1] or nil + if not net_port then + error("Failed to determine modem network port") + end + + local identity, id_err = modem_types.new.ModemIdentity( + modem_info.imei, + address, + "/dev/" .. selected_mode_port, + "/dev/" .. at_port, + net_port, + modem_info.device + ) + if not identity then + error("Failed to get modem identity: " .. tostring(id_err)) + end + return identity end ---@param line string? ---@return ModemStateEvent? ---@return string error local function parse_modem_state_line(line) - if not line or line == "" then - return nil, "Command closed" - end + if not line or line == "" then + return nil, "Command closed" + end - line = line:match("^%s*(.-)%s*$") + line = line:match("^%s*(.-)%s*$") - local initial_state = line:match(": Initial state, '([^']+)'") - if initial_state then - return modem_types.new.ModemStateInitialEvent(initial_state, "initial") - end + local initial_state = line:match(": Initial state, '([^']+)'") + if initial_state then + return modem_types.new.ModemStateInitialEvent(initial_state, "initial") + end - local old_state, new_state, reason = line:match(": State changed, '([^']+)' %-%-> '([^']+)' %(Reason: ([^)]+)%)") - if old_state and new_state then - return modem_types.new.ModemStateChangeEvent(old_state, new_state, reason) - end + local old_state, new_state, reason = line:match(": State changed, '([^']+)' %-%-> '([^']+)' %(Reason: ([^)]+)%)") + if old_state and new_state then + return modem_types.new.ModemStateChangeEvent(old_state, new_state, reason) + end - if line:match(": Removed") then - return modem_types.new.ModemStateRemovedEvent("removed") - end + if line:match(": Removed") then + return modem_types.new.ModemStateRemovedEvent("removed") + end - return nil, "Unknown modem state line format: " .. line + return nil, "Unknown modem state line format: " .. line end local ModemBackend = {} @@ -471,378 +471,378 @@ ModemBackend._read_firmware = read_firmware_version ---@return ModemIdentityInfo? ---@return string error function ModemBackend:read_identity() - local modem_info, err = read_modem_info(self.identity.address) - if not modem_info then - return nil, err - end - - local firmware = self._read_firmware(self.identity) - -- Non-fatal: firmware may not be available yet, or the model hook may not be installed yet. - -- The driver will re-call read_identity() after model hooks are applied. - firmware = firmware or nil - - return modem_types.new.ModemIdentityInfo( - modem_info.imei, - modem_info.drivers or {}, - modem_info.model, - modem_info.revision, - firmware, - modem_info.plugin, - derive_mode(modem_info.drivers or {}, { - qmi_ports = modem_info.qmi_ports or {}, - mbim_ports = modem_info.mbim_ports or {}, - }) - ) + local modem_info, err = read_modem_info(self.identity.address) + if not modem_info then + return nil, err + end + + local firmware = self._read_firmware(self.identity) + -- Non-fatal: firmware may not be available yet, or the model hook may not be installed yet. + -- The driver will re-call read_identity() after model hooks are applied. + firmware = firmware or nil + + return modem_types.new.ModemIdentityInfo( + modem_info.imei, + modem_info.drivers or {}, + modem_info.model, + modem_info.revision, + firmware, + modem_info.plugin, + derive_mode(modem_info.drivers or {}, { + qmi_ports = modem_info.qmi_ports or {}, + mbim_ports = modem_info.mbim_ports or {}, + }) + ) end ---@return ModemPortsInfo? ---@return string error function ModemBackend:read_ports() - local modem_info, err = read_modem_info(self.identity.address) - if not modem_info then - return nil, err - end + local modem_info, err = read_modem_info(self.identity.address) + if not modem_info then + return nil, err + end - return modem_types.new.ModemPortsInfo( - modem_info.device, - modem_info.primary_port, - modem_info.at_ports, - modem_info.qmi_ports, - modem_info.gps_ports, - modem_info.net_ports - ) + return modem_types.new.ModemPortsInfo( + modem_info.device, + modem_info.primary_port, + modem_info.at_ports, + modem_info.qmi_ports, + modem_info.gps_ports, + modem_info.net_ports + ) end ---@return ModemSimInfo? ---@return string error function ModemBackend:read_sim_info() - local modem_info, err = read_modem_info(self.identity.address) - if not modem_info then - return nil, err - end - - local sim_info = nil - if modem_info.sim and modem_info.sim ~= "--" then - sim_info, err = read_sim_payload(modem_info.sim) - if err ~= "" then - return nil, err - end - end - - return modem_types.new.ModemSimInfo( - modem_info.sim, - sim_info and sim_info.iccid or nil, - sim_info and sim_info.imsi or nil, - nil, - modem_info.sim_lock, - modem_info.sim_lock_retries, - modem_info.modem_state - ) + local modem_info, err = read_modem_info(self.identity.address) + if not modem_info then + return nil, err + end + + local sim_info = nil + if modem_info.sim and modem_info.sim ~= "--" then + sim_info, err = read_sim_payload(modem_info.sim) + if err ~= "" then + return nil, err + end + end + + return modem_types.new.ModemSimInfo( + modem_info.sim, + sim_info and sim_info.iccid or nil, + sim_info and sim_info.imsi or nil, + nil, + modem_info.sim_lock, + modem_info.sim_lock_retries, + modem_info.modem_state + ) end ---@return ModemNetworkInfo? ---@return string error function ModemBackend:read_network_info() - local modem_info, err = read_modem_info(self.identity.address) - if not modem_info then - return nil, err - end + local modem_info, err = read_modem_info(self.identity.address) + if not modem_info then + return nil, err + end - return modem_types.new.ModemNetworkInfo( - modem_info.operator, - modem_info.access_techs, - nil, - nil, - nil - ) + return modem_types.new.ModemNetworkInfo( + modem_info.operator, + modem_info.access_techs, + nil, + nil, + nil + ) end ---@return ModemSignalInfo? ---@return string error function ModemBackend:read_signal() - return read_signal_info(self.identity) + return read_signal_info(self.identity) end ---@return ModemTrafficInfo? ---@return string error function ModemBackend:read_traffic() - local rx_bytes, rx_err = read_net_stat(self.identity.net_port, "rx_bytes") - if rx_err ~= "" then - return nil, rx_err - end + local rx_bytes, rx_err = read_net_stat(self.identity.net_port, "rx_bytes") + if rx_err ~= "" then + return nil, rx_err + end - local tx_bytes, tx_err = read_net_stat(self.identity.net_port, "tx_bytes") - if tx_err ~= "" then - return nil, tx_err - end + local tx_bytes, tx_err = read_net_stat(self.identity.net_port, "tx_bytes") + if tx_err ~= "" then + return nil, tx_err + end - return modem_types.new.ModemTrafficInfo(rx_bytes, tx_bytes) + return modem_types.new.ModemTrafficInfo(rx_bytes, tx_bytes) end ---@return boolean ok ---@return string error function ModemBackend:enable() - local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "-e" }) - return err == "", err + local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "-e" }) + return err == "", err end ---@return boolean ok ---@return string error function ModemBackend:disable() - local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "-d" }) - return err == "", err + local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "-d" }) + return err == "", err end ---@return boolean ok ---@return string error function ModemBackend:reset() - local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "--reset" }) - return err == "", err + local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "--reset" }) + return err == "", err end ---@param conn_string string ---@return boolean ok ---@return string error function ModemBackend:connect(conn_string) - local _, err = run_command(self.identity.address, { - "mmcli", "-m", self.identity.address, "--simple-connect=" .. conn_string - }) - return err == "", err + local _, err = run_command(self.identity.address, { + "mmcli", "-m", self.identity.address, "--simple-connect=" .. conn_string + }) + return err == "", err end ---@return boolean ok ---@return string error function ModemBackend:disconnect() - local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "--simple-disconnect" }) - return err == "", err + local _, err = run_command(self.identity.address, { "mmcli", "-m", self.identity.address, "--simple-disconnect" }) + return err == "", err end ---@return boolean ok ---@return string error function ModemBackend:inhibit() - if self.inhibit_cmd then - return false, "Modem is already inhibited" - end + if self.inhibit_cmd then + return false, "Modem is already inhibited" + end - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "--inhibit", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--inhibit", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } - local stream, err = cmd:stdout_stream() - if not stream then - return false, "Failed to start inhibit command: --inhibit, reason: " .. tostring(err) - end + local stream, err = cmd:stdout_stream() + if not stream then + return false, "Failed to start inhibit command: --inhibit, reason: " .. tostring(err) + end - self.inhibit_cmd = cmd - return true, "Modem inhibit started" + self.inhibit_cmd = cmd + return true, "Modem inhibit started" end ---@param timeout number? ---@return Op function ModemBackend:uninhibit_op(timeout) - return op.guard(function() - local cmd = self.inhibit_cmd - if not cmd then - return op.always(false, "Modem is not inhibited") - end - - self.inhibit_cmd = nil - return cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) - if status == "exited" or status == "signalled" then - return true, "Modem uninhibited" - end - return false, err or ("failed to stop inhibit command: " .. tostring(status)) - end) - end) + return op.guard(function() + local cmd = self.inhibit_cmd + if not cmd then + return op.always(false, "Modem is not inhibited") + end + + self.inhibit_cmd = nil + return cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) + if status == "exited" or status == "signalled" then + return true, "Modem uninhibited" + end + return false, err or ("failed to stop inhibit command: " .. tostring(status)) + end) + end) end ---@param timeout number? ---@return boolean ok ---@return string error function ModemBackend:uninhibit(timeout) - return fibers.perform(self:uninhibit_op(timeout)) + return fibers.perform(self:uninhibit_op(timeout)) end ---@return boolean ok ---@return string error function ModemBackend:start_state_monitor() - if self.state_monitor then - return false, "Already monitoring modem state" - end - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "-w", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } - local stream, err = cmd:stdout_stream() - if not stream then - return false, "Failed to start monitor state command: " .. tostring(err) - end - self.state_monitor = { cmd = cmd, stream = stream } - return true, "" + if self.state_monitor then + return false, "Already monitoring modem state" + end + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-w", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } + local stream, err = cmd:stdout_stream() + if not stream then + return false, "Failed to start monitor state command: " .. tostring(err) + end + self.state_monitor = { cmd = cmd, stream = stream } + return true, "" end ---@param timeout number? ---@return Op function ModemBackend:stop_state_monitor_op(timeout) - return op.guard(function() - local rec = self.state_monitor - self.state_monitor = nil + return op.guard(function() + local rec = self.state_monitor + self.state_monitor = nil - if not rec then - return op.always(true, "") - end + if not rec then + return op.always(true, "") + end - if rec.stream then - pcall(function() rec.stream:terminate("modem_state_monitor_stop") end) - end + if rec.stream then + pcall(function() rec.stream:terminate("modem_state_monitor_stop") end) + end - if not rec.cmd then - return op.always(true, "") - end + if not rec.cmd then + return op.always(true, "") + end - return rec.cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) - if status == "exited" or status == "signalled" then - return true, "" - end - return false, err or ("state monitor shutdown failed: " .. tostring(status)) - end) - end) + return rec.cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) + if status == "exited" or status == "signalled" then + return true, "" + end + return false, err or ("state monitor shutdown failed: " .. tostring(status)) + end) + end) end ---@param timeout number? ---@return boolean ok ---@return string error function ModemBackend:stop_state_monitor(timeout) - return fibers.perform(self:stop_state_monitor_op(timeout)) + return fibers.perform(self:stop_state_monitor_op(timeout)) end local function terminate_monitor_record(rec, stream_key, reason) - if not rec then - return - end + if not rec then + return + end - local stream = rec[stream_key] - if stream then - pcall(function() stream:terminate(reason) end) - end + local stream = rec[stream_key] + if stream then + pcall(function() stream:terminate(reason) end) + end - if rec.cmd then - terminate_command(rec.cmd, 15) - end + if rec.cmd then + terminate_command(rec.cmd, 15) + end end ---Best-effort non-blocking teardown for scope finalisers. ---@param reason string? function ModemBackend:terminate(reason) - reason = reason or "modem_backend_terminated" + reason = reason or "modem_backend_terminated" - terminate_monitor_record(self.sim_present, "stdout", reason) - self.sim_present = nil + terminate_monitor_record(self.sim_present, "stdout", reason) + self.sim_present = nil - terminate_monitor_record(self.state_monitor, "stream", reason) - self.state_monitor = nil + terminate_monitor_record(self.state_monitor, "stream", reason) + self.state_monitor = nil - if self.inhibit_cmd then - terminate_command(self.inhibit_cmd, 15) - self.inhibit_cmd = nil - end + if self.inhibit_cmd then + terminate_command(self.inhibit_cmd, 15) + self.inhibit_cmd = nil + end end ---@param timeout number? ---@return Op function ModemBackend:shutdown_op(timeout) - timeout = timeout or 1.0 + timeout = timeout or 1.0 - return scope.run_op(function(s) - local errors = {} + return scope.run_op(function(s) + local errors = {} - if self.stop_sim_presence_monitor_op then - local ok, err = s:perform(self:stop_sim_presence_monitor_op(timeout)) - if not ok then errors[#errors + 1] = tostring(err) end - end + if self.stop_sim_presence_monitor_op then + local ok, err = s:perform(self:stop_sim_presence_monitor_op(timeout)) + if not ok then errors[#errors + 1] = tostring(err) end + end - if self.stop_state_monitor_op then - local ok, err = s:perform(self:stop_state_monitor_op(timeout)) - if not ok then errors[#errors + 1] = tostring(err) end - end + if self.stop_state_monitor_op then + local ok, err = s:perform(self:stop_state_monitor_op(timeout)) + if not ok then errors[#errors + 1] = tostring(err) end + end - if self.inhibit_cmd and self.uninhibit_op then - local ok, err = s:perform(self:uninhibit_op(timeout)) - if not ok then errors[#errors + 1] = tostring(err) end - end + if self.inhibit_cmd and self.uninhibit_op then + local ok, err = s:perform(self:uninhibit_op(timeout)) + if not ok then errors[#errors + 1] = tostring(err) end + end - if #errors > 0 then - return false, table.concat(errors, "; ") - end + if #errors > 0 then + return false, table.concat(errors, "; ") + end - return true, "" - end):wrap(function(st, _, ...) - if st == "ok" then - return ... - elseif st == "cancelled" then - return false, "cancelled" - end - return false, (... or "modem backend shutdown failed") - end) + return true, "" + end):wrap(function(st, _, ...) + if st == "ok" then + return ... + elseif st == "cancelled" then + return false, "cancelled" + end + return false, (... or "modem backend shutdown failed") + end) end ---@param timeout number? ---@return boolean ok ---@return string error function ModemBackend:shutdown(timeout) - return fibers.perform(self:shutdown_op(timeout)) + return fibers.perform(self:shutdown_op(timeout)) end ---@return Op function ModemBackend:monitor_state_op() - return op.guard(function() - if not self.state_monitor then - return op.always(nil) - end - return self.state_monitor.stream:read_line_op():wrap(function(line) - local state_ev, err = parse_modem_state_line(line) - if state_ev then - self.last_state_event = state_ev - end - return state_ev, err - end) - end) + return op.guard(function() + if not self.state_monitor then + return op.always(nil) + end + return self.state_monitor.stream:read_line_op():wrap(function(line) + local state_ev, err = parse_modem_state_line(line) + if state_ev then + self.last_state_event = state_ev + end + return state_ev, err + end) + end) end ---@param period number ---@return boolean ok ---@return string error function ModemBackend:set_signal_update_interval(period) - local _, err = run_command(self.identity.address, { - "mmcli", "-m", self.identity.address, "--signal-setup=" .. tostring(period) - }) - return err == "", err + local _, err = run_command(self.identity.address, { + "mmcli", "-m", self.identity.address, "--signal-setup=" .. tostring(period) + }) + return err == "", err end ---@return ModemBackend local function new(address) - local self = { - identity = get_identity(address), - base = "linux_mm", - last_state_event = nil, - } - return setmetatable(self, ModemBackend) + local self = { + identity = get_identity(address), + base = "linux_mm", + last_state_event = nil, + } + return setmetatable(self, ModemBackend) end return { - new = new, - _test = { - is_signal_valid = is_signal_valid, - normalize_unlock_retries = normalize_unlock_retries, - parse_modem_info_json = parse_modem_info_json, - parse_signal_info_json = parse_signal_info_json, - valid_signal_ranges = VALID_SIGNAL_RANGES, - }, + new = new, + _test = { + is_signal_valid = is_signal_valid, + normalize_unlock_retries = normalize_unlock_retries, + parse_modem_info_json = parse_modem_info_json, + parse_signal_info_json = parse_signal_info_json, + valid_signal_ranges = VALID_SIGNAL_RANGES, + }, } diff --git a/src/services/hal/backends/modem/providers/linux_mm/init.lua b/src/services/hal/backends/modem/providers/linux_mm/init.lua index c8f7b224..152cde50 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/init.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -6,52 +6,52 @@ local backend = require "services.hal.backends.modem.providers.linux_mm.impl" local monitor = require "services.hal.backends.modem.providers.linux_mm.monitor" local function is_linux() - local fh, open_err = file.open("/proc/version", "r") - if not fh or open_err then - return false - end - - local content, read_err = fh:read_all() - fh:close() - if not content or read_err then - return false - end - - return content:lower():find("linux") ~= nil + local fh, open_err = file.open("/proc/version", "r") + if not fh or open_err then + return false + end + + local content, read_err = fh:read_all() + fh:close() + if not content or read_err then + return false + end + + return content:lower():find("linux") ~= nil end --- Returns true if `mmcli` is runnable ---@return boolean ok local function has_mmcli() - local cmd = exec.command{ - "mmcli", "--version", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, code, _, _ = fibers.perform(cmd:combined_output_op()) - if status == "exited" and code == 0 then - return true - end - return false + local cmd = exec.command{ + "mmcli", "--version", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, code, _, _ = fibers.perform(cmd:combined_output_op()) + if status == "exited" and code == 0 then + return true + end + return false end --- Returns if linux with modem manager is supported ---@return boolean local function is_supported() - local res = is_linux() and has_mmcli() - return res + local res = is_linux() and has_mmcli() + return res end ---@return ModemMonitor? monitor ---@return string error local function new_monitor() - return monitor.new() + return monitor.new() end return { - is_supported = is_supported, - backend = backend, - new_monitor = new_monitor, + is_supported = is_supported, + backend = backend, + new_monitor = new_monitor, } diff --git a/src/services/hal/backends/modem/providers/linux_mm/monitor.lua b/src/services/hal/backends/modem/providers/linux_mm/monitor.lua index 65e08917..2bc47bb3 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/monitor.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/monitor.lua @@ -4,11 +4,11 @@ local modem_types = require "services.hal.types.modem" local process_flags = require "services.hal.backends.modem.process_flags" local function terminate_command(cmd, sig) - if not cmd or type(cmd.kill) ~= 'function' then return true, nil end - local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) - if not ok then return nil, tostring(a) end - if a == false or a == nil then return nil, tostring(b or 'command kill failed') end - return true, nil + if not cmd or type(cmd.kill) ~= 'function' then return true, nil end + local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) + if not ok then return nil, tostring(a) end + if a == false or a == nil then return nil, tostring(b or 'command kill failed') end + return true, nil end ---@class ModemMonitor @@ -24,22 +24,22 @@ ModemMonitor.__index = ModemMonitor ---@return ModemMonitorEvent? ---@return string error local function parse_monitor_line(line) - if not line then - return nil, "Command closed" - end - - local status, address = line:match("^(.-)(/org%S+)") - if not address then - return nil, "line could not be parsed: " .. tostring(line) - end - - local is_added = not status:match("-") - local event, err = modem_types.new.ModemMonitorEvent(is_added, address) - if not event then - return nil, "failed to create monitor event: " .. tostring(err) - end - - return event, "" + if not line then + return nil, "Command closed" + end + + local status, address = line:match("^(.-)(/org%S+)") + if not address then + return nil, "line could not be parsed: " .. tostring(line) + end + + local is_added = not status:match("-") + local event, err = modem_types.new.ModemMonitorEvent(is_added, address) + if not event then + return nil, "failed to create monitor event: " .. tostring(err) + end + + return event, "" end --- Returns an Op that when performed yields the next ModemMonitorEvent. @@ -47,71 +47,71 @@ end --- (nil, error) signals an unparseable line — the caller should continue looping. ---@return Op function ModemMonitor:next_event_op() - return op.guard(function() - return self.stream:read_line_op():wrap(parse_monitor_line) - end) + return op.guard(function() + return self.stream:read_line_op():wrap(parse_monitor_line) + end) end ---Best-effort non-blocking teardown for scope finalisers. ---@param reason string? function ModemMonitor:terminate(reason) - reason = reason or "modem_monitor_terminated" + reason = reason or "modem_monitor_terminated" - if self.stream then - pcall(function() self.stream:terminate(reason) end) - end + if self.stream then + pcall(function() self.stream:terminate(reason) end) + end - if self.cmd then - terminate_command(self.cmd, 15) - end + if self.cmd then + terminate_command(self.cmd, 15) + end - self.stream = nil - self.cmd = nil + self.stream = nil + self.cmd = nil end ---@param timeout number? ---@return Op function ModemMonitor:shutdown_op(timeout) - return op.guard(function() - if self.stream then - pcall(function() self.stream:terminate("modem_monitor_stop") end) - end - - local cmd = self.cmd - self.cmd = nil - self.stream = nil - - if not cmd then - return op.always(true, "") - end - - return cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) - if status == "exited" or status == "signalled" then - return true, "" - end - return false, err or ("modem monitor shutdown failed: " .. tostring(status)) - end) - end) + return op.guard(function() + if self.stream then + pcall(function() self.stream:terminate("modem_monitor_stop") end) + end + + local cmd = self.cmd + self.cmd = nil + self.stream = nil + + if not cmd then + return op.always(true, "") + end + + return cmd:shutdown_op(timeout or 1.0):wrap(function(status, _code, _sig, err) + if status == "exited" or status == "signalled" then + return true, "" + end + return false, err or ("modem monitor shutdown failed: " .. tostring(status)) + end) + end) end --- Create and start a new ModemMonitor backed by `mmcli -M`. ---@return ModemMonitor? monitor ---@return string error local function new() - local cmd = exec.command { - "mmcli", "-M", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - flags = process_flags.owned_monitor_flags(), - } - local stream, err = cmd:stdout_stream() - if not stream then - return nil, "failed to start modem monitor: " .. tostring(err) - end - return setmetatable({ cmd = cmd, stream = stream }, ModemMonitor), "" + local cmd = exec.command { + "mmcli", "-M", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + flags = process_flags.owned_monitor_flags(), + } + local stream, err = cmd:stdout_stream() + if not stream then + return nil, "failed to start modem monitor: " .. tostring(err) + end + return setmetatable({ cmd = cmd, stream = stream }, ModemMonitor), "" end return { - new = new, + new = new, } diff --git a/src/services/hal/backends/radio/contract.lua b/src/services/hal/backends/radio/contract.lua index df316868..16310816 100644 --- a/src/services/hal/backends/radio/contract.lua +++ b/src/services/hal/backends/radio/contract.lua @@ -2,15 +2,15 @@ ---All radio backends must implement the functions listed in RADIO_BACKEND_FUNCTIONS. local RADIO_BACKEND_FUNCTIONS = { - 'get_meta', - 'apply', - 'clear', - 'start_client_monitor', - 'watch_clients_op', - 'get_connected_macs', - 'get_iface_info', - 'get_iface_survey', - 'get_station_info', + 'get_meta', + 'apply', + 'clear', + 'start_client_monitor', + 'watch_clients_op', + 'get_connected_macs', + 'get_iface_info', + 'get_iface_survey', + 'get_station_info', } ---Validate that a backend table implements all required functions. @@ -18,18 +18,18 @@ local RADIO_BACKEND_FUNCTIONS = { ---@return boolean ok ---@return string err Empty string on success. local function validate(backend) - if type(backend) ~= 'table' then - return false, "backend must be a table" - end - for _, fn_name in ipairs(RADIO_BACKEND_FUNCTIONS) do - if type(backend[fn_name]) ~= 'function' then - return false, "backend missing required function: " .. fn_name - end - end - return true, "" + if type(backend) ~= 'table' then + return false, "backend must be a table" + end + for _, fn_name in ipairs(RADIO_BACKEND_FUNCTIONS) do + if type(backend[fn_name]) ~= 'function' then + return false, "backend missing required function: " .. fn_name + end + end + return true, "" end return { - RADIO_BACKEND_FUNCTIONS = RADIO_BACKEND_FUNCTIONS, - validate = validate, + RADIO_BACKEND_FUNCTIONS = RADIO_BACKEND_FUNCTIONS, + validate = validate, } diff --git a/src/services/hal/backends/radio/provider.lua b/src/services/hal/backends/radio/provider.lua index 9353dc6b..10a9d40b 100644 --- a/src/services/hal/backends/radio/provider.lua +++ b/src/services/hal/backends/radio/provider.lua @@ -4,7 +4,7 @@ local contract = require "services.hal.backends.radio.contract" local BACKENDS = { - "services.hal.backends.radio.providers.openwrt", + "services.hal.backends.radio.providers.openwrt", } ---Instantiate a new backend for the named radio from the first supported provider. @@ -12,21 +12,21 @@ local BACKENDS = { ---@return table|nil backend instance ---@return string err "" on success local function new(name) - for _, path in ipairs(BACKENDS) do - local ok, provider = pcall(require, path) - if ok and provider.is_supported and provider.is_supported() then - local backend = provider.backend.new(name) - local valid, verr = contract.validate(backend) - if valid then - return backend, "" - else - return nil, "backend " .. path .. " failed contract check: " .. verr - end - end - end - return nil, "no supported radio backend found on this device" + for _, path in ipairs(BACKENDS) do + local ok, provider = pcall(require, path) + if ok and provider.is_supported and provider.is_supported() then + local backend = provider.backend.new(name) + local valid, verr = contract.validate(backend) + if valid then + return backend, "" + else + return nil, "backend " .. path .. " failed contract check: " .. verr + end + end + end + return nil, "no supported radio backend found on this device" end return { - new = new, + new = new, } diff --git a/src/services/hal/backends/radio/providers/openwrt/impl.lua b/src/services/hal/backends/radio/providers/openwrt/impl.lua index 5bed0d8c..b907324c 100644 --- a/src/services/hal/backends/radio/providers/openwrt/impl.lua +++ b/src/services/hal/backends/radio/providers/openwrt/impl.lua @@ -10,38 +10,38 @@ local exec = require "fibers.io.exec" local file = require "fibers.io.file" local SYSFS_STATS = { - 'rx_bytes', 'tx_bytes', - 'rx_packets', 'tx_packets', - 'rx_dropped', 'tx_dropped', - 'rx_errors', 'tx_errors', + 'rx_bytes', 'tx_bytes', + 'rx_packets', 'tx_packets', + 'rx_dropped', 'tx_dropped', + 'rx_errors', 'tx_errors', } local function owned_process_flags() - local flags = { process_group = true } - if type(exec.supports) == 'function' and exec.supports('parent_death_signal') then - flags.parent_death_signal = 'TERM' - end - return flags + local flags = { process_group = true } + if type(exec.supports) == 'function' and exec.supports('parent_death_signal') then + flags.parent_death_signal = 'TERM' + end + return flags end local function terminate_command(cmd, sig) - if not cmd or type(cmd.kill) ~= 'function' then return true, nil end - local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) - if not ok then return nil, tostring(a) end - if a == false or a == nil then return nil, tostring(b or 'command kill failed') end - return true, nil + if not cmd or type(cmd.kill) ~= 'function' then return true, nil end + local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) + if not ok then return nil, tostring(a) end + if a == false or a == nil then return nil, tostring(b or 'command kill failed') end + return true, nil end local function shutdown_command_op(cmd, timeout) - return op.guard(function() - if not cmd then return op.always(true, '') end - return cmd:shutdown_op(timeout or 0.2):wrap(function(status, _code, _sig, err) - if status == 'exited' or status == 'signalled' then - return true, '' - end - return false, err or ('command shutdown failed: ' .. tostring(status)) - end) - end) + return op.guard(function() + if not cmd then return op.always(true, '') end + return cmd:shutdown_op(timeout or 0.2):wrap(function(status, _code, _sig, err) + if status == 'exited' or status == 'signalled' then + return true, '' + end + return false, err or ('command shutdown failed: ' .. tostring(status)) + end) + end) end ---Read a single value from /sys/class/net//statistics/ @@ -50,67 +50,67 @@ end ---@return number|nil ---@return string|nil err local function read_sysfs_stat(iface, stat) - local path = '/sys/class/net/' .. iface .. '/statistics/' .. stat - local f, ferr = file.open(path, 'r') - if not f then return nil, tostring(ferr) end - local content, rerr = f:read_all() - f:close() - if not content then return nil, tostring(rerr) end - local num = tonumber((content or ''):match('(%d+)')) - if num == nil then return nil, stat .. ' invalid number' end - return num, nil + local path = '/sys/class/net/' .. iface .. '/statistics/' .. stat + local f, ferr = file.open(path, 'r') + if not f then return nil, tostring(ferr) end + local content, rerr = f:read_all() + f:close() + if not content then return nil, tostring(rerr) end + local num = tonumber((content or ''):match('(%d+)')) + if num == nil then return nil, stat .. ' invalid number' end + return num, nil end ---Parse `iw dev info` output into {txpower, channel, freq, width} local function parse_iw_dev_info(raw) - if not raw or raw == '' then return nil end - local result = {} - for line in raw:gmatch('[^\r\n]+') do - local txpower = line:match('%s*txpower%s+([%d.]+)%s+dBm') - if txpower then result.txpower = tonumber(txpower) end - - local chan, freq, width = line:match( - '%s*channel%s+(%d+)%s+%(([%d]+)%s*MHz%),%s*width:%s*(%d+)%s*MHz' - ) - if chan then - result.channel = tonumber(chan) - result.freq = tonumber(freq) - result.width = tonumber(width) - end - end - return result + if not raw or raw == '' then return nil end + local result = {} + for line in raw:gmatch('[^\r\n]+') do + local txpower = line:match('%s*txpower%s+([%d.]+)%s+dBm') + if txpower then result.txpower = tonumber(txpower) end + + local chan, freq, width = line:match( + '%s*channel%s+(%d+)%s+%(([%d]+)%s*MHz%),%s*width:%s*(%d+)%s*MHz' + ) + if chan then + result.channel = tonumber(chan) + result.freq = tonumber(freq) + result.width = tonumber(width) + end + end + return result end ---Parse `iw survey dump` for in-use noise value local function parse_survey_noise(raw) - if not raw or raw == '' then return nil end - local in_use = false - for line in raw:gmatch('[^\r\n]+') do - if not in_use then - if line:find('%[in use%]') then in_use = true end - else - local val = line:match('noise:%s*(%-?%d+%.?%d*)') - if val then return tonumber(val) end - end - end - return nil + if not raw or raw == '' then return nil end + local in_use = false + for line in raw:gmatch('[^\r\n]+') do + if not in_use then + if line:find('%[in use%]') then in_use = true end + else + local val = line:match('noise:%s*(%-?%d+%.?%d*)') + if val then return tonumber(val) end + end + end + return nil end ---Parse `iw dev station get ` output into {signal, tx_bytes, rx_bytes} local function parse_station_info(raw) - if not raw or raw == '' then return nil end - local result = {} - for line in raw:gmatch('[^\r\n]+') do - local sig = line:match('%s*signal:%s*(%-?%d+)%s*dBm') - if sig then result.signal = tonumber(sig) end - - local tx = line:match('%s*tx%s+bytes:%s+(%d+)') - if tx then result.tx_bytes = tonumber(tx) end - - local rx = line:match('%s*rx%s+bytes:%s+(%d+)') - if rx then result.rx_bytes = tonumber(rx) end - end - return result + if not raw or raw == '' then return nil end + local result = {} + for line in raw:gmatch('[^\r\n]+') do + local sig = line:match('%s*signal:%s*(%-?%d+)%s*dBm') + if sig then result.signal = tonumber(sig) end + + local tx = line:match('%s*tx%s+bytes:%s+(%d+)') + if tx then result.tx_bytes = tonumber(tx) end + + local rx = line:match('%s*rx%s+bytes:%s+(%d+)') + if rx then result.rx_bytes = tonumber(rx) end + end + return result end ---Parse `iw dev station dump` output into a list of MAC addresses. @@ -118,12 +118,12 @@ end ---@param raw string ---@return string[] list of MAC addresses local function parse_station_dump_macs(raw) - if not raw or raw == '' then return {} end - local macs = {} - for mac in raw:gmatch('Station%s+([%x:]+)%s+%(on%s+%S+%)') do - macs[#macs + 1] = mac - end - return macs + if not raw or raw == '' then return {} end + local macs = {} + for mac in raw:gmatch('Station%s+([%x:]+)%s+%(on%s+%S+%)') do + macs[#macs + 1] = mac + end + return macs end ------------------------------------------------------------------------ @@ -145,136 +145,136 @@ RadioBackend.SYSFS_STATS = SYSFS_STATS ---@param name string UCI radio section name (e.g. "radio0") ---@return RadioBackend function RadioBackend.new(name) - return setmetatable({ name = name, _monitor = nil }, RadioBackend) + return setmetatable({ name = name, _monitor = nil }, RadioBackend) end ---Get radio metadata from UCI wireless config. ---@return table|nil { path, type } ---@return string err "" on success function RadioBackend:get_meta() - local path = uci.get_value('wireless', self.name, 'path') - local rtype = uci.get_value('wireless', self.name, 'type') - if not path then - return nil, "could not read wireless." .. self.name .. ".path from UCI" - end - return { path = path, type = rtype or '' }, "" + local path = uci.get_value('wireless', self.name, 'path') + local rtype = uci.get_value('wireless', self.name, 'type') + if not path then + return nil, "could not read wireless." .. self.name .. ".path from UCI" + end + return { path = path, type = rtype or '' }, "" end ---Apply the staged radio config table to UCI and reload wireless. ---Uses the shared UCI reactor for debounced writes. ---@param staged table Full staged config as accumulated by the driver function RadioBackend:apply(staged) - uci.ensure_started() - local session = uci.new_session() - local name = self.name - - -- Delete any existing wifi-iface sections that belong to this radio but - -- are not in the staged interfaces set, so stale sections don't linger. - local staged_iface_names = {} - for _, iface in ipairs(staged.interfaces or {}) do - staged_iface_names[iface.name] = true - end - for _, sec in ipairs(uci.get_sections('wireless', 'wifi-iface')) do - if uci.get_value('wireless', sec, 'device') == name - and not staged_iface_names[sec] then - session:delete('wireless', sec) - end - end - - -- Radio section (wifi-device) — ensure it exists before setting options - session:set('wireless', name, 'wifi-device') - if staged.path and staged.path ~= '' then - session:set('wireless', name, 'path', staged.path) - end - if staged.type and staged.type ~= '' then - session:set('wireless', name, 'type', staged.type) - end - if staged.band then - session:set('wireless', name, 'band', staged.band) - end - if staged.channel ~= nil then - session:set('wireless', name, 'channel', staged.channel) - end - if staged.htmode then - session:set('wireless', name, 'htmode', staged.htmode) - end - if staged.channels then - session:set('wireless', name, 'channels', table.concat(staged.channels, ' ')) - end - if staged.txpower ~= nil then - session:set('wireless', name, 'txpower', staged.txpower) - end - if staged.country then - session:set('wireless', name, 'country', staged.country) - end - if staged.disabled ~= nil then - session:set('wireless', name, 'disabled', staged.disabled) - end - - -- Delete removed interfaces - for _, iface_name in ipairs(staged.deleted_interfaces or {}) do - session:delete('wireless', iface_name) - end - - -- Write interface sections (wifi-iface) - for _, iface in ipairs(staged.interfaces or {}) do - -- Ensure the named section exists with the correct type before setting options - session:set('wireless', iface.name, 'wifi-iface') - session:set('wireless', iface.name, 'ifname', iface.name) - session:set('wireless', iface.name, 'device', name) - session:set('wireless', iface.name, 'mode', iface.mode or 'ap') - session:set('wireless', iface.name, 'ssid', iface.ssid) - session:set('wireless', iface.name, 'encryption', iface.encryption) - session:set('wireless', iface.name, 'key', iface.password or '') - session:set('wireless', iface.name, 'network', iface.network) - if iface.enable_steering then - session:set('wireless', iface.name, 'bss_transition', '1') - session:set('wireless', iface.name, 'ieee80211k', '1') - session:set('wireless', iface.name, 'rrm_neighbor_report', '1') - session:set('wireless', iface.name, 'rrm_beacon_report', '1') - else - session:set('wireless', iface.name, 'bss_transition', '0') - session:set('wireless', iface.name, 'ieee80211k', '0') - session:set('wireless', iface.name, 'rrm_neighbor_report', '0') - session:set('wireless', iface.name, 'rrm_beacon_report', '0') - end - end - - local ok, err = session:commit('wireless', { { 'wifi', 'reload' } }) - if not ok then - error('apply commit failed: ' .. tostring(err)) - end + uci.ensure_started() + local session = uci.new_session() + local name = self.name + + -- Delete any existing wifi-iface sections that belong to this radio but + -- are not in the staged interfaces set, so stale sections don't linger. + local staged_iface_names = {} + for _, iface in ipairs(staged.interfaces or {}) do + staged_iface_names[iface.name] = true + end + for _, sec in ipairs(uci.get_sections('wireless', 'wifi-iface')) do + if uci.get_value('wireless', sec, 'device') == name + and not staged_iface_names[sec] then + session:delete('wireless', sec) + end + end + + -- Radio section (wifi-device) — ensure it exists before setting options + session:set('wireless', name, 'wifi-device') + if staged.path and staged.path ~= '' then + session:set('wireless', name, 'path', staged.path) + end + if staged.type and staged.type ~= '' then + session:set('wireless', name, 'type', staged.type) + end + if staged.band then + session:set('wireless', name, 'band', staged.band) + end + if staged.channel ~= nil then + session:set('wireless', name, 'channel', staged.channel) + end + if staged.htmode then + session:set('wireless', name, 'htmode', staged.htmode) + end + if staged.channels then + session:set('wireless', name, 'channels', table.concat(staged.channels, ' ')) + end + if staged.txpower ~= nil then + session:set('wireless', name, 'txpower', staged.txpower) + end + if staged.country then + session:set('wireless', name, 'country', staged.country) + end + if staged.disabled ~= nil then + session:set('wireless', name, 'disabled', staged.disabled) + end + + -- Delete removed interfaces + for _, iface_name in ipairs(staged.deleted_interfaces or {}) do + session:delete('wireless', iface_name) + end + + -- Write interface sections (wifi-iface) + for _, iface in ipairs(staged.interfaces or {}) do + -- Ensure the named section exists with the correct type before setting options + session:set('wireless', iface.name, 'wifi-iface') + session:set('wireless', iface.name, 'ifname', iface.name) + session:set('wireless', iface.name, 'device', name) + session:set('wireless', iface.name, 'mode', iface.mode or 'ap') + session:set('wireless', iface.name, 'ssid', iface.ssid) + session:set('wireless', iface.name, 'encryption', iface.encryption) + session:set('wireless', iface.name, 'key', iface.password or '') + session:set('wireless', iface.name, 'network', iface.network) + if iface.enable_steering then + session:set('wireless', iface.name, 'bss_transition', '1') + session:set('wireless', iface.name, 'ieee80211k', '1') + session:set('wireless', iface.name, 'rrm_neighbor_report', '1') + session:set('wireless', iface.name, 'rrm_beacon_report', '1') + else + session:set('wireless', iface.name, 'bss_transition', '0') + session:set('wireless', iface.name, 'ieee80211k', '0') + session:set('wireless', iface.name, 'rrm_neighbor_report', '0') + session:set('wireless', iface.name, 'rrm_beacon_report', '0') + end + end + + local ok, err = session:commit('wireless', { { 'wifi', 'reload' } }) + if not ok then + error('apply commit failed: ' .. tostring(err)) + end end ---Delete all UCI config owned by this radio and reload wireless. ---Removes all wifi-iface sections whose device == self.name, then ---deletes the configurable options from the wifi-device section. function RadioBackend:clear() - uci.ensure_started() - local session = uci.new_session() - local name = self.name - - -- Delete all wifi-iface sections that belong to this radio - for _, sec in ipairs(uci.get_sections('wireless', 'wifi-iface')) do - if uci.get_value('wireless', sec, 'device') == name then - session:delete('wireless', sec) - end - end - - -- Delete the configurable options on the wifi-device section - -- (leave 'path' and 'type' intact since they are hardware facts) - local OPT_KEYS = { 'band', 'channel', 'channels', 'htmode', 'txpower', - 'country', 'disabled' } - for _, opt in ipairs(OPT_KEYS) do - if uci.get_value('wireless', name, opt) ~= nil then - session:delete('wireless', name, opt) - end - end - - local ok, err = session:commit('wireless', { { 'wifi', 'reload' } }) - if not ok then - error('clear failed: ' .. tostring(err)) - end + uci.ensure_started() + local session = uci.new_session() + local name = self.name + + -- Delete all wifi-iface sections that belong to this radio + for _, sec in ipairs(uci.get_sections('wireless', 'wifi-iface')) do + if uci.get_value('wireless', sec, 'device') == name then + session:delete('wireless', sec) + end + end + + -- Delete the configurable options on the wifi-device section + -- (leave 'path' and 'type' intact since they are hardware facts) + local OPT_KEYS = { 'band', 'channel', 'channels', 'htmode', 'txpower', + 'country', 'disabled' } + for _, opt in ipairs(OPT_KEYS) do + if uci.get_value('wireless', name, opt) ~= nil then + session:delete('wireless', name, opt) + end + end + + local ok, err = session:commit('wireless', { { 'wifi', 'reload' } }) + if not ok then + error('clear failed: ' .. tostring(err)) + end end ---Parse a single `iw event` line into a ClientEvent, or nil if not a station event. @@ -282,10 +282,10 @@ end ---@param line string ---@return ClientEvent|nil local function parse_client_event_line(line) - local iface, verb, mac = line:match('^(%S-):%s+(%a+)%s+station%s+([%x:]+)$') - if not iface then return nil end - if verb ~= 'new' and verb ~= 'del' then return nil end - return { mac = mac, added = verb == 'new', interface = iface } + local iface, verb, mac = line:match('^(%S-):%s+(%a+)%s+station%s+([%x:]+)$') + if not iface then return nil end + if verb ~= 'new' and verb ~= 'del' then return nil end + return { mac = mac, added = verb == 'new', interface = iface } end ---Start the iw event subprocess for client monitoring. @@ -293,46 +293,46 @@ end ---@return boolean ok ---@return string err function RadioBackend:start_client_monitor() - if self._monitor then - return false, 'client monitor already started' - end - local cmd = exec.command { - 'iw', 'event', - stdin = 'null', - stdout = 'pipe', - stderr = 'null', - shutdown_grace = 0.2, - flags = owned_process_flags(), - } - local stdout, err = cmd:stdout_stream() - if not stdout then - return false, 'failed to start iw event: ' .. tostring(err) - end - self._monitor = { cmd = cmd, stdout = stdout } - return true, '' + if self._monitor then + return false, 'client monitor already started' + end + local cmd = exec.command { + 'iw', 'event', + stdin = 'null', + stdout = 'pipe', + stderr = 'null', + shutdown_grace = 0.2, + flags = owned_process_flags(), + } + local stdout, err = cmd:stdout_stream() + if not stdout then + return false, 'failed to start iw event: ' .. tostring(err) + end + self._monitor = { cmd = cmd, stdout = stdout } + return true, '' end ---Immediate best-effort stop for finalisers. ---@param reason string? function RadioBackend:terminate(reason) - local mon = self._monitor - self._monitor = nil - if not mon then return true, nil end - if mon.stdout then pcall(function() mon.stdout:terminate(reason or 'radio monitor terminated') end) end - return terminate_command(mon.cmd, 15) + local mon = self._monitor + self._monitor = nil + if not mon then return true, nil end + if mon.stdout then pcall(function() mon.stdout:terminate(reason or 'radio monitor terminated') end) end + return terminate_command(mon.cmd, 15) end ---Gracefully stop the iw event subprocess. ---@param timeout number? ---@return Op function RadioBackend:stop_client_monitor_op(timeout) - return op.guard(function() - local mon = self._monitor - self._monitor = nil - if not mon then return op.always(true, '') end - if mon.stdout then pcall(function() mon.stdout:terminate('radio monitor stopped') end) end - return shutdown_command_op(mon.cmd, timeout or 0.2) - end) + return op.guard(function() + local mon = self._monitor + self._monitor = nil + if not mon then return op.always(true, '') end + if mon.stdout then pcall(function() mon.stdout:terminate('radio monitor stopped') end) end + return shutdown_command_op(mon.cmd, timeout or 0.2) + end) end ---Return an op that blocks until the next client connect/disconnect event @@ -340,32 +340,32 @@ end ---Each perform of the op yields exactly one matching event. ---@return Op function RadioBackend:watch_clients_op() - return op.guard(function() - if not self._monitor then - return op.always(nil, 'client monitor not started') - end - return scope.run_op(function(s) - while true do - ---@diagnostic disable-next-line: need-check-nil - local line = s:perform(self._monitor.stdout:read_line_op()) --[[@as string?]] - if not line then - return nil, 'iw event stream closed' - end - local ev = parse_client_event_line(line) - if ev then - return ev - end - end - end):wrap(function(st, _, ...) - if st == 'ok' then - return ... - elseif st == 'cancelled' then - return nil, 'cancelled' - else - return nil, (... or 'iw event monitor failed') - end - end) - end) + return op.guard(function() + if not self._monitor then + return op.always(nil, 'client monitor not started') + end + return scope.run_op(function(s) + while true do + ---@diagnostic disable-next-line: need-check-nil + local line = s:perform(self._monitor.stdout:read_line_op()) --[[@as string?]] + if not line then + return nil, 'iw event stream closed' + end + local ev = parse_client_event_line(line) + if ev then + return ev + end + end + end):wrap(function(st, _, ...) + if st == 'ok' then + return ... + elseif st == 'cancelled' then + return nil, 'cancelled' + else + return nil, (... or 'iw event monitor failed') + end + end) + end) end ---Get interface info: txpower, channel, freq, width. @@ -373,12 +373,12 @@ end ---@return table|nil { txpower, channel, freq, width } ---@return string err function RadioBackend:get_iface_info(iface) - local proc = exec.command { 'iw', 'dev', iface, 'info', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, status, _, _, err = fibers.perform(proc:output_op()) - if status ~= 'exited' or err then return nil, tostring(err or 'iw dev info failed') end - local result = parse_iw_dev_info(out) - if not result then return nil, "could not parse iw dev info output" end - return result, "" + local proc = exec.command { 'iw', 'dev', iface, 'info', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, status, _, _, err = fibers.perform(proc:output_op()) + if status ~= 'exited' or err then return nil, tostring(err or 'iw dev info failed') end + local result = parse_iw_dev_info(out) + if not result then return nil, "could not parse iw dev info output" end + return result, "" end ---Get noise floor for an interface via survey dump. @@ -386,12 +386,12 @@ end ---@return number|nil noise value ---@return string err function RadioBackend:get_iface_survey(iface) - local proc = exec.command { 'iw', iface, 'survey', 'dump', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, status, _, _, err = fibers.perform(proc:output_op()) - if status ~= 'exited' or err then return nil, tostring(err or 'iw survey failed') end - local noise = parse_survey_noise(out) - if noise == nil then return nil, "noise value not found" end - return noise, "" + local proc = exec.command { 'iw', iface, 'survey', 'dump', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, status, _, _, err = fibers.perform(proc:output_op()) + if status ~= 'exited' or err then return nil, tostring(err or 'iw survey failed') end + local noise = parse_survey_noise(out) + if noise == nil then return nil, "noise value not found" end + return noise, "" end ---Get per-client stats: signal, tx_bytes, rx_bytes. @@ -400,12 +400,12 @@ end ---@return table|nil { signal, tx_bytes, rx_bytes } ---@return string err function RadioBackend:get_station_info(iface, mac) - local proc = exec.command { 'iw', 'dev', iface, 'station', 'get', mac, stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, status, _, _, err = fibers.perform(proc:output_op()) - if status ~= 'exited' or err then return nil, tostring(err or 'iw station get failed') end - local result = parse_station_info(out) - if not result then return nil, "could not parse station info" end - return result, "" + local proc = exec.command { 'iw', 'dev', iface, 'station', 'get', mac, stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, status, _, _, err = fibers.perform(proc:output_op()) + if status ~= 'exited' or err then return nil, tostring(err or 'iw station get failed') end + local result = parse_station_info(out) + if not result then return nil, "could not parse station info" end + return result, "" end ---Get all currently associated MAC addresses for an interface. @@ -413,10 +413,10 @@ end ---@return string[] list of MAC addresses (may be empty) ---@return string err function RadioBackend:get_connected_macs(iface) - local proc = exec.command { 'iw', 'dev', iface, 'station', 'dump', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, status, _, _, err = fibers.perform(proc:output_op()) - if status ~= 'exited' or err then return {}, tostring(err or 'iw station dump failed') end - return parse_station_dump_macs(out), '' + local proc = exec.command { 'iw', 'dev', iface, 'station', 'dump', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, status, _, _, err = fibers.perform(proc:output_op()) + if status ~= 'exited' or err then return {}, tostring(err or 'iw station dump failed') end + return parse_station_dump_macs(out), '' end ---Read a sysfs statistics value for an interface. @@ -425,9 +425,9 @@ end ---@return number|nil ---@return string|nil err function RadioBackend:read_sysfs_stat(iface, stat) - return read_sysfs_stat(iface, stat) + return read_sysfs_stat(iface, stat) end return { - new = RadioBackend.new, + new = RadioBackend.new, } diff --git a/src/services/hal/backends/radio/providers/openwrt/init.lua b/src/services/hal/backends/radio/providers/openwrt/init.lua index 8269631a..7d7d4423 100644 --- a/src/services/hal/backends/radio/providers/openwrt/init.lua +++ b/src/services/hal/backends/radio/providers/openwrt/init.lua @@ -4,12 +4,12 @@ local file = require "fibers.io.file" ---Check whether OpenWrt UCI is available on this device. ---@return boolean local function is_supported() - local f, _ = file.open('/etc/openwrt_release', 'r') - if f then f:close() return true end - return false + local f, _ = file.open('/etc/openwrt_release', 'r') + if f then f:close() return true end + return false end return { - is_supported = is_supported, - backend = impl, + is_supported = is_supported, + backend = impl, } diff --git a/src/services/hal/backends/time/contract.lua b/src/services/hal/backends/time/contract.lua index c1da2caf..5c18b17b 100644 --- a/src/services/hal/backends/time/contract.lua +++ b/src/services/hal/backends/time/contract.lua @@ -4,23 +4,23 @@ ---@field stop fun(self: TimeBackend): boolean, string local BACKEND_FUNCTIONS = { - "start_ntp_monitor", - "ntp_event_op", - "stop", + "start_ntp_monitor", + "ntp_event_op", + "stop", } ---Check that a time backend provides all required functions. ---@param backend TimeBackend ---@return string error Empty string on success. local function validate(backend) - for _, func in ipairs(BACKEND_FUNCTIONS) do - if type(backend[func]) ~= "function" then - return "Missing required function: " .. func - end - end - return "" + for _, func in ipairs(BACKEND_FUNCTIONS) do + if type(backend[func]) ~= "function" then + return "Missing required function: " .. func + end + end + return "" end return { - validate = validate + validate = validate } diff --git a/src/services/hal/backends/time/provider.lua b/src/services/hal/backends/time/provider.lua index 5377c00c..31013c7d 100644 --- a/src/services/hal/backends/time/provider.lua +++ b/src/services/hal/backends/time/provider.lua @@ -4,26 +4,26 @@ local contract = require "services.hal.backends.time.contract" local BACKENDS = { - "openwrt" + "openwrt" } --- Select and initialize the backend implementation ---@return table backend_impl local function get_backend_impl() - local backend_impl = nil - for _, backend_name in ipairs(BACKENDS) do - local ok, backend_mod = pcall(require, "services.hal.backends.time.providers." .. backend_name .. ".init") - if ok and type(backend_mod) == "table" and backend_mod.is_supported and backend_mod.is_supported() then - backend_impl = backend_mod.backend - break - end - end - - if backend_impl == nil then - error("No supported time backend found") - end - - return backend_impl + local backend_impl = nil + for _, backend_name in ipairs(BACKENDS) do + local ok, backend_mod = pcall(require, "services.hal.backends.time.providers." .. backend_name .. ".init") + if ok and type(backend_mod) == "table" and backend_mod.is_supported and backend_mod.is_supported() then + backend_impl = backend_mod.backend + break + end + end + + if backend_impl == nil then + error("No supported time backend found") + end + + return backend_impl end ---Create a new TimeBackend instance. @@ -33,17 +33,17 @@ end --- ---@return TimeBackend local function new() - local backend_impl = get_backend_impl() - local backend = backend_impl.new() + local backend_impl = get_backend_impl() + local backend = backend_impl.new() - local iface_err = contract.validate(backend) - if iface_err ~= "" then - error("Time backend does not implement required interface: " .. tostring(iface_err)) - end + local iface_err = contract.validate(backend) + if iface_err ~= "" then + error("Time backend does not implement required interface: " .. tostring(iface_err)) + end - return backend + return backend end return { - new = new, + new = new, } diff --git a/src/services/hal/backends/time/providers/openwrt/impl.lua b/src/services/hal/backends/time/providers/openwrt/impl.lua index c004f0bd..bce0c4da 100644 --- a/src/services/hal/backends/time/providers/openwrt/impl.lua +++ b/src/services/hal/backends/time/providers/openwrt/impl.lua @@ -11,31 +11,31 @@ local exec = require "fibers.io.exec" local cjson = require "cjson.safe" local function owned_process_flags() - local flags = { process_group = true } - if type(exec.supports) == 'function' and exec.supports('parent_death_signal') then - flags.parent_death_signal = 'TERM' - end - return flags + local flags = { process_group = true } + if type(exec.supports) == 'function' and exec.supports('parent_death_signal') then + flags.parent_death_signal = 'TERM' + end + return flags end local function terminate_command(cmd, sig) - if not cmd or type(cmd.kill) ~= 'function' then return true, nil end - local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) - if not ok then return nil, tostring(a) end - if a == false or a == nil then return nil, tostring(b or 'command kill failed') end - return true, nil + if not cmd or type(cmd.kill) ~= 'function' then return true, nil end + local ok, a, b = pcall(function() return cmd:kill(sig or 15) end) + if not ok then return nil, tostring(a) end + if a == false or a == nil then return nil, tostring(b or 'command kill failed') end + return true, nil end local function shutdown_command_op(cmd, timeout) - return op.guard(function() - if not cmd then return op.always(true, '') end - return cmd:shutdown_op(timeout or 0.2):wrap(function(status, _code, _sig, err) - if status == 'exited' or status == 'signalled' then - return true, '' - end - return false, err or ('command shutdown failed: ' .. tostring(status)) - end) - end) + return op.guard(function() + if not cmd then return op.always(true, '') end + return cmd:shutdown_op(timeout or 0.2):wrap(function(status, _code, _sig, err) + if status == 'exited' or status == 'signalled' then + return true, '' + end + return false, err or ('command shutdown failed: ' .. tostring(status)) + end) + end) end ---@class OpenWrtTimeBackend : TimeBackend @@ -50,22 +50,22 @@ OpenWrtTimeBackend.__index = OpenWrtTimeBackend ---@param value any ---@return any local function coerce_numeric_strings(value) - if type(value) == 'string' then - local n = tonumber(value) - if n ~= nil then - return n - end - return value - end - - if type(value) == 'table' then - for k, v in pairs(value) do - value[k] = coerce_numeric_strings(v) - end - return value - end - - return value + if type(value) == 'string' then + local n = tonumber(value) + if n ~= nil then + return n + end + return value + end + + if type(value) == 'table' then + for k, v in pairs(value) do + value[k] = coerce_numeric_strings(v) + end + return value + end + + return value end ---Parse a single ubus listen hotplug.ntp line into a strongly typed NTPEvent. @@ -80,50 +80,50 @@ end ---@return any? ---@return string? local function parse_ntp_event_line(line, read_err) - if read_err ~= nil then - return nil, "read error: " .. tostring(read_err) - end - - if line == nil or line == "" then - return nil, "stream closed" - end - - local decoded = cjson.decode(line) - if not decoded then - return nil, "decode failed: " .. line - end - - decoded = coerce_numeric_strings(decoded) - local ntp_data = decoded["hotplug.ntp"] - if type(ntp_data) ~= 'table' then - return nil, "missing hotplug.ntp key: " .. line - end - - if type(ntp_data.stratum) ~= 'number' then - return nil, "invalid stratum: " .. line - end - - local action = ntp_data.action or "unknown" - local offset = ntp_data.offset or 0 - local freq_drift_ppm = ntp_data.freq_drift_ppm or 0 - - local ntp_event, event_err = time_types.new.NTPEvent( - ntp_data.stratum, - action, - offset, - freq_drift_ppm - ) - if not ntp_event then - return nil, "NTPEvent construction failed: " .. tostring(event_err) - end - - for k, v in pairs(ntp_data) do - if ntp_event[k] == nil then - ntp_event[k] = v - end - end - - return ntp_event, nil + if read_err ~= nil then + return nil, "read error: " .. tostring(read_err) + end + + if line == nil or line == "" then + return nil, "stream closed" + end + + local decoded = cjson.decode(line) + if not decoded then + return nil, "decode failed: " .. line + end + + decoded = coerce_numeric_strings(decoded) + local ntp_data = decoded["hotplug.ntp"] + if type(ntp_data) ~= 'table' then + return nil, "missing hotplug.ntp key: " .. line + end + + if type(ntp_data.stratum) ~= 'number' then + return nil, "invalid stratum: " .. line + end + + local action = ntp_data.action or "unknown" + local offset = ntp_data.offset or 0 + local freq_drift_ppm = ntp_data.freq_drift_ppm or 0 + + local ntp_event, event_err = time_types.new.NTPEvent( + ntp_data.stratum, + action, + offset, + freq_drift_ppm + ) + if not ntp_event then + return nil, "NTPEvent construction failed: " .. tostring(event_err) + end + + for k, v in pairs(ntp_data) do + if ntp_event[k] == nil then + ntp_event[k] = v + end + end + + return ntp_event, nil end ---- Backend Lifecycle ---- @@ -133,26 +133,26 @@ end ---@return boolean ok ---@return string error Empty string on success. function OpenWrtTimeBackend:start_ntp_monitor() - if self.ntp_monitor_cmd then - return false, "NTP monitor already running" - end - - -- Start ubus listen command bound to current scope - self.ntp_monitor_cmd = exec.command{ - 'ubus', 'listen', 'hotplug.ntp', - stdin = 'null', - stdout = 'pipe', - stderr = 'null', - shutdown_grace = 0.2, - flags = owned_process_flags(), - } - local stream, stream_err = self.ntp_monitor_cmd:stdout_stream() - if not stream then - return false, "failed to start ubus listen: " .. tostring(stream_err) - end - - self.ntp_monitor_stream = stream - return true, "" + if self.ntp_monitor_cmd then + return false, "NTP monitor already running" + end + + -- Start ubus listen command bound to current scope + self.ntp_monitor_cmd = exec.command{ + 'ubus', 'listen', 'hotplug.ntp', + stdin = 'null', + stdout = 'pipe', + stderr = 'null', + shutdown_grace = 0.2, + flags = owned_process_flags(), + } + local stream, stream_err = self.ntp_monitor_cmd:stdout_stream() + if not stream then + return false, "failed to start ubus listen: " .. tostring(stream_err) + end + + self.ntp_monitor_stream = stream + return true, "" end ---Get an operation that yields the next NTP event from the hotplug.ntp stream. @@ -162,12 +162,12 @@ end --- ---@return Op function OpenWrtTimeBackend:ntp_event_op() - return op.guard(function() - if not self.ntp_monitor_stream then - error("NTP monitor not started") - end - return self.ntp_monitor_stream:read_line_op():wrap(parse_ntp_event_line) - end) + return op.guard(function() + if not self.ntp_monitor_stream then + error("NTP monitor not started") + end + return self.ntp_monitor_stream:read_line_op():wrap(parse_ntp_event_line) + end) end ---Stop the NTP monitor and clean up resources. @@ -175,28 +175,28 @@ end ---@return boolean ok ---@return string error function OpenWrtTimeBackend:terminate(reason) - local stream = self.ntp_monitor_stream - local cmd = self.ntp_monitor_cmd - self.ntp_monitor_stream = nil - self.ntp_monitor_cmd = nil - if stream then pcall(function() stream:terminate(reason or 'ntp monitor terminated') end) end - return terminate_command(cmd, 15) + local stream = self.ntp_monitor_stream + local cmd = self.ntp_monitor_cmd + self.ntp_monitor_stream = nil + self.ntp_monitor_cmd = nil + if stream then pcall(function() stream:terminate(reason or 'ntp monitor terminated') end) end + return terminate_command(cmd, 15) end function OpenWrtTimeBackend:shutdown_op(timeout) - return op.guard(function() - local stream = self.ntp_monitor_stream - local cmd = self.ntp_monitor_cmd - self.ntp_monitor_stream = nil - self.ntp_monitor_cmd = nil - if stream then pcall(function() stream:terminate('ntp monitor stopped') end) end - return shutdown_command_op(cmd, timeout or 0.2) - end) + return op.guard(function() + local stream = self.ntp_monitor_stream + local cmd = self.ntp_monitor_cmd + self.ntp_monitor_stream = nil + self.ntp_monitor_cmd = nil + if stream then pcall(function() stream:terminate('ntp monitor stopped') end) end + return shutdown_command_op(cmd, timeout or 0.2) + end) end function OpenWrtTimeBackend:stop() - local fibers = require 'fibers' - return fibers.perform(self:shutdown_op(0.2)) + local fibers = require 'fibers' + return fibers.perform(self:shutdown_op(0.2)) end ---- Constructor ---- @@ -205,12 +205,12 @@ end --- ---@return OpenWrtTimeBackend local function new() - return setmetatable({ - ntp_monitor_stream = nil, - ntp_monitor_cmd = nil, - }, OpenWrtTimeBackend) + return setmetatable({ + ntp_monitor_stream = nil, + ntp_monitor_cmd = nil, + }, OpenWrtTimeBackend) end return { - new = new, + new = new, } diff --git a/src/services/hal/backends/time/providers/openwrt/init.lua b/src/services/hal/backends/time/providers/openwrt/init.lua index ec563590..5bbf7398 100644 --- a/src/services/hal/backends/time/providers/openwrt/init.lua +++ b/src/services/hal/backends/time/providers/openwrt/init.lua @@ -5,44 +5,44 @@ local fibers = require "fibers" local backend = require "services.hal.backends.time.providers.openwrt.impl" local function is_linux() - local fh, open_err = file.open("/proc/version", "r") - if not fh or open_err then - return false - end + local fh, open_err = file.open("/proc/version", "r") + if not fh or open_err then + return false + end - local content, read_err = fh:read_all() - fh:close() - if not content or read_err then - return false - end + local content, read_err = fh:read_all() + fh:close() + if not content or read_err then + return false + end - return content:lower():find("linux") ~= nil + return content:lower():find("linux") ~= nil end --- Returns true if `ubus` is available and the daemon is reachable ---@return boolean ok local function has_ubus() - local cmd = exec.command{ - "ubus", "list", - stdin = "null", - stdout = "pipe", - stderr = "null" - } - local _, status, code = fibers.perform(cmd:combined_output_op()) - if status == "exited" and code == 0 then - return true - end - return false + local cmd = exec.command{ + "ubus", "list", + stdin = "null", + stdout = "pipe", + stderr = "null" + } + local _, status, code = fibers.perform(cmd:combined_output_op()) + if status == "exited" and code == 0 then + return true + end + return false end --- Returns true if this is a supported OpenWrt system ---@return boolean local function is_supported() - local res = is_linux() and has_ubus() - return res + local res = is_linux() and has_ubus() + return res end return { - is_supported = is_supported, - backend = backend + is_supported = is_supported, + backend = backend } diff --git a/src/services/hal/drivers/band.lua b/src/services/hal/drivers/band.lua index 11f95c0c..b854f606 100644 --- a/src/services/hal/drivers/band.lua +++ b/src/services/hal/drivers/band.lua @@ -11,14 +11,14 @@ local CONTROL_Q_LEN = 16 local VALID_KICK_MODES = { 'none', 'compare', 'absolute', 'both' } local VALID_RRM_MODES = { 'PAT' } local VALID_LEGACY_KEYS = { - 'eval_probe_req', 'eval_assoc_req', 'eval_auth_req', - 'min_probe_count', 'deny_assoc_reason', 'deny_auth_reason', + 'eval_probe_req', 'eval_assoc_req', 'eval_auth_req', + 'min_probe_count', 'deny_assoc_reason', 'deny_auth_reason', } local VALID_BAND_KICKING_OPTS = { - 'rssi_center', 'rssi_reward_threshold', 'rssi_reward', - 'rssi_penalty_threshold', 'rssi_penalty', 'rssi_weight', - 'channel_util_reward_threshold', 'channel_util_reward', - 'channel_util_penalty_threshold', 'channel_util_penalty', + 'rssi_center', 'rssi_reward_threshold', 'rssi_reward', + 'rssi_penalty_threshold', 'rssi_penalty', 'rssi_weight', + 'channel_util_reward_threshold', 'channel_util_reward', + 'channel_util_penalty_threshold', 'channel_util_penalty', } local VALID_UPDATE_KEYS = { 'client', 'chan_util', 'hostapd', 'beacon_reports', 'tcp_con' } local VALID_CLEANUP_KEYS = { 'probe', 'client', 'ap' } @@ -28,10 +28,10 @@ local VALID_BANDS = { '2G', '5G' } local VALID_SUPPORTS = { 'ht', 'vht' } local function is_in(value, list) - for _, v in ipairs(list) do - if v == value then return true end - end - return false + for _, v in ipairs(list) do + if v == value then return true end + end + return false end ---@class BandDriver @@ -55,356 +55,356 @@ BandDriver.__index = BandDriver ---@return boolean ok ---@return string? reason function BandDriver:set_log_level(opts) - if getmetatable(opts) ~= cap_args.BandSetLogLevelOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetLogLevelOpts(opts.level) - if not casted then return false, err end - opts = casted - end - local level = opts.level - if type(level) ~= 'number' or level < 0 then - return false, 'level must be a non-negative number' - end - self.staged.log_level = level - return true + if getmetatable(opts) ~= cap_args.BandSetLogLevelOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetLogLevelOpts(opts.level) + if not casted then return false, err end + opts = casted + end + local level = opts.level + if type(level) ~= 'number' or level < 0 then + return false, 'level must be a non-negative number' + end + self.staged.log_level = level + return true end ---@param opts BandSetKickingOpts ---@return boolean ok ---@return string? reason function BandDriver:set_kicking(opts) - if getmetatable(opts) ~= cap_args.BandSetKickingOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetKickingOpts( - opts.mode, opts.bandwidth_threshold, opts.kicking_threshold, opts.evals_before_kick) - if not casted then return false, err end - opts = casted - end - if not is_in(opts.mode, VALID_KICK_MODES) then - return false, 'mode must be one of: ' .. table.concat(VALID_KICK_MODES, ', ') - end - if type(opts.bandwidth_threshold) ~= 'number' or opts.bandwidth_threshold < 0 then - return false, 'bandwidth_threshold must be a non-negative number' - end - if type(opts.kicking_threshold) ~= 'number' or opts.kicking_threshold < 0 then - return false, 'kicking_threshold must be a non-negative number' - end - if type(opts.evals_before_kick) ~= 'number' or opts.evals_before_kick < 0 then - return false, 'evals_before_kick must be a non-negative integer' - end - self.staged.kicking = { - mode = opts.mode, - bandwidth_threshold = opts.bandwidth_threshold, - kicking_threshold = opts.kicking_threshold, - evals_before_kick = opts.evals_before_kick, - } - return true + if getmetatable(opts) ~= cap_args.BandSetKickingOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetKickingOpts( + opts.mode, opts.bandwidth_threshold, opts.kicking_threshold, opts.evals_before_kick) + if not casted then return false, err end + opts = casted + end + if not is_in(opts.mode, VALID_KICK_MODES) then + return false, 'mode must be one of: ' .. table.concat(VALID_KICK_MODES, ', ') + end + if type(opts.bandwidth_threshold) ~= 'number' or opts.bandwidth_threshold < 0 then + return false, 'bandwidth_threshold must be a non-negative number' + end + if type(opts.kicking_threshold) ~= 'number' or opts.kicking_threshold < 0 then + return false, 'kicking_threshold must be a non-negative number' + end + if type(opts.evals_before_kick) ~= 'number' or opts.evals_before_kick < 0 then + return false, 'evals_before_kick must be a non-negative integer' + end + self.staged.kicking = { + mode = opts.mode, + bandwidth_threshold = opts.bandwidth_threshold, + kicking_threshold = opts.kicking_threshold, + evals_before_kick = opts.evals_before_kick, + } + return true end ---@param opts BandSetStationCountingOpts ---@return boolean ok ---@return string? reason function BandDriver:set_station_counting(opts) - if getmetatable(opts) ~= cap_args.BandSetStationCountingOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetStationCountingOpts(opts.use_station_count, opts.max_station_diff) - if not casted then return false, err end - opts = casted - end - if type(opts.use_station_count) ~= 'boolean' then - return false, 'use_station_count must be a boolean' - end - if type(opts.max_station_diff) ~= 'number' or opts.max_station_diff < 0 then - return false, 'max_station_diff must be a non-negative integer' - end - self.staged.station_counting = { - use_station_count = opts.use_station_count, - max_station_diff = opts.max_station_diff, - } - return true + if getmetatable(opts) ~= cap_args.BandSetStationCountingOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetStationCountingOpts(opts.use_station_count, opts.max_station_diff) + if not casted then return false, err end + opts = casted + end + if type(opts.use_station_count) ~= 'boolean' then + return false, 'use_station_count must be a boolean' + end + if type(opts.max_station_diff) ~= 'number' or opts.max_station_diff < 0 then + return false, 'max_station_diff must be a non-negative integer' + end + self.staged.station_counting = { + use_station_count = opts.use_station_count, + max_station_diff = opts.max_station_diff, + } + return true end ---@param opts BandSetRrmModeOpts ---@return boolean ok ---@return string? reason function BandDriver:set_rrm_mode(opts) - if getmetatable(opts) ~= cap_args.BandSetRrmModeOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetRrmModeOpts(opts.mode) - if not casted then return false, err end - opts = casted - end - if not is_in(opts.mode, VALID_RRM_MODES) then - return false, 'mode must be one of: ' .. table.concat(VALID_RRM_MODES, ', ') - end - self.staged.rrm_mode = opts.mode - return true + if getmetatable(opts) ~= cap_args.BandSetRrmModeOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetRrmModeOpts(opts.mode) + if not casted then return false, err end + opts = casted + end + if not is_in(opts.mode, VALID_RRM_MODES) then + return false, 'mode must be one of: ' .. table.concat(VALID_RRM_MODES, ', ') + end + self.staged.rrm_mode = opts.mode + return true end ---@param opts BandSetNeighbourReportsOpts ---@return boolean ok ---@return string? reason function BandDriver:set_neighbour_reports(opts) - if getmetatable(opts) ~= cap_args.BandSetNeighbourReportsOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetNeighbourReportsOpts(opts.dyn_report_num, opts.disassoc_report_len) - if not casted then return false, err end - opts = casted - end - local dyn = tonumber(opts.dyn_report_num) - local dis = tonumber(opts.disassoc_report_len) - if not dyn or dyn < 0 then - return false, 'dyn_report_num must be a non-negative integer' - end - if not dis or dis < 0 then - return false, 'disassoc_report_len must be a non-negative integer' - end - self.staged.neighbour_reports = { - dyn_report_num = dyn, - disassoc_report_len = dis, - } - return true + if getmetatable(opts) ~= cap_args.BandSetNeighbourReportsOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetNeighbourReportsOpts(opts.dyn_report_num, opts.disassoc_report_len) + if not casted then return false, err end + opts = casted + end + local dyn = tonumber(opts.dyn_report_num) + local dis = tonumber(opts.disassoc_report_len) + if not dyn or dyn < 0 then + return false, 'dyn_report_num must be a non-negative integer' + end + if not dis or dis < 0 then + return false, 'disassoc_report_len must be a non-negative integer' + end + self.staged.neighbour_reports = { + dyn_report_num = dyn, + disassoc_report_len = dis, + } + return true end ---@param opts BandSetLegacyOptionsOpts ---@return boolean ok ---@return string? reason function BandDriver:set_legacy_options(opts) - if getmetatable(opts) ~= cap_args.BandSetLegacyOptionsOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetLegacyOptionsOpts(opts.opts) - if not casted then return false, err end - opts = casted - end - local legacy_opts = opts.opts - if type(legacy_opts) ~= 'table' then - return false, 'opts.opts must be a table' - end - if not self.staged.legacy then self.staged.legacy = {} end - for key, value in pairs(legacy_opts) do - if not is_in(key, VALID_LEGACY_KEYS) then - return false, 'unknown legacy option key: ' .. tostring(key) - end - if value == nil then - return false, 'nil value for legacy option: ' .. key - end - self.staged.legacy[key] = value - end - return true + if getmetatable(opts) ~= cap_args.BandSetLegacyOptionsOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetLegacyOptionsOpts(opts.opts) + if not casted then return false, err end + opts = casted + end + local legacy_opts = opts.opts + if type(legacy_opts) ~= 'table' then + return false, 'opts.opts must be a table' + end + if not self.staged.legacy then self.staged.legacy = {} end + for key, value in pairs(legacy_opts) do + if not is_in(key, VALID_LEGACY_KEYS) then + return false, 'unknown legacy option key: ' .. tostring(key) + end + if value == nil then + return false, 'nil value for legacy option: ' .. key + end + self.staged.legacy[key] = value + end + return true end ---@param opts BandSetBandPriorityOpts ---@return boolean ok ---@return string? reason function BandDriver:set_band_priority(opts) - if getmetatable(opts) ~= cap_args.BandSetBandPriorityOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetBandPriorityOpts(opts.band, opts.priority) - if not casted then return false, err end - opts = casted - end - local band = type(opts.band) == 'string' and opts.band:upper() or '' - if not is_in(band, VALID_BANDS) then - return false, 'band must be "2G" or "5G"' - end - if type(opts.priority) ~= 'number' or opts.priority < 0 then - return false, 'priority must be a non-negative number' - end - if not self.staged.band_priorities then self.staged.band_priorities = {} end - self.staged.band_priorities[band] = { initial_score = opts.priority } - return true + if getmetatable(opts) ~= cap_args.BandSetBandPriorityOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetBandPriorityOpts(opts.band, opts.priority) + if not casted then return false, err end + opts = casted + end + local band = type(opts.band) == 'string' and opts.band:upper() or '' + if not is_in(band, VALID_BANDS) then + return false, 'band must be "2G" or "5G"' + end + if type(opts.priority) ~= 'number' or opts.priority < 0 then + return false, 'priority must be a non-negative number' + end + if not self.staged.band_priorities then self.staged.band_priorities = {} end + self.staged.band_priorities[band] = { initial_score = opts.priority } + return true end ---@param opts BandSetBandKickingOpts ---@return boolean ok ---@return string? reason function BandDriver:set_band_kicking(opts) - if getmetatable(opts) ~= cap_args.BandSetBandKickingOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetBandKickingOpts(opts.band, opts.options) - if not casted then return false, err end - opts = casted - end - local band = type(opts.band) == 'string' and opts.band:upper() or '' - if not is_in(band, VALID_BANDS) then - return false, 'band must be "2G" or "5G"' - end - if type(opts.options) ~= 'table' then - return false, 'options must be a table' - end - if not self.staged.band_kicking then self.staged.band_kicking = {} end - if not self.staged.band_kicking[band] then self.staged.band_kicking[band] = {} end - for key, value in pairs(opts.options) do - if not is_in(key, VALID_BAND_KICKING_OPTS) then - return false, 'unknown band kicking option: ' .. tostring(key) - end - local n = tonumber(value) - if n == nil then - return false, 'value for ' .. key .. ' must be a number' - end - self.staged.band_kicking[band][key] = n - end - return true + if getmetatable(opts) ~= cap_args.BandSetBandKickingOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetBandKickingOpts(opts.band, opts.options) + if not casted then return false, err end + opts = casted + end + local band = type(opts.band) == 'string' and opts.band:upper() or '' + if not is_in(band, VALID_BANDS) then + return false, 'band must be "2G" or "5G"' + end + if type(opts.options) ~= 'table' then + return false, 'options must be a table' + end + if not self.staged.band_kicking then self.staged.band_kicking = {} end + if not self.staged.band_kicking[band] then self.staged.band_kicking[band] = {} end + for key, value in pairs(opts.options) do + if not is_in(key, VALID_BAND_KICKING_OPTS) then + return false, 'unknown band kicking option: ' .. tostring(key) + end + local n = tonumber(value) + if n == nil then + return false, 'value for ' .. key .. ' must be a number' + end + self.staged.band_kicking[band][key] = n + end + return true end ---@param opts BandSetSupportBonusOpts ---@return boolean ok ---@return string? reason function BandDriver:set_support_bonus(opts) - if getmetatable(opts) ~= cap_args.BandSetSupportBonusOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetSupportBonusOpts(opts.band, opts.support, opts.reward) - if not casted then return false, err end - opts = casted - end - local band = type(opts.band) == 'string' and opts.band:upper() or '' - if not is_in(band, VALID_BANDS) then - return false, 'band must be "2G" or "5G"' - end - if not is_in(opts.support, VALID_SUPPORTS) then - return false, 'support must be "ht" or "vht"' - end - if type(opts.reward) ~= 'number' then - return false, 'reward must be a number' - end - if not self.staged.support_bonus then self.staged.support_bonus = {} end - if not self.staged.support_bonus[band] then self.staged.support_bonus[band] = {} end - self.staged.support_bonus[band][opts.support] = opts.reward - return true + if getmetatable(opts) ~= cap_args.BandSetSupportBonusOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetSupportBonusOpts(opts.band, opts.support, opts.reward) + if not casted then return false, err end + opts = casted + end + local band = type(opts.band) == 'string' and opts.band:upper() or '' + if not is_in(band, VALID_BANDS) then + return false, 'band must be "2G" or "5G"' + end + if not is_in(opts.support, VALID_SUPPORTS) then + return false, 'support must be "ht" or "vht"' + end + if type(opts.reward) ~= 'number' then + return false, 'reward must be a number' + end + if not self.staged.support_bonus then self.staged.support_bonus = {} end + if not self.staged.support_bonus[band] then self.staged.support_bonus[band] = {} end + self.staged.support_bonus[band][opts.support] = opts.reward + return true end ---@param opts BandSetUpdateFreqOpts ---@return boolean ok ---@return string? reason function BandDriver:set_update_freq(opts) - if getmetatable(opts) ~= cap_args.BandSetUpdateFreqOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetUpdateFreqOpts(opts.updates) - if not casted then return false, err end - opts = casted - end - if type(opts.updates) ~= 'table' then - return false, 'updates must be a table' - end - if not self.staged.update_freq then self.staged.update_freq = {} end - for key, value in pairs(opts.updates) do - if not is_in(key, VALID_UPDATE_KEYS) then - return false, 'unknown update key: ' .. tostring(key) - end - if type(value) ~= 'number' or value < 0 then - return false, 'value for ' .. key .. ' must be a non-negative number' - end - self.staged.update_freq[key] = value - end - return true + if getmetatable(opts) ~= cap_args.BandSetUpdateFreqOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetUpdateFreqOpts(opts.updates) + if not casted then return false, err end + opts = casted + end + if type(opts.updates) ~= 'table' then + return false, 'updates must be a table' + end + if not self.staged.update_freq then self.staged.update_freq = {} end + for key, value in pairs(opts.updates) do + if not is_in(key, VALID_UPDATE_KEYS) then + return false, 'unknown update key: ' .. tostring(key) + end + if type(value) ~= 'number' or value < 0 then + return false, 'value for ' .. key .. ' must be a non-negative number' + end + self.staged.update_freq[key] = value + end + return true end ---@param opts BandSetClientInactiveKickoffOpts ---@return boolean ok ---@return string? reason function BandDriver:set_client_inactive_kickoff(opts) - if getmetatable(opts) ~= cap_args.BandSetClientInactiveKickoffOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetClientInactiveKickoffOpts(opts.timeout) - if not casted then return false, err end - opts = casted - end - local timeout = tonumber(opts.timeout) - if not timeout or timeout < 0 then - return false, 'timeout must be a non-negative integer' - end - self.staged.con_timeout = timeout - return true + if getmetatable(opts) ~= cap_args.BandSetClientInactiveKickoffOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetClientInactiveKickoffOpts(opts.timeout) + if not casted then return false, err end + opts = casted + end + local timeout = tonumber(opts.timeout) + if not timeout or timeout < 0 then + return false, 'timeout must be a non-negative integer' + end + self.staged.con_timeout = timeout + return true end ---@param opts BandSetCleanupOpts ---@return boolean ok ---@return string? reason function BandDriver:set_cleanup(opts) - if getmetatable(opts) ~= cap_args.BandSetCleanupOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetCleanupOpts(opts.timeouts) - if not casted then return false, err end - opts = casted - end - if type(opts.timeouts) ~= 'table' then - return false, 'timeouts must be a table' - end - if not self.staged.cleanup then self.staged.cleanup = {} end - for key, value in pairs(opts.timeouts) do - if not is_in(key, VALID_CLEANUP_KEYS) then - return false, 'unknown cleanup key: ' .. tostring(key) - end - if type(value) ~= 'number' or value < 0 then - return false, 'value for cleanup.' .. key .. ' must be a non-negative number' - end - self.staged.cleanup[key] = value - end - return true + if getmetatable(opts) ~= cap_args.BandSetCleanupOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetCleanupOpts(opts.timeouts) + if not casted then return false, err end + opts = casted + end + if type(opts.timeouts) ~= 'table' then + return false, 'timeouts must be a table' + end + if not self.staged.cleanup then self.staged.cleanup = {} end + for key, value in pairs(opts.timeouts) do + if not is_in(key, VALID_CLEANUP_KEYS) then + return false, 'unknown cleanup key: ' .. tostring(key) + end + if type(value) ~= 'number' or value < 0 then + return false, 'value for cleanup.' .. key .. ' must be a non-negative number' + end + self.staged.cleanup[key] = value + end + return true end ---@param opts BandSetNetworkingOpts ---@return boolean ok ---@return string? reason function BandDriver:set_networking(opts) - if getmetatable(opts) ~= cap_args.BandSetNetworkingOpts then - opts = opts or {} - local casted, err = cap_args.new.BandSetNetworkingOpts(opts.method, opts.options) - if not casted then return false, err end - opts = casted - end - if not is_in(opts.method, VALID_NETWORKING_METHODS) then - return false, 'method must be one of: ' .. table.concat(VALID_NETWORKING_METHODS, ', ') - end - if type(opts.options) ~= 'table' then - return false, 'options must be a table' - end - - local net = { method = opts.method } - for key, value in pairs(opts.options) do - if not is_in(key, VALID_NETWORKING_OPTS) then - return false, 'unknown networking option: ' .. tostring(key) - end - if key == 'ip' and type(value) ~= 'string' then - return false, 'networking.ip must be a string' - end - if (key == 'port' or key == 'broadcast_port') and type(value) ~= 'number' then - return false, 'networking.' .. key .. ' must be a number' - end - if key == 'enable_encryption' and type(value) ~= 'boolean' then - return false, 'networking.enable_encryption must be a boolean' - end - net[key] = value - end - self.staged.networking = net - return true + if getmetatable(opts) ~= cap_args.BandSetNetworkingOpts then + opts = opts or {} + local casted, err = cap_args.new.BandSetNetworkingOpts(opts.method, opts.options) + if not casted then return false, err end + opts = casted + end + if not is_in(opts.method, VALID_NETWORKING_METHODS) then + return false, 'method must be one of: ' .. table.concat(VALID_NETWORKING_METHODS, ', ') + end + if type(opts.options) ~= 'table' then + return false, 'options must be a table' + end + + local net = { method = opts.method } + for key, value in pairs(opts.options) do + if not is_in(key, VALID_NETWORKING_OPTS) then + return false, 'unknown networking option: ' .. tostring(key) + end + if key == 'ip' and type(value) ~= 'string' then + return false, 'networking.ip must be a string' + end + if (key == 'port' or key == 'broadcast_port') and type(value) ~= 'number' then + return false, 'networking.' .. key .. ' must be a number' + end + if key == 'enable_encryption' and type(value) ~= 'boolean' then + return false, 'networking.enable_encryption must be a boolean' + end + net[key] = value + end + self.staged.networking = net + return true end ---@return boolean ok ---@return string? reason function BandDriver:apply() - local ok, err = pcall(function() self.backend:apply(self.staged) end) - if not ok then - return false, tostring(err) - end - return true + local ok, err = pcall(function() self.backend:apply(self.staged) end) + if not ok then + return false, tostring(err) + end + return true end ---@return boolean ok ---@return string? reason function BandDriver:clear() - local ok, err = self.backend:clear() - if not ok then - return false, err - end - self.staged = {} - return true + local ok, err = self.backend:clear() + if not ok then + return false, err + end + self.staged = {} + return true end ---@return boolean ok function BandDriver:rollback() - self.staged = {} - return true + self.staged = {} + return true end ------------------------------------------------------------------------ @@ -412,36 +412,36 @@ end ------------------------------------------------------------------------ function BandDriver:control_manager() - fibers.current_scope():finally(function() - self.log:debug({ what = 'band_driver_stopped', id = self.id }) - end) - - while true do - local name, request = fibers.perform(fibers.named_choice({ - rpc = self.control_ch:get_op(), - cancel = fibers.current_scope():cancel_op(), - })) - - if name == 'cancel' then break end - - local fn = self[request.verb] - local ok, reason - if type(fn) ~= 'function' then - ok, reason = false, 'unknown verb: ' .. tostring(request.verb) - else - local call_ok, r1, r2 = pcall(fn, self, request.opts) - if not call_ok then - ok, reason = false, tostring(r1) - else - ok, reason = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, reason) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + self.log:debug({ what = 'band_driver_stopped', id = self.id }) + end) + + while true do + local name, request = fibers.perform(fibers.named_choice({ + rpc = self.control_ch:get_op(), + cancel = fibers.current_scope():cancel_op(), + })) + + if name == 'cancel' then break end + + local fn = self[request.verb] + local ok, reason + if type(fn) ~= 'function' then + ok, reason = false, 'unknown verb: ' .. tostring(request.verb) + else + local call_ok, r1, r2 = pcall(fn, self, request.opts) + if not call_ok then + ok, reason = false, tostring(r1) + else + ok, reason = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, reason) + if reply then + request.reply_ch:put(reply) + end + end end ------------------------------------------------------------------------ @@ -450,68 +450,68 @@ end ---@return string err empty string on success function BandDriver:init() - local ok, err = self.backend:clear() - if not ok then - return "band backend clear failed: " .. tostring(err) - end - self.initialised = true - return "" + local ok, err = self.backend:clear() + if not ok then + return "band backend clear failed: " .. tostring(err) + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? caps ---@return string err function BandDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "driver not initialised" - end - if self.caps_applied then - return nil, "capabilities already applied" - end - self.cap_emit_ch = emit_ch - - local cap, cap_err = cap_types.new.Capability( - 'band', - '1', - self.control_ch, - { - 'set_log_level', - 'set_kicking', - 'set_station_counting', - 'set_rrm_mode', - 'set_neighbour_reports', - 'set_legacy_options', - 'set_band_priority', - 'set_band_kicking', - 'set_support_bonus', - 'set_update_freq', - 'set_client_inactive_kickoff', - 'set_cleanup', - 'set_networking', - 'apply', - 'clear', - 'rollback', - } - ) - if not cap then - return nil, cap_err - end - - self.caps_applied = true - return { cap }, "" + if not self.initialised then + return nil, "driver not initialised" + end + if self.caps_applied then + return nil, "capabilities already applied" + end + self.cap_emit_ch = emit_ch + + local cap, cap_err = cap_types.new.Capability( + 'band', + '1', + self.control_ch, + { + 'set_log_level', + 'set_kicking', + 'set_station_counting', + 'set_rrm_mode', + 'set_neighbour_reports', + 'set_legacy_options', + 'set_band_priority', + 'set_band_kicking', + 'set_support_bonus', + 'set_update_freq', + 'set_client_inactive_kickoff', + 'set_cleanup', + 'set_networking', + 'apply', + 'clear', + 'rollback', + } + ) + if not cap then + return nil, cap_err + end + + self.caps_applied = true + return { cap }, "" end ---@return boolean ok ---@return string err function BandDriver:start() - if not self.initialised then - return false, "driver not initialised" - end - if not self.caps_applied then - return false, "capabilities not applied" - end - self.scope:spawn(function() self:control_manager() end) - return true, "" + if not self.initialised then + return false, "driver not initialised" + end + if not self.caps_applied then + return false, "capabilities not applied" + end + self.scope:spawn(function() self:control_manager() end) + return true, "" end ---Create a new BandDriver instance. @@ -519,32 +519,32 @@ end ---@return BandDriver? driver ---@return string err local function new(logger) - local bknd, berr = provider.new() - if not bknd then - return nil, "no band backend: " .. tostring(berr) - end - - local scope, serr = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(serr) - end - - local driver = setmetatable({ - id = '1', - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - staged = {}, - initialised = false, - caps_applied = false, - log = logger, - backend = bknd, - }, BandDriver) - - return driver, "" + local bknd, berr = provider.new() + if not bknd then + return nil, "no band backend: " .. tostring(berr) + end + + local scope, serr = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(serr) + end + + local driver = setmetatable({ + id = '1', + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + staged = {}, + initialised = false, + caps_applied = false, + log = logger, + backend = bknd, + }, BandDriver) + + return driver, "" end return { - new = new, - Driver = BandDriver, + new = new, + Driver = BandDriver, } diff --git a/src/services/hal/drivers/cpu.lua b/src/services/hal/drivers/cpu.lua index d99be6e2..67d39be0 100644 --- a/src/services/hal/drivers/cpu.lua +++ b/src/services/hal/drivers/cpu.lua @@ -24,9 +24,9 @@ local CONTROL_Q_LEN = 8 local FREQ_SYSFS_FMT = '/sys/devices/system/cpu/cpu%d/cpufreq/scaling_cur_freq' local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class CpuDriver @@ -46,16 +46,16 @@ CpuDriver.__index = CpuDriver ---@return string? content ---@return string err local function read_file(path) - local f, open_err = file.open(path, 'r') - if not f then - return nil, tostring(open_err) - end - local content, read_err = f:read_all() - f:close() - if not content then - return nil, tostring(read_err) - end - return content, "" + local f, open_err = file.open(path, 'r') + if not f then + return nil, tostring(open_err) + end + local content, read_err = f:read_all() + f:close() + if not content then + return nil, tostring(read_err) + end + return content, "" end --- Parse /proc/cpuinfo for the first model name and core count. @@ -63,35 +63,35 @@ end ---@return string model ---@return number core_count local function read_cpuinfo(logger) - local content, err = read_file('/proc/cpuinfo') - if not content then - dlog(logger, 'warn', { what = 'cpuinfo_read_failed', err = tostring(err) }) - return "", 1 - end - local model = content:match("model name%s*:%s*([^\n]+)") or "" - local count = 0 - for _ in content:gmatch("processor%s*:") do - count = count + 1 - end - return model:match("^%s*(.-)%s*$") or model, math.max(count, 1) + local content, err = read_file('/proc/cpuinfo') + if not content then + dlog(logger, 'warn', { what = 'cpuinfo_read_failed', err = tostring(err) }) + return "", 1 + end + local model = content:match("model name%s*:%s*([^\n]+)") or "" + local count = 0 + for _ in content:gmatch("processor%s*:") do + count = count + 1 + end + return model:match("^%s*(.-)%s*$") or model, math.max(count, 1) end --- Parse /proc/stat cpu lines into {user, nice, system, idle, ...} tables. ---@param content string ---@return table local function parse_stat(content) - local result = {} - for line in content:gmatch("[^\n]+") do - local name, rest = line:match("^(cpu%w*)%s+(.+)$") - if name then - local fields = {} - for n in rest:gmatch("%d+") do - fields[#fields + 1] = tonumber(n) - end - result[name] = fields - end - end - return result + local result = {} + for line in content:gmatch("[^\n]+") do + local name, rest = line:match("^(cpu%w*)%s+(.+)$") + if name then + local fields = {} + for n in rest:gmatch("%d+") do + fields[#fields + 1] = tonumber(n) + end + result[name] = fields + end + end + return result end --- Compute utilisation (%) from two successive /proc/stat snapshots. @@ -99,13 +99,13 @@ end ---@param s2 table ---@return number util percentage 0-100 local function compute_util(s1, s2) - local total1, total2, idle1, idle2 = 0, 0, (s1[4] or 0), (s2[4] or 0) - for _, v in ipairs(s1) do total1 = total1 + v end - for _, v in ipairs(s2) do total2 = total2 + v end - local delta_total = total2 - total1 - local delta_idle = idle2 - idle1 - if delta_total == 0 then return 0 end - return math.max(0, math.min(100, (1 - delta_idle / delta_total) * 100)) + local total1, total2, idle1, idle2 = 0, 0, (s1[4] or 0), (s2[4] or 0) + for _, v in ipairs(s1) do total1 = total1 + v end + for _, v in ipairs(s2) do total2 = total2 + v end + local delta_total = total2 - total1 + local delta_idle = idle2 - idle1 + if delta_total == 0 then return 0 end + return math.max(0, math.min(100, (1 - delta_idle / delta_total) * 100)) end ---- capability verbs ---- @@ -116,118 +116,118 @@ local VALID_FIELDS = { utilisation = true, core_utilisations = true, frequency = ---@return boolean ok ---@return any value_or_err function CpuDriver:get(opts) - if opts == nil or getmetatable(opts) ~= cap_args.CpuGetOpts then - return false, "invalid opts" - end - local field = opts.field - local max_age = opts.max_age - - if not VALID_FIELDS[field] then - return false, "unsupported field: " .. tostring(field) - end - - local cached = self.cache:get(field, max_age) - if cached ~= nil then - return true, cached - end - - -- Utilisation pair: double-sample /proc/stat with 1-second sleep. - if field == 'utilisation' or field == 'core_utilisations' then - local s1_raw, err1 = read_file('/proc/stat') - if not s1_raw then - return false, "failed to read /proc/stat: " .. err1 - end - local s1 = parse_stat(s1_raw) - perform(sleep.sleep_op(1)) - local s2_raw, err2 = read_file('/proc/stat') - if not s2_raw then - return false, "failed to read /proc/stat (2nd sample): " .. err2 - end - local s2 = parse_stat(s2_raw) - - local overall = compute_util(s1['cpu'] or {}, s2['cpu'] or {}) - local per_core = {} - for i = 0, self.core_count - 1 do - local key = 'cpu' .. i - per_core[key] = compute_util(s1[key] or {}, s2[key] or {}) - end - - self.cache:set('utilisation', overall) - self.cache:set('core_utilisations', per_core) - if field == 'utilisation' then - return true, overall - end - return true, per_core - end - - -- Frequency pair: read scaling_cur_freq for each core. - if field == 'frequency' or field == 'core_frequencies' then - local per_core = {} - local total = 0 - local count = 0 - for i = 0, self.core_count - 1 do - local path = FREQ_SYSFS_FMT:format(i) - local raw, ferr = read_file(path) - if raw then - local khz = tonumber(raw:match("%d+")) - if khz then - local key = 'cpu' .. i - per_core[key] = khz - total = total + khz - count = count + 1 - end - else - dlog(self.logger, 'debug', { what = 'frequency_read_skipped', path = path, err = tostring(ferr) }) - end - end - local avg = (count > 0) and (total / count) or 0 - - self.cache:set('frequency', avg) - self.cache:set('core_frequencies', per_core) - if field == 'frequency' then - return true, avg - end - return true, per_core - end - - return false, "unreachable" + if opts == nil or getmetatable(opts) ~= cap_args.CpuGetOpts then + return false, "invalid opts" + end + local field = opts.field + local max_age = opts.max_age + + if not VALID_FIELDS[field] then + return false, "unsupported field: " .. tostring(field) + end + + local cached = self.cache:get(field, max_age) + if cached ~= nil then + return true, cached + end + + -- Utilisation pair: double-sample /proc/stat with 1-second sleep. + if field == 'utilisation' or field == 'core_utilisations' then + local s1_raw, err1 = read_file('/proc/stat') + if not s1_raw then + return false, "failed to read /proc/stat: " .. err1 + end + local s1 = parse_stat(s1_raw) + perform(sleep.sleep_op(1)) + local s2_raw, err2 = read_file('/proc/stat') + if not s2_raw then + return false, "failed to read /proc/stat (2nd sample): " .. err2 + end + local s2 = parse_stat(s2_raw) + + local overall = compute_util(s1['cpu'] or {}, s2['cpu'] or {}) + local per_core = {} + for i = 0, self.core_count - 1 do + local key = 'cpu' .. i + per_core[key] = compute_util(s1[key] or {}, s2[key] or {}) + end + + self.cache:set('utilisation', overall) + self.cache:set('core_utilisations', per_core) + if field == 'utilisation' then + return true, overall + end + return true, per_core + end + + -- Frequency pair: read scaling_cur_freq for each core. + if field == 'frequency' or field == 'core_frequencies' then + local per_core = {} + local total = 0 + local count = 0 + for i = 0, self.core_count - 1 do + local path = FREQ_SYSFS_FMT:format(i) + local raw, ferr = read_file(path) + if raw then + local khz = tonumber(raw:match("%d+")) + if khz then + local key = 'cpu' .. i + per_core[key] = khz + total = total + khz + count = count + 1 + end + else + dlog(self.logger, 'debug', { what = 'frequency_read_skipped', path = path, err = tostring(ferr) }) + end + end + local avg = (count > 0) and (total / count) or 0 + + self.cache:set('frequency', avg) + self.cache:set('core_frequencies', per_core) + if field == 'frequency' then + return true, avg + end + return true, per_core + end + + return false, "unreachable" end ---- control manager ---- function CpuDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - ---@cast request ControlRequest - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + ---@cast request ControlRequest + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- emit helpers ---- @@ -236,108 +236,108 @@ end ---@param key string ---@param data any function CpuDriver:_emit(mode, key, data) - if not self.cap_emit_ch then return end - local payload, err = hal_types.new.Emit('cpu', '1', mode, key, data) - if not payload then - dlog(self.logger, 'debug', { what = 'emit_failed', key = key, err = tostring(err) }) - return - end - self.cap_emit_ch:put(payload) + if not self.cap_emit_ch then return end + local payload, err = hal_types.new.Emit('cpu', '1', mode, key, data) + if not payload then + dlog(self.logger, 'debug', { what = 'emit_failed', key = key, err = tostring(err) }) + return + end + self.cap_emit_ch:put(payload) end ---- public interface ---- ---@return string error function CpuDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[] ---@return string error function CpuDriver:capabilities(emit_ch) - if not self.initialised then - return {}, "cpu driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.CpuCapability('1', self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return {}, "cpu driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.CpuCapability('1', self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function CpuDriver:start() - if not self.initialised then - return false, "cpu driver not initialised" - end - self:_emit('meta', 'info', { - provider = 'hal', - version = 1, - model = self.model, - core_count = self.core_count, - }) - - local ok, err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(err) - end - return true, "" + if not self.initialised then + return false, "cpu driver not initialised" + end + self:_emit('meta', 'info', { + provider = 'hal', + version = 1, + model = self.model, + core_count = self.core_count, + }) + + local ok, err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function CpuDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel('cpu driver stopped') - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, "cpu driver stop timeout" - end - return true, "" + timeout = timeout or 5 + self.scope:cancel('cpu driver stopped') + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, "cpu driver stop timeout" + end + return true, "" end ---@param logger Logger? ---@return CpuDriver? ---@return string error local function new(logger) - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - local model, core_count = read_cpuinfo(logger) - - return setmetatable({ - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - cache = cache_mod.new(), - model = model, - core_count = core_count, - logger = logger, - initialised = false, - }, CpuDriver), "" + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + local model, core_count = read_cpuinfo(logger) + + return setmetatable({ + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + cache = cache_mod.new(), + model = model, + core_count = core_count, + logger = logger, + initialised = false, + }, CpuDriver), "" end return { new = new } diff --git a/src/services/hal/drivers/filesystem.lua b/src/services/hal/drivers/filesystem.lua index 05ddda7b..cc74c812 100644 --- a/src/services/hal/drivers/filesystem.lua +++ b/src/services/hal/drivers/filesystem.lua @@ -27,9 +27,9 @@ local DEFAULT_STOP_TIMEOUT = 5 local CONTROL_Q_LEN = 8 local function dlog(self, level, payload) - if self.logger and self.logger[level] then - self.logger[level](self.logger, payload) - end + if self.logger and self.logger[level] then + self.logger[level](self.logger, payload) + end end ---- Utility Functions ---- @@ -41,10 +41,10 @@ end ---@return string reason ---@return integer? code local function return_error(err, code) - if err == nil then - err = "unknown error" - end - return false, err, code + if err == nil then + err = "unknown error" + end + return false, err, code end --- Emit from the filesystem capability @@ -56,18 +56,18 @@ end ---@return boolean ok ---@return string? error local function emit(emit_ch, root_name, mode, key, data) - local payload, err = hal_types.new.Emit( - 'fs', - root_name, - mode, - key, - data - ) - if not payload then - return false, err - end - emit_ch:put(payload) - return true + local payload, err = hal_types.new.Emit( + 'fs', + root_name, + mode, + key, + data + ) + if not payload then + return false, err + end + emit_ch:put(payload) + return true end ---- Filesystem Capabilities ---- @@ -79,58 +79,58 @@ end ---@return string reason_or_content ---@return integer? code function FSDriver:read(root_name, opts) - if opts == nil or getmetatable(opts) ~= cap_args.FilesystemReadOpts then - return return_error("invalid options", 1) - end - - local filename = opts.filename - if filename == nil then - return return_error("missing filename", 1) - end - - local root_path = self.roots[root_name] - if not root_path then - return return_error("unknown root: " .. tostring(root_name), 1) - end - - local full_path = root_path .. "/" .. filename - - -- Open file using fibers stream - local f, open_err = file.open(full_path, "r") - if not f then - return return_error("failed to open file " .. full_path .. ": " .. tostring(open_err), 1) - end - - local content, read_err = f:read_all() - local _, close_err = f:close() - - if not content then - return return_error("failed to read file " .. full_path .. ": " .. tostring(read_err), 1) - end - - if close_err then - dlog(self, 'warn', { - what = 'read_close_warning', - root = root_name, - filename = filename, - err = tostring(close_err), - }) - end - - -- Emit success event - local ok, emit_err = emit(self.cap_emit_ch, root_name, 'event', 'read_success', { - filename = filename - }) - if not ok then - dlog(self, 'warn', { - what = 'read_success_emit_failed', - root = root_name, - filename = filename, - err = tostring(emit_err), - }) - end - - return true, content + if opts == nil or getmetatable(opts) ~= cap_args.FilesystemReadOpts then + return return_error("invalid options", 1) + end + + local filename = opts.filename + if filename == nil then + return return_error("missing filename", 1) + end + + local root_path = self.roots[root_name] + if not root_path then + return return_error("unknown root: " .. tostring(root_name), 1) + end + + local full_path = root_path .. "/" .. filename + + -- Open file using fibers stream + local f, open_err = file.open(full_path, "r") + if not f then + return return_error("failed to open file " .. full_path .. ": " .. tostring(open_err), 1) + end + + local content, read_err = f:read_all() + local _, close_err = f:close() + + if not content then + return return_error("failed to read file " .. full_path .. ": " .. tostring(read_err), 1) + end + + if close_err then + dlog(self, 'warn', { + what = 'read_close_warning', + root = root_name, + filename = filename, + err = tostring(close_err), + }) + end + + -- Emit success event + local ok, emit_err = emit(self.cap_emit_ch, root_name, 'event', 'read_success', { + filename = filename + }) + if not ok then + dlog(self, 'warn', { + what = 'read_success_emit_failed', + root = root_name, + filename = filename, + err = tostring(emit_err), + }) + end + + return true, content end --- Write a file to a root @@ -140,58 +140,58 @@ end ---@return string? reason ---@return integer? code function FSDriver:write(root_name, opts) - if opts == nil or getmetatable(opts) ~= cap_args.FilesystemWriteOpts then - return return_error("invalid options", 1) - end - - local filename = opts.filename - if filename == nil then - return return_error("missing filename", 1) - end - - local root_path = self.roots[root_name] - if not root_path then - return return_error("unknown root: " .. tostring(root_name), 1) - end - - local full_path = root_path .. "/" .. filename - - -- Open file using fibers stream - local f, open_err = file.open(full_path, "w") - if not f then - return return_error("failed to open file for writing: " .. tostring(open_err), 1) - end - - local ok, write_err = f:write(opts.data) - local _, close_err = f:close() - - if not ok then - return return_error("failed to write file: " .. tostring(write_err), 1) - end - - if close_err then - dlog(self, 'warn', { - what = 'write_close_warning', - root = root_name, - filename = filename, - err = tostring(close_err), - }) - end - - -- Emit success event - local ok_emit, emit_err = emit(self.cap_emit_ch, root_name, 'event', 'write_success', { - filename = filename - }) - if not ok_emit then - dlog(self, 'warn', { - what = 'write_success_emit_failed', - root = root_name, - filename = filename, - err = tostring(emit_err), - }) - end - - return true + if opts == nil or getmetatable(opts) ~= cap_args.FilesystemWriteOpts then + return return_error("invalid options", 1) + end + + local filename = opts.filename + if filename == nil then + return return_error("missing filename", 1) + end + + local root_path = self.roots[root_name] + if not root_path then + return return_error("unknown root: " .. tostring(root_name), 1) + end + + local full_path = root_path .. "/" .. filename + + -- Open file using fibers stream + local f, open_err = file.open(full_path, "w") + if not f then + return return_error("failed to open file for writing: " .. tostring(open_err), 1) + end + + local ok, write_err = f:write(opts.data) + local _, close_err = f:close() + + if not ok then + return return_error("failed to write file: " .. tostring(write_err), 1) + end + + if close_err then + dlog(self, 'warn', { + what = 'write_close_warning', + root = root_name, + filename = filename, + err = tostring(close_err), + }) + end + + -- Emit success event + local ok_emit, emit_err = emit(self.cap_emit_ch, root_name, 'event', 'write_success', { + filename = filename + }) + if not ok_emit then + dlog(self, 'warn', { + what = 'write_success_emit_failed', + root = root_name, + filename = filename, + err = tostring(emit_err), + }) + end + + return true end --- Validate that a function is implemented @@ -199,73 +199,73 @@ end ---@return boolean is_valid ---@return string? error local function validate_fn(fn) - if fn == nil then - return false, tostring(fn) .. " is unimplemented" - end - if type(fn) ~= "function" then - return false, tostring(fn) .. " is not a function" - end - return true + if fn == nil then + return false, tostring(fn) .. " is unimplemented" + end + if type(fn) ~= "function" then + return false, tostring(fn) .. " is not a function" + end + return true end ---- Long Running Fibers ---- function FSDriver:control_manager() - if self.cap_emit_ch == nil then - dlog(self, 'error', { what = 'control_manager_missing_cap_emit_channel' }) - return - end - - dlog(self, 'debug', { what = 'control_manager_started' }) - - fibers.current_scope():finally(function() - dlog(self, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - -- Build named choice over all root control channels - local choice_arms = {} - for root_name, control_ch in pairs(self.control_chs) do - choice_arms[root_name] = control_ch:get_op() - end - - -- Wait for a request from any root's control channel - local root_name, request, req_err = fibers.perform(op.named_choice(choice_arms)) - - if not request then - dlog(self, 'error', { what = 'control_channel_get_failed', err = tostring(req_err) }) - break - end - - ---@cast request ControlRequest - - local ok, reason, code - - local fn = self[request.verb] - local valid, validation_err = validate_fn(fn) - if not valid then - ok = false - reason = "no function exists for verb: " .. tostring(validation_err) - else - local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, root_name, request.opts) - if not call_ok then - ok = false - reason = "internal error: " .. tostring(fn_ok) - code = 1 - else - ok = fn_ok - reason = fn_reason - code = fn_code - end - end - - local reply, reply_err = hal_types.new.Reply(ok, reason, code) - if not reply then - dlog(self, 'error', { what = 'reply_create_failed', err = tostring(reply_err) }) - else - request.reply_ch:put(reply) - end - end + if self.cap_emit_ch == nil then + dlog(self, 'error', { what = 'control_manager_missing_cap_emit_channel' }) + return + end + + dlog(self, 'debug', { what = 'control_manager_started' }) + + fibers.current_scope():finally(function() + dlog(self, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + -- Build named choice over all root control channels + local choice_arms = {} + for root_name, control_ch in pairs(self.control_chs) do + choice_arms[root_name] = control_ch:get_op() + end + + -- Wait for a request from any root's control channel + local root_name, request, req_err = fibers.perform(op.named_choice(choice_arms)) + + if not request then + dlog(self, 'error', { what = 'control_channel_get_failed', err = tostring(req_err) }) + break + end + + ---@cast request ControlRequest + + local ok, reason, code + + local fn = self[request.verb] + local valid, validation_err = validate_fn(fn) + if not valid then + ok = false + reason = "no function exists for verb: " .. tostring(validation_err) + else + local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, root_name, request.opts) + if not call_ok then + ok = false + reason = "internal error: " .. tostring(fn_ok) + code = 1 + else + ok = fn_ok + reason = fn_reason + code = fn_code + end + end + + local reply, reply_err = hal_types.new.Reply(ok, reason, code) + if not reply then + dlog(self, 'error', { what = 'reply_create_failed', err = tostring(reply_err) }) + else + request.reply_ch:put(reply) + end + end end ---- Driver Functions ---- @@ -274,16 +274,16 @@ end ---@return boolean ok ---@return string error function FSDriver:start() - if not self.initialised then - return false, "filesystem not initialised" - end - if not self.caps_applied then - return false, "capabilities not applied" - end + if not self.initialised then + return false, "filesystem not initialised" + end + if not self.caps_applied then + return false, "capabilities not applied" + end - self.scope:spawn(function() self:control_manager() end) + self.scope:spawn(function() self:control_manager() end) - return true, "" + return true, "" end --- Closes down the filesystem driver @@ -291,18 +291,18 @@ end ---@return boolean ok ---@return string error function FSDriver:stop(timeout) - timeout = timeout or DEFAULT_STOP_TIMEOUT - self.scope:cancel() - - local source = fibers.perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout) - }) - - if source == "timeout" then - return false, "filesystem stop timeout" - end - return true, "" + timeout = timeout or DEFAULT_STOP_TIMEOUT + self.scope:cancel() + + local source = fibers.perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout) + }) + + if source == "timeout" then + return false, "filesystem stop timeout" + end + return true, "" end --- Apply capabilities to HAL and start monitoring state @@ -311,67 +311,67 @@ end ---@return Capability[]? capabilities ---@return string error function FSDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "filesystem not initialised" - end - if self.caps_applied then - return nil, "capabilities already applied" - end + if not self.initialised then + return nil, "filesystem not initialised" + end + if self.caps_applied then + return nil, "capabilities already applied" + end - self.cap_emit_ch = emit_ch + self.cap_emit_ch = emit_ch - local caps = {} + local caps = {} - for cap_name, _ in pairs(self.roots) do - local control_ch = self.control_chs[cap_name] - local cap, cap_err = cap_types.new.FilesystemCapability(cap_name, control_ch) - if not cap then - return nil, "failed to create capability for root " .. cap_name .. ": " .. tostring(cap_err) - end + for cap_name, _ in pairs(self.roots) do + local control_ch = self.control_chs[cap_name] + local cap, cap_err = cap_types.new.FilesystemCapability(cap_name, control_ch) + if not cap then + return nil, "failed to create capability for root " .. cap_name .. ": " .. tostring(cap_err) + end - table.insert(caps, cap) - end + table.insert(caps, cap) + end - self.caps_applied = true + self.caps_applied = true - return caps, "" + return caps, "" end --- Initialize the filesystem driver --- Creates missing root directories and validates configuration ---@return string error function FSDriver:init() - if self.initialised then - return "already initialised" - end - - -- Validate roots - if type(self.roots) ~= 'table' or next(self.roots) == nil then - return "roots must be a non-empty table" - end - - -- Create control channels for each root and create missing directories - for root_name, root_path in pairs(self.roots) do - if type(root_path) ~= 'string' or root_path == '' then - return "root path for " .. tostring(root_name) .. " must be a non-empty string" - end - - -- Create control channel for this root - self.control_chs[root_name] = channel.new(CONTROL_Q_LEN) - - -- Create missing root directory - local ok, mkdir_err = file.mkdir_p(root_path) - if not ok then - local err_msg = "failed to create root directory " .. tostring(root_name) .. - " at " .. tostring(root_path) .. ": " .. tostring(mkdir_err) - return err_msg - end - - dlog(self, 'debug', { what = 'root_ready', root = root_name, path = root_path }) - end - - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + + -- Validate roots + if type(self.roots) ~= 'table' or next(self.roots) == nil then + return "roots must be a non-empty table" + end + + -- Create control channels for each root and create missing directories + for root_name, root_path in pairs(self.roots) do + if type(root_path) ~= 'string' or root_path == '' then + return "root path for " .. tostring(root_name) .. " must be a non-empty string" + end + + -- Create control channel for this root + self.control_chs[root_name] = channel.new(CONTROL_Q_LEN) + + -- Create missing root directory + local ok, mkdir_err = file.mkdir_p(root_path) + if not ok then + local err_msg = "failed to create root directory " .. tostring(root_name) .. + " at " .. tostring(root_path) .. ": " .. tostring(mkdir_err) + return err_msg + end + + dlog(self, 'debug', { what = 'root_ready', root = root_name, path = root_path }) + end + + self.initialised = true + return "" end --- Create a new filesystem driver @@ -380,20 +380,20 @@ end ---@return FSDriver? ---@return string error local function new(roots, logger) - local self = setmetatable({}, FSDriver) - local scope, sc_err = fibers.current_scope():child() - if not scope then return nil, sc_err end - - self.scope = scope - self.roots = roots or {} - self.control_chs = {} - self.logger = logger - self.initialised = false - self.caps_applied = false - - return self, "" + local self = setmetatable({}, FSDriver) + local scope, sc_err = fibers.current_scope():child() + if not scope then return nil, sc_err end + + self.scope = scope + self.roots = roots or {} + self.control_chs = {} + self.logger = logger + self.initialised = false + self.caps_applied = false + + return self, "" end return { - new = new, + new = new, } diff --git a/src/services/hal/drivers/memory.lua b/src/services/hal/drivers/memory.lua index 59a8f049..38b0ff73 100644 --- a/src/services/hal/drivers/memory.lua +++ b/src/services/hal/drivers/memory.lua @@ -21,9 +21,9 @@ local perform = fibers.perform local CONTROL_Q_LEN = 8 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class MemoryDriver @@ -41,42 +41,42 @@ MemoryDriver.__index = MemoryDriver ---@return string? content ---@return string error local function read_file(path) - local f, open_err = file.open(path, 'r') - if not f then - return nil, tostring(open_err) - end - local content, read_err = f:read_all() - f:close() - if not content then - return nil, tostring(read_err) - end - return content, "" + local f, open_err = file.open(path, 'r') + if not f then + return nil, tostring(open_err) + end + local content, read_err = f:read_all() + f:close() + if not content then + return nil, tostring(read_err) + end + return content, "" end --- Parse /proc/meminfo and compute total, free, used, util. ---@return table? result ---@return string error local function read_meminfo() - local content, err = read_file('/proc/meminfo') - if not content then - return nil, "failed to read /proc/meminfo: " .. err - end - - local function kv(key) - local v = content:match(key .. "%s*:%s*(%d+)") - return v and tonumber(v) or 0 - end - - local mem_total = kv("MemTotal") - local mem_free = kv("MemFree") - local buffers = kv("Buffers") - local cached = kv("Cached") - - local effective_free = mem_free + buffers + cached - local used = mem_total - effective_free - local util = (mem_total > 0) and (used / mem_total * 100) or 0 - - return { total = mem_total, used = used, free = effective_free, util = util }, "" + local content, err = read_file('/proc/meminfo') + if not content then + return nil, "failed to read /proc/meminfo: " .. err + end + + local function kv(key) + local v = content:match(key .. "%s*:%s*(%d+)") + return v and tonumber(v) or 0 + end + + local mem_total = kv("MemTotal") + local mem_free = kv("MemFree") + local buffers = kv("Buffers") + local cached = kv("Cached") + + local effective_free = mem_free + buffers + cached + local used = mem_total - effective_free + local util = (mem_total > 0) and (used / mem_total * 100) or 0 + + return { total = mem_total, used = used, free = effective_free, util = util }, "" end ---- capability verbs ---- @@ -87,163 +87,163 @@ local VALID_FIELDS = { total = true, used = true, free = true, util = true } ---@return boolean ok ---@return any value_or_err function MemoryDriver:get(opts) - if opts == nil or getmetatable(opts) ~= cap_args.MemoryGetOpts then - return false, "invalid opts" - end - local field = opts.field - local max_age = opts.max_age - - if not VALID_FIELDS[field] then - return false, "unsupported field: " .. tostring(field) - end - - local cached = self.cache:get(field, max_age) - if cached ~= nil then - return true, cached - end - - local info, err = read_meminfo() - if not info then - return false, err - end - - -- Populate all four cache entries at once. - self.cache:set('total', info.total) - self.cache:set('used', info.used) - self.cache:set('free', info.free) - self.cache:set('util', info.util) - - return true, info[field] + if opts == nil or getmetatable(opts) ~= cap_args.MemoryGetOpts then + return false, "invalid opts" + end + local field = opts.field + local max_age = opts.max_age + + if not VALID_FIELDS[field] then + return false, "unsupported field: " .. tostring(field) + end + + local cached = self.cache:get(field, max_age) + if cached ~= nil then + return true, cached + end + + local info, err = read_meminfo() + if not info then + return false, err + end + + -- Populate all four cache entries at once. + self.cache:set('total', info.total) + self.cache:set('used', info.used) + self.cache:set('free', info.free) + self.cache:set('util', info.util) + + return true, info[field] end ---- control manager ---- function MemoryDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- public interface ---- ---@return string error function MemoryDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? ---@return string error function MemoryDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "memory driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.MemoryCapability('1', self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return nil, "memory driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.MemoryCapability('1', self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function MemoryDriver:start() - if not self.initialised then - return false, "memory driver not initialised" - end - local payload, err = hal_types.new.Emit('memory', '1', 'meta', 'info', { - provider = 'hal', - version = 1, - }) - if payload and self.cap_emit_ch then - self.cap_emit_ch:put(payload) - elseif not payload then - dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(err) }) - end - - local ok, spawn_err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(spawn_err) - end - return true, "" + if not self.initialised then + return false, "memory driver not initialised" + end + local payload, err = hal_types.new.Emit('memory', '1', 'meta', 'info', { + provider = 'hal', + version = 1, + }) + if payload and self.cap_emit_ch then + self.cap_emit_ch:put(payload) + elseif not payload then + dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(err) }) + end + + local ok, spawn_err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(spawn_err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function MemoryDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel('memory driver stopped') - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, "memory driver stop timeout" - end - return true, "" + timeout = timeout or 5 + self.scope:cancel('memory driver stopped') + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, "memory driver stop timeout" + end + return true, "" end ---@param logger Logger? ---@return MemoryDriver? ---@return string error local function new(logger) - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - return setmetatable({ - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - cache = cache_mod.new(), - logger = logger, - initialised = false, - }, MemoryDriver), "" + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + return setmetatable({ + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + cache = cache_mod.new(), + logger = logger, + initialised = false, + }, MemoryDriver), "" end return { new = new } diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index 5c2db0ef..20a85c41 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -37,11 +37,11 @@ local Modem = {} Modem.__index = Modem local function list_to_map(list) - local map = {} - for _, value in ipairs(list) do - map[value] = true - end - return map + local map = {} + for _, value in ipairs(list) do + map[value] = true + end + return map end ---- Constant Definitions ---- @@ -53,69 +53,69 @@ local LISTEN_TRIGGER_INTERVAL = 1 local CONTROL_Q_LEN = 8 local GROUP_FIELDS = { - identity = { - "imei", - "drivers", - "plugin", - "model", - "revision", - "firmware", - }, - ports = { - "device", - "primary_port", - "at_ports", - "qmi_ports", - "net_ports", - }, - sim = { - "sim", - "iccid", - "imsi", - "gid1", - "sim_lock", - "sim_lock_retries", - "modem_state", - }, - network = { - "access_techs", - "operator", - "mcc", - "mnc", - "active_band_class", - }, - signal = { - "signal", - }, - traffic = { - "rx_bytes", - "tx_bytes", - }, + identity = { + "imei", + "drivers", + "plugin", + "model", + "revision", + "firmware", + }, + ports = { + "device", + "primary_port", + "at_ports", + "qmi_ports", + "net_ports", + }, + sim = { + "sim", + "iccid", + "imsi", + "gid1", + "sim_lock", + "sim_lock_retries", + "modem_state", + }, + network = { + "access_techs", + "operator", + "mcc", + "mnc", + "active_band_class", + }, + signal = { + "signal", + }, + traffic = { + "rx_bytes", + "tx_bytes", + }, } local FIELD_TO_GROUP = {} for group_name, fields in pairs(GROUP_FIELDS) do - for _, field in ipairs(fields) do - FIELD_TO_GROUP[field] = group_name - end + for _, field in ipairs(fields) do + FIELD_TO_GROUP[field] = group_name + end end local GROUP_FETCHERS = { - identity = "read_identity", - ports = "read_ports", - sim = "read_sim_info", - network = "read_network_info", - signal = "read_signal", - traffic = "read_traffic", + identity = "read_identity", + ports = "read_ports", + sim = "read_sim_info", + network = "read_network_info", + signal = "read_signal", + traffic = "read_traffic", } local VALID_GROUPS = list_to_map { - "identity", - "ports", - "sim", - "network", - "signal", - "traffic", + "identity", + "ports", + "sim", + "network", + "signal", + "traffic", } @@ -130,18 +130,18 @@ local VALID_GROUPS = list_to_map { ---@return boolean ok ---@return string? error local function emit(emit_ch, imei, mode, key, data) - local payload, err = hal_types.new.Emit( - 'modem', - imei, - mode, - key, - data - ) - if not payload then - return false, err - end - emit_ch:put(payload) - return true + local payload, err = hal_types.new.Emit( + 'modem', + imei, + mode, + key, + data + ) + if not payload then + return false, err + end + emit_ch:put(payload) + return true end --- Utility function to return a ControlError @@ -151,10 +151,10 @@ end ---@return string reason ---@return integer? code local function return_error(err, code) - if err == nil then - err = "unknown error" - end - return false, err, code + if err == nil then + err = "unknown error" + end + return false, err, code end --- Emit an event. @@ -163,7 +163,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_event(key, data) - return emit(self.cap_emit_ch, self.imei, 'event', key, data) + return emit(self.cap_emit_ch, self.imei, 'event', key, data) end --- Emit a state. @@ -172,7 +172,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_state(key, data) - return emit(self.cap_emit_ch, self.imei, 'state', key, data) + return emit(self.cap_emit_ch, self.imei, 'state', key, data) end --- Emit meta information. @@ -181,7 +181,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_meta(key, data) - return emit(self.cap_emit_ch, self.imei, 'meta', key, data) + return emit(self.cap_emit_ch, self.imei, 'meta', key, data) end --- Validate that a function is implemented @@ -190,67 +190,67 @@ end ---@return boolean is_valid ---@return string? error local function validate_fn(fn, verb) - if fn == nil then - return false, tostring(verb) .. " is unimplemented" - end - if type(fn) ~= "function" then - return false, tostring(verb) .. " is not a function" - end - return true + if fn == nil then + return false, tostring(verb) .. " is unimplemented" + end + if type(fn) ~= "function" then + return false, tostring(verb) .. " is not a function" + end + return true end --- Trim the traceback from an error message ---@param err string ---@return string trimmed_error local function trim_error(err) - local traceback_start = err:find("\nstack traceback:") - if traceback_start then - return err:sub(1, traceback_start - 1) - end - return err + local traceback_start = err:find("\nstack traceback:") + if traceback_start then + return err:sub(1, traceback_start - 1) + end + return err end ---@param snapshot any ---@param field string ---@return any local function extract_group_field(snapshot, field) - if field == "signal" then - return snapshot.values - end - return snapshot[field] + if field == "signal" then + return snapshot.values + end + return snapshot[field] end ---@param group string ---@return boolean local function group_valid(group) - return VALID_GROUPS[group] == true + return VALID_GROUPS[group] == true end ---@param group string ---@param value any function Modem:_cache_group(group, value) - self.cache:set(group, value) + self.cache:set(group, value) end ---@param group string function Modem:_invalidate_group(group) - if not group_valid(group) then return end - if self.cache and type(self.cache.delete) == 'function' then - self.cache:delete(group) - elseif self.cache and self.cache.store then - self.cache.store[group] = nil - end + if not group_valid(group) then return end + if self.cache and type(self.cache.delete) == 'function' then + self.cache:delete(group) + elseif self.cache and self.cache.store then + self.cache.store[group] = nil + end end ---@param groups string[] function Modem:invalidate_groups(groups) - for _, group in ipairs(groups or {}) do - self:_invalidate_group(group) - end + for _, group in ipairs(groups or {}) do + self:_invalidate_group(group) + end end function Modem:_invalidate_dynamic() - self:invalidate_groups { 'sim', 'network', 'signal', 'traffic' } + self:invalidate_groups { 'sim', 'network', 'signal', 'traffic' } end ---@param group string @@ -258,35 +258,35 @@ end ---@return any snapshot ---@return string error function Modem:_get_group(group, timescale) - if not group_valid(group) then - return nil, "unsupported group: " .. tostring(group) - end + if not group_valid(group) then + return nil, "unsupported group: " .. tostring(group) + end - local cached = self.cache:get(group, timescale) - if cached ~= nil then - return cached, "" - end + local cached = self.cache:get(group, timescale) + if cached ~= nil then + return cached, "" + end - local fetcher_name = GROUP_FETCHERS[group] - local fetcher = self.backend and self.backend[fetcher_name] or nil - if type(fetcher) ~= "function" then - return nil, "group " .. tostring(group) .. " is not implemented by backend" - end + local fetcher_name = GROUP_FETCHERS[group] + local fetcher = self.backend and self.backend[fetcher_name] or nil + if type(fetcher) ~= "function" then + return nil, "group " .. tostring(group) .. " is not implemented by backend" + end - local snapshot, err = fetcher(self.backend) - if err ~= "" then - return nil, err - end + local snapshot, err = fetcher(self.backend) + if err ~= "" then + return nil, err + end - self:_cache_group(group, snapshot) - return snapshot, "" + self:_cache_group(group, snapshot) + return snapshot, "" end --- Checks if an error string indicates a closed command ---@param err string ---@return boolean is_closed local function is_command_closed(err) - return err == "Command closed" or err == "Stream closed" + return err == "Command closed" or err == "Stream closed" end ---- Modem Capabilities ---- @@ -297,29 +297,29 @@ end ---@return any reason_or_value ---@return integer? code function Modem:get(opts) - if opts == nil or getmetatable(opts) ~= capability_args.ModemGetOpts then - return return_error("invalid options", 1) - end - local field = opts.field - local timescale = opts.timescale + if opts == nil or getmetatable(opts) ~= capability_args.ModemGetOpts then + return return_error("invalid options", 1) + end + local field = opts.field + local timescale = opts.timescale - -- Check that the field is supported - local group = FIELD_TO_GROUP[field] - if not group then - return return_error("unsupported field: " .. tostring(field), 1) - end + -- Check that the field is supported + local group = FIELD_TO_GROUP[field] + if not group then + return return_error("unsupported field: " .. tostring(field), 1) + end - local snapshot, err = self:_get_group(group, timescale) - if err ~= "" then - return return_error("error getting field " .. tostring(field) .. ": " .. tostring(err), 1) - end + local snapshot, err = self:_get_group(group, timescale) + if err ~= "" then + return return_error("error getting field " .. tostring(field) .. ": " .. tostring(err), 1) + end - local value = extract_group_field(snapshot, field) - if value == nil then - return return_error("field unavailable: " .. tostring(field), 1) - end + local value = extract_group_field(snapshot, field) + if value == nil then + return return_error("field unavailable: " .. tostring(field), 1) + end - return true, value + return true, value end --- Enable the modem @@ -327,12 +327,12 @@ end ---@return string? reason ---@return integer? code function Modem:enable() - local ok, err = self.backend:enable() - if not ok then - return return_error(err, 1) - end - self:_invalidate_dynamic() - return true + local ok, err = self.backend:enable() + if not ok then + return return_error(err, 1) + end + self:_invalidate_dynamic() + return true end --- Disable the modem @@ -340,12 +340,12 @@ end ---@return string? reason ---@return integer? code function Modem:disable() - local ok, err = self.backend:disable() - if not ok then - return return_error(err, 1) - end - self:_invalidate_dynamic() - return true + local ok, err = self.backend:disable() + if not ok then + return return_error(err, 1) + end + self:_invalidate_dynamic() + return true end --- Reset the modem @@ -353,12 +353,12 @@ end ---@return string? reason ---@return integer? code function Modem:reset() - local ok, err = self.backend:reset() - if not ok then - return return_error(err, 1) - end - self:_invalidate_dynamic() - return true + local ok, err = self.backend:reset() + if not ok then + return return_error(err, 1) + end + self:_invalidate_dynamic() + return true end --- Connect the modem @@ -367,15 +367,15 @@ end ---@return string? reason ---@return integer? code function Modem:connect(opts) - if opts == nil or getmetatable(opts) ~= capability_args.ModemConnectOpts then - return return_error("invalid options", 1) - end - local ok, err = self.backend:connect(opts.connection_string) - if not ok then - return return_error(err, 1) - end - self:invalidate_groups { 'network', 'signal', 'traffic' } - return true + if opts == nil or getmetatable(opts) ~= capability_args.ModemConnectOpts then + return return_error("invalid options", 1) + end + local ok, err = self.backend:connect(opts.connection_string) + if not ok then + return return_error(err, 1) + end + self:invalidate_groups { 'network', 'signal', 'traffic' } + return true end --- Disconnect the modem @@ -383,12 +383,12 @@ end ---@return string? reason ---@return integer? code function Modem:disconnect() - local ok, err = self.backend:disconnect() - if not ok then - return return_error(err, 1) - end - self:invalidate_groups { 'network', 'signal', 'traffic' } - return true + local ok, err = self.backend:disconnect() + if not ok then + return return_error(err, 1) + end + self:invalidate_groups { 'network', 'signal', 'traffic' } + return true end --- Inhibit the modem @@ -396,31 +396,31 @@ end ---@return string? reason ---@return integer? code function Modem:inhibit() - local done_ch = channel.new() + local done_ch = channel.new() - local ok, err = self.scope:spawn(function() - local result_ok, result_err = self.backend:inhibit() - done_ch:put({ ok = result_ok, err = result_err }) - end) + local ok, err = self.scope:spawn(function() + local result_ok, result_err = self.backend:inhibit() + done_ch:put({ ok = result_ok, err = result_err }) + end) - if not ok then - return return_error("failed to spawn inhibit fiber: " .. tostring(err), 1) - end + if not ok then + return return_error("failed to spawn inhibit fiber: " .. tostring(err), 1) + end - local source, msg, primary = fibers.perform(op.named_choice { - done = done_ch:get_op(), - failed = self.scope:fault_op(), - }) + local source, msg, primary = fibers.perform(op.named_choice { + done = done_ch:get_op(), + failed = self.scope:fault_op(), + }) - if source == "done" then - if not msg.ok then - return return_error(msg.err, 1) - end - return true - elseif source == "failed" then - return return_error("modem inhibit failed: " .. tostring(primary), 1) - end - return return_error("unexpected error during modem inhibit", 1) + if source == "done" then + if not msg.ok then + return return_error(msg.err, 1) + end + return true + elseif source == "failed" then + return return_error("modem inhibit failed: " .. tostring(primary), 1) + end + return return_error("unexpected error during modem inhibit", 1) end --- Uninhibit the modem @@ -428,11 +428,11 @@ end ---@return string? reason ---@return integer? code function Modem:uninhibit() - local ok, err = self.backend:uninhibit() - if not ok then - return return_error(err, 1) - end - return true + local ok, err = self.backend:uninhibit() + if not ok then + return return_error(err, 1) + end + return true end --- Start listening for a sim insertion @@ -440,45 +440,45 @@ end ---@return string? reason ---@return integer? code function Modem:listen_for_sim() - if self.listening_for_sim then - return true - end - self.listening_for_sim = true - local ok, err = fibers.current_scope():spawn(function() - self:_emit_state("sim_listener", "open") - - fibers.current_scope():finally(function() - self.listening_for_sim = false - self:_emit_state("sim_listener", "closed") - end) - - -- Capture pulse version before reading state to close the insertion race window. - local last_seen = self.sim_inserted_pulse:version() - local sim_present = self.sim_state_ch:get() - - while sim_present ~= true do - local source, _, primary = fibers.perform(op.named_choice { - inserted = self.sim_inserted_pulse:changed_op(last_seen), - trigger = sleep.sleep_op(LISTEN_TRIGGER_INTERVAL), - failed = self.scope:fault_op(), - }) - if source == "inserted" then - break - elseif source == "trigger" then - local trigger_ok, check_err = self.backend:trigger_sim_presence_check() - if not trigger_ok then - self.log:error({ what = 'trigger_sim_check_failed', imei = self.imei, err = tostring(check_err) }) - end - elseif source == "failed" then - self.log:error({ what = 'listen_for_sim_scope_faulted', imei = self.imei, err = tostring(primary) }) - break - end - end - end) - if not ok then - return return_error("listen_for_sim spawn failed: " .. tostring(err), 1) - end - return true + if self.listening_for_sim then + return true + end + self.listening_for_sim = true + local ok, err = fibers.current_scope():spawn(function() + self:_emit_state("sim_listener", "open") + + fibers.current_scope():finally(function() + self.listening_for_sim = false + self:_emit_state("sim_listener", "closed") + end) + + -- Capture pulse version before reading state to close the insertion race window. + local last_seen = self.sim_inserted_pulse:version() + local sim_present = self.sim_state_ch:get() + + while sim_present ~= true do + local source, _, primary = fibers.perform(op.named_choice { + inserted = self.sim_inserted_pulse:changed_op(last_seen), + trigger = sleep.sleep_op(LISTEN_TRIGGER_INTERVAL), + failed = self.scope:fault_op(), + }) + if source == "inserted" then + break + elseif source == "trigger" then + local trigger_ok, check_err = self.backend:trigger_sim_presence_check() + if not trigger_ok then + self.log:error({ what = 'trigger_sim_check_failed', imei = self.imei, err = tostring(check_err) }) + end + elseif source == "failed" then + self.log:error({ what = 'listen_for_sim_scope_faulted', imei = self.imei, err = tostring(primary) }) + break + end + end + end) + if not ok then + return return_error("listen_for_sim spawn failed: " .. tostring(err), 1) + end + return true end --- Set the signal update period @@ -487,213 +487,213 @@ end ---@return string? reason ---@return integer? code function Modem:set_signal_update_freq(opts) - if opts == nil or getmetatable(opts) ~= capability_args.ModemSignalUpdateOpts then - return return_error("invalid options", 1) - end - local ok, err = self.backend:set_signal_update_interval(opts.frequency) - if not ok then - return return_error(err, 1) - end - return true + if opts == nil or getmetatable(opts) ~= capability_args.ModemSignalUpdateOpts then + return return_error("invalid options", 1) + end + local ok, err = self.backend:set_signal_update_interval(opts.frequency) + if not ok then + return return_error(err, 1) + end + return true end function Modem:emitter() - local timeout_buffer = 0.1 - self.log:debug({ what = 'emitter_started', imei = self.imei }) - - fibers.current_scope():finally(function() - self.log:debug({ what = 'emitter_exiting', imei = self.imei }) - end) - - local seen_version = 0 - while true do - self.log:debug({ what = 'emitter_waiting', imei = self.imei, seen_version = seen_version }) - local new_version = self.state_pulse:changed(seen_version) - if not new_version then - -- Pulse was closed - break - end - seen_version = new_version - sleep.sleep(timeout_buffer) -- we want to put some buffer time in to invalidate any cache - self.log:debug({ what = 'emitter_dispatching', imei = self.imei }) - - for group_name, fields in pairs(GROUP_FIELDS) do - local snapshot, group_err = self:_get_group(group_name, timeout_buffer) - if group_err ~= "" then - if D_LOG_EMITTER then - self.log:warn({ - what = 'emitter_group_failed', - imei = self.imei, - group = group_name, - err = tostring(trim_error(group_err)) - }) - end - else - for _, field in ipairs(fields) do - local value = extract_group_field(snapshot, field) - if value ~= nil then - local emit_ok, emit_err = self:_emit_meta(field, value) - if not emit_ok and D_LOG_EMITTER then - self.log:warn({ - what = 'emitter_emit_failed', - imei = self.imei, - field = tostring(field), - err = tostring(emit_err) - }) - end - end - end - end - end - end + local timeout_buffer = 0.1 + self.log:debug({ what = 'emitter_started', imei = self.imei }) + + fibers.current_scope():finally(function() + self.log:debug({ what = 'emitter_exiting', imei = self.imei }) + end) + + local seen_version = 0 + while true do + self.log:debug({ what = 'emitter_waiting', imei = self.imei, seen_version = seen_version }) + local new_version = self.state_pulse:changed(seen_version) + if not new_version then + -- Pulse was closed + break + end + seen_version = new_version + sleep.sleep(timeout_buffer) -- we want to put some buffer time in to invalidate any cache + self.log:debug({ what = 'emitter_dispatching', imei = self.imei }) + + for group_name, fields in pairs(GROUP_FIELDS) do + local snapshot, group_err = self:_get_group(group_name, timeout_buffer) + if group_err ~= "" then + if D_LOG_EMITTER then + self.log:warn({ + what = 'emitter_group_failed', + imei = self.imei, + group = group_name, + err = tostring(trim_error(group_err)) + }) + end + else + for _, field in ipairs(fields) do + local value = extract_group_field(snapshot, field) + if value ~= nil then + local emit_ok, emit_err = self:_emit_meta(field, value) + if not emit_ok and D_LOG_EMITTER then + self.log:warn({ + what = 'emitter_emit_failed', + imei = self.imei, + field = tostring(field), + err = tostring(emit_err) + }) + end + end + end + end + end + end end --- Handles both modem card state changes and SIM presence lifecycle in a single fiber. --- card_state is always current when SIM removal decisions are made, eliminating the --- need for backend-level guards about whether to reset on SIM absent. function Modem:modem_lifecycle_monitor() - local function on_card_change(state_update) - ---@cast state_update ModemStateEvent - self.log:debug({ what = 'state_change', imei = self.imei, from = state_update.from, to = state_update.to }) - local to_state = state_update and state_update.to or nil - if to_state == 'failed' or to_state == 'disabled' then - self:_invalidate_dynamic() - elseif to_state == 'locked' then - self:invalidate_groups { 'network', 'signal', 'traffic' } - else - self:invalidate_groups { 'network', 'signal', 'traffic' } - end - self:_emit_state('card', state_update) - - self.state_pulse:signal() - end - - local function on_sim_change(present, current_card_state) - local sim_state = present == true and "present" or "absent" - - self.log:debug({ what = 'sim_' .. sim_state, imei = self.imei }) - self:_emit_state("sim_state", sim_state) - - if present == true then - self:_invalidate_dynamic() - self.sim_inserted_pulse:signal() - self.state_pulse:signal() - elseif present == false then - self:_invalidate_dynamic() - if current_card_state ~= "failed" then - -- Only reset if the card is not already in a failed state. - -- If failed, a SIM-absent report is expected and resetting would cause a boot loop. - self:reset() - self.scope:cancel("modem restarting") - end - end - end - - self.log:debug({ what = 'lifecycle_monitor_started', imei = self.imei }) - - fibers.current_scope():finally(function() - self.log:debug({ what = 'lifecycle_monitor_exiting', imei = self.imei }) - end) - - local init_card_state, state_err = fibers.perform(self.backend:monitor_state_op()) - if state_err ~= "" then - self.log:error({ what = 'state_monitor_init_failed', imei = self.imei, err = tostring(state_err) }) - return - end - ---@cast init_card_state ModemStateEvent - on_card_change(init_card_state) - local card_state = init_card_state and init_card_state.to - local sim_present = nil - while true do - local source, v1, v2 = fibers.perform(op.named_choice { - card_change = self.backend:monitor_state_op(), -- outputs when modem state changes - sim_change = self.backend:wait_for_sim_present_op(), -- outputs when sim state changes - send = self.sim_state_ch:put_op(sim_present), - }) - - if is_command_closed(v2) then - local command_name = source == "card_change" and "state monitor" or "sim monitor" - self.log:error({ what = command_name .. '_closed', imei = self.imei, err = tostring(v2) }) - break - end - - if source == "card_change" then - local state_update, err = v1, v2 - ---@cast state_update ModemStateEvent - if err ~= "" then - self.log:error({ what = 'state_monitor_error', imei = self.imei, err = tostring(err) }) - elseif state_update then - card_state = state_update.to - on_card_change(state_update) - end - elseif source == "sim_change" then - local present, err = v1, v2 - if err ~= "" then - self.log:error({ what = 'sim_poll_error', imei = self.imei, err = tostring(err) }) - else - if sim_present ~= present then - on_sim_change(present, card_state) - end - sim_present = present - end - end - -- source == "send": listener consumed sim_present, loop to re-offer - end - self.log:trace({ what = 'lifecycle_monitor_exiting', imei = self.imei }) + local function on_card_change(state_update) + ---@cast state_update ModemStateEvent + self.log:debug({ what = 'state_change', imei = self.imei, from = state_update.from, to = state_update.to }) + local to_state = state_update and state_update.to or nil + if to_state == 'failed' or to_state == 'disabled' then + self:_invalidate_dynamic() + elseif to_state == 'locked' then + self:invalidate_groups { 'network', 'signal', 'traffic' } + else + self:invalidate_groups { 'network', 'signal', 'traffic' } + end + self:_emit_state('card', state_update) + + self.state_pulse:signal() + end + + local function on_sim_change(present, current_card_state) + local sim_state = present == true and "present" or "absent" + + self.log:debug({ what = 'sim_' .. sim_state, imei = self.imei }) + self:_emit_state("sim_state", sim_state) + + if present == true then + self:_invalidate_dynamic() + self.sim_inserted_pulse:signal() + self.state_pulse:signal() + elseif present == false then + self:_invalidate_dynamic() + if current_card_state ~= "failed" then + -- Only reset if the card is not already in a failed state. + -- If failed, a SIM-absent report is expected and resetting would cause a boot loop. + self:reset() + self.scope:cancel("modem restarting") + end + end + end + + self.log:debug({ what = 'lifecycle_monitor_started', imei = self.imei }) + + fibers.current_scope():finally(function() + self.log:debug({ what = 'lifecycle_monitor_exiting', imei = self.imei }) + end) + + local init_card_state, state_err = fibers.perform(self.backend:monitor_state_op()) + if state_err ~= "" then + self.log:error({ what = 'state_monitor_init_failed', imei = self.imei, err = tostring(state_err) }) + return + end + ---@cast init_card_state ModemStateEvent + on_card_change(init_card_state) + local card_state = init_card_state and init_card_state.to + local sim_present = nil + while true do + local source, v1, v2 = fibers.perform(op.named_choice { + card_change = self.backend:monitor_state_op(), -- outputs when modem state changes + sim_change = self.backend:wait_for_sim_present_op(), -- outputs when sim state changes + send = self.sim_state_ch:put_op(sim_present), + }) + + if is_command_closed(v2) then + local command_name = source == "card_change" and "state monitor" or "sim monitor" + self.log:error({ what = command_name .. '_closed', imei = self.imei, err = tostring(v2) }) + break + end + + if source == "card_change" then + local state_update, err = v1, v2 + ---@cast state_update ModemStateEvent + if err ~= "" then + self.log:error({ what = 'state_monitor_error', imei = self.imei, err = tostring(err) }) + elseif state_update then + card_state = state_update.to + on_card_change(state_update) + end + elseif source == "sim_change" then + local present, err = v1, v2 + if err ~= "" then + self.log:error({ what = 'sim_poll_error', imei = self.imei, err = tostring(err) }) + else + if sim_present ~= present then + on_sim_change(present, card_state) + end + sim_present = present + end + end + -- source == "send": listener consumed sim_present, loop to re-offer + end + self.log:trace({ what = 'lifecycle_monitor_exiting', imei = self.imei }) end function Modem:control_manager() - if self.cap_emit_ch == nil then - self.log:error({ what = 'control_no_emit_ch', imei = self.imei }) - return - end - if self.control_ch == nil then - self.log:error({ what = 'control_no_control_ch', imei = self.imei }) - return - end - - self.log:debug({ what = 'control_manager_started', imei = self.imei }) - - fibers.current_scope():finally(function() - self.log:debug({ what = 'control_manager_exiting', imei = self.imei }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - self.log:error({ what = 'control_ch_error', imei = self.imei, err = tostring(req_err) }) - break - end - - ---@cast request ControlRequest - - local ok, reason, code - - local fn = self[request.verb] - local valid, validation_err = validate_fn(fn, request.verb) - if not valid then - ok = false - reason = validation_err - else - local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, request.opts) - if not call_ok then - ok = false - reason = "internal error: " .. tostring(fn_ok) - code = 1 - else - ok = fn_ok - reason = fn_reason - code = fn_code - end - end - - local reply, reply_err = hal_types.new.Reply(ok, reason, code) - if not reply then - self.log:error({ what = 'reply_create_failed', imei = self.imei, err = tostring(reply_err) }) - else - request.reply_ch:put(reply) - end - end + if self.cap_emit_ch == nil then + self.log:error({ what = 'control_no_emit_ch', imei = self.imei }) + return + end + if self.control_ch == nil then + self.log:error({ what = 'control_no_control_ch', imei = self.imei }) + return + end + + self.log:debug({ what = 'control_manager_started', imei = self.imei }) + + fibers.current_scope():finally(function() + self.log:debug({ what = 'control_manager_exiting', imei = self.imei }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + self.log:error({ what = 'control_ch_error', imei = self.imei, err = tostring(req_err) }) + break + end + + ---@cast request ControlRequest + + local ok, reason, code + + local fn = self[request.verb] + local valid, validation_err = validate_fn(fn, request.verb) + if not valid then + ok = false + reason = validation_err + else + local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, request.opts) + if not call_ok then + ok = false + reason = "internal error: " .. tostring(fn_ok) + code = 1 + else + ok = fn_ok + reason = fn_reason + code = fn_code + end + end + + local reply, reply_err = hal_types.new.Reply(ok, reason, code) + if not reply then + self.log:error({ what = 'reply_create_failed', imei = self.imei, err = tostring(reply_err) }) + else + request.reply_ch:put(reply) + end + end end ---- Driver Functions ---- @@ -702,21 +702,21 @@ end ---@return boolean ok ---@return string error function Modem:start() - if not self.initialised then - return false, "modem not initialised" - end - if not self.caps_applied then - return false, "capabilities not applied" - end + if not self.initialised then + return false, "modem not initialised" + end + if not self.caps_applied then + return false, "capabilities not applied" + end - self.scope:spawn(function() self:modem_lifecycle_monitor() end) - self.scope:spawn(function() self:control_manager() end) - self.scope:spawn(function() self:emitter() end) + self.scope:spawn(function() self:modem_lifecycle_monitor() end) + self.scope:spawn(function() self:control_manager() end) + self.scope:spawn(function() self:emitter() end) - -- Signal initial pulse so emitter emits the initial state - self.state_pulse:signal() + -- Signal initial pulse so emitter emits the initial state + self.state_pulse:signal() - return true, "" + return true, "" end --- Closes down the modem driver @@ -724,31 +724,31 @@ end ---@return boolean ok ---@return string error function Modem:stop(timeout) - timeout = timeout or DEFAULT_STOP_TIMEOUT + timeout = timeout or DEFAULT_STOP_TIMEOUT - if self.backend and self.backend.shutdown_op then - local ok, err = fibers.perform(self.backend:shutdown_op(1.0)) - if not ok then - self.log:warn({ - what = "backend_shutdown_failed", - address = self.address, - imei = self.imei, - err = tostring(err), - }) - end - end + if self.backend and self.backend.shutdown_op then + local ok, err = fibers.perform(self.backend:shutdown_op(1.0)) + if not ok then + self.log:warn({ + what = "backend_shutdown_failed", + address = self.address, + imei = self.imei, + err = tostring(err), + }) + end + end - self.scope:cancel("modem stopping") + self.scope:cancel("modem stopping") - local source = fibers.perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout) - }) + local source = fibers.perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout) + }) - if source == "timeout" then - return false, "modem stop timeout" - end - return true, "" + if source == "timeout" then + return false, "modem stop timeout" + end + return true, "" end --- Apply capabilities to HAL and start monitoring state @@ -757,86 +757,86 @@ end ---@return Capability[]? capabilities ---@return string? error function Modem:capabilities(emit_ch) - if not self.initialised then - return nil, "modem not initialised" - end - if self.caps_applied then - return nil, "capabilities already applied" - end + if not self.initialised then + return nil, "modem not initialised" + end + if self.caps_applied then + return nil, "capabilities already applied" + end - self.cap_emit_ch = emit_ch + self.cap_emit_ch = emit_ch - local modem_cap, mod_cap_err = cap_types.new.ModemCapability( - self.imei, - self.control_ch - ) - if not modem_cap then - return nil, "failed to create modem capability: " .. tostring(mod_cap_err) - end + local modem_cap, mod_cap_err = cap_types.new.ModemCapability( + self.imei, + self.control_ch + ) + if not modem_cap then + return nil, "failed to create modem capability: " .. tostring(mod_cap_err) + end - self.caps_applied = true + self.caps_applied = true - return { modem_cap } + return { modem_cap } end --- Setup modem overrides and long running fibers ---@return string error function Modem:init() - if self.initialised then - return "already initialised" - end - - local backend_built_sig = cond.new() - - local ok, err = self.scope:spawn(function() - self.backend = modem_backend_provider.new(self.address) - - self.scope:finally(function() - if self.backend and self.backend.terminate then - self.backend:terminate("modem_driver_scope_exit") - end - end) - - local sim_ok, sim_err = self.backend:start_sim_presence_monitor() - if not sim_ok then - error("failed to start SIM presence monitor: " .. tostring(sim_err)) - end - - local state_ok, state_err = self.backend:start_state_monitor() - if not state_ok then - error("failed to start modem state monitor: " .. tostring(state_err)) - end - - local identity_info, identity_err = self.backend:read_identity() - if not identity_info then - error("failed to get modem identity info: " .. tostring(identity_err)) - end - - self:_cache_group("identity", identity_info) - self.imei = identity_info.imei - self.model = identity_info.model - self.mode = identity_info.mode - - backend_built_sig:signal() - end) - - if not ok then - return "failed to spawn modem backend fiber: " .. tostring(err) - end - - local source, _, primary = fibers.perform(op.named_choice { - backend_ready = backend_built_sig:wait_op(), - failed = self.scope:fault_op() - }) - - if source == "backend_ready" then - self.initialised = true - return "" - elseif source == "failed" then - return "modem init failed: " .. tostring(primary) -- primary is the error from the faulted fiber - else - return "unexpected error during modem init" - end + if self.initialised then + return "already initialised" + end + + local backend_built_sig = cond.new() + + local ok, err = self.scope:spawn(function() + self.backend = modem_backend_provider.new(self.address) + + self.scope:finally(function() + if self.backend and self.backend.terminate then + self.backend:terminate("modem_driver_scope_exit") + end + end) + + local sim_ok, sim_err = self.backend:start_sim_presence_monitor() + if not sim_ok then + error("failed to start SIM presence monitor: " .. tostring(sim_err)) + end + + local state_ok, state_err = self.backend:start_state_monitor() + if not state_ok then + error("failed to start modem state monitor: " .. tostring(state_err)) + end + + local identity_info, identity_err = self.backend:read_identity() + if not identity_info then + error("failed to get modem identity info: " .. tostring(identity_err)) + end + + self:_cache_group("identity", identity_info) + self.imei = identity_info.imei + self.model = identity_info.model + self.mode = identity_info.mode + + backend_built_sig:signal() + end) + + if not ok then + return "failed to spawn modem backend fiber: " .. tostring(err) + end + + local source, _, primary = fibers.perform(op.named_choice { + backend_ready = backend_built_sig:wait_op(), + failed = self.scope:fault_op() + }) + + if source == "backend_ready" then + self.initialised = true + return "" + elseif source == "failed" then + return "modem init failed: " .. tostring(primary) -- primary is the error from the faulted fiber + else + return "unexpected error during modem init" + end end --- Create a new Modem driver. @@ -845,42 +845,42 @@ end ---@return Modem? modem ---@return string error local function new(address, logger) - if type(address) ~= 'string' or address == '' then - return nil, "invalid address" - end - - local control_ch = channel.new(CONTROL_Q_LEN) - - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - -- Print out driver stack trace if scope closes on a failure - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - logger:error({ what = 'scope_error', address = address, err = tostring(primary) }) - logger:debug({ what = 'scope_exit', address = address, status = st }) - end - logger:debug({ what = 'stopped', address = address }) - end) - - return setmetatable({ - scope = scope, - log = logger, - address = address, - cache = cache_mod.new(math.huge, fibers.now, '.'), - initialised = false, -- modem cannot apply capabilities until initialised - caps_applied = false, -- modem cannot start until capabilities applied - listening_for_sim = false, - state_pulse = pulse.new(), - sim_inserted_pulse = pulse.new(), - sim_state_ch = channel.new(), - control_ch = control_ch - }, Modem), "" + if type(address) ~= 'string' or address == '' then + return nil, "invalid address" + end + + local control_ch = channel.new(CONTROL_Q_LEN) + + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + -- Print out driver stack trace if scope closes on a failure + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + logger:error({ what = 'scope_error', address = address, err = tostring(primary) }) + logger:debug({ what = 'scope_exit', address = address, status = st }) + end + logger:debug({ what = 'stopped', address = address }) + end) + + return setmetatable({ + scope = scope, + log = logger, + address = address, + cache = cache_mod.new(math.huge, fibers.now, '.'), + initialised = false, -- modem cannot apply capabilities until initialised + caps_applied = false, -- modem cannot start until capabilities applied + listening_for_sim = false, + state_pulse = pulse.new(), + sim_inserted_pulse = pulse.new(), + sim_state_ch = channel.new(), + control_ch = control_ch + }, Modem), "" end return { - new = new + new = new } diff --git a/src/services/hal/drivers/platform.lua b/src/services/hal/drivers/platform.lua index d0dc195c..79bf851e 100644 --- a/src/services/hal/drivers/platform.lua +++ b/src/services/hal/drivers/platform.lua @@ -23,9 +23,9 @@ local perform = fibers.perform local CONTROL_Q_LEN = 8 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class PlatformDriver @@ -44,59 +44,59 @@ PlatformDriver.__index = PlatformDriver ---@return string? content ---@return string error local function read_file(path) - local f, open_err = file.open(path, 'r') - if not f then - return nil, tostring(open_err) - end - local content, read_err = f:read_all() - f:close() - if not content then - return nil, tostring(read_err) - end - return content, "" + local f, open_err = file.open(path, 'r') + if not f then + return nil, tostring(open_err) + end + local content, read_err = f:read_all() + f:close() + if not content then + return nil, tostring(read_err) + end + return content, "" end ---@param s string ---@return string local function trim(s) - return (s or ""):match("^%s*(.-)%s*$") or "" + return (s or ""):match("^%s*(.-)%s*$") or "" end local function copy_plain(t) - local out = {} - for k, v in pairs(t or {}) do out[k] = v end - return out + local out = {} + for k, v in pairs(t or {}) do out[k] = v end + return out end --- Run fw_printenv and extract a named variable. ---@param varname string ---@return string value local function read_fw_printenv(varname) - local cmd = exec.command { 'fw_printenv', varname, stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, _, code = perform(cmd:output_op()) - if code ~= 0 or not out then - return "" - end - -- Output format: "varname=value\n" - return trim(out:match(varname .. "=(.+)$") or "") + local cmd = exec.command { 'fw_printenv', varname, stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, _, code = perform(cmd:output_op()) + if code ~= 0 or not out then + return "" + end + -- Output format: "varname=value\n" + return trim(out:match(varname .. "=(.+)$") or "") end --- Read all identity fields once at driver creation. ---@return table identity local function read_identity() - local function safe_read(path) - local v, _ = read_file(path) - return trim(v or "") - end - - local board_revision = read_fw_printenv('board_revision') - - return { - hw_revision = safe_read('/etc/hwrevision'), - fw_version = safe_read('/etc/fwversion'), - serial = safe_read('/data/serial'), - board_revision = board_revision, - } + local function safe_read(path) + local v, _ = read_file(path) + return trim(v or "") + end + + local board_revision = read_fw_printenv('board_revision') + + return { + hw_revision = safe_read('/etc/hwrevision'), + fw_version = safe_read('/etc/fwversion'), + serial = safe_read('/data/serial'), + board_revision = board_revision, + } end ---- capability verbs ---- @@ -105,186 +105,186 @@ end ---@return boolean ok ---@return any value_or_err function PlatformDriver:get(opts) - if opts == nil or getmetatable(opts) ~= cap_args.PlatformGetOpts then - return false, "invalid opts" - end - local field = opts.field - local max_age = opts.max_age - - if field == 'identity' then - return true, copy_plain(self.identity) - end - - if self.identity and self.identity[field] ~= nil then - return true, self.identity[field] - end - - if field ~= 'uptime' then - return false, "unsupported field: " .. tostring(field) - end - - local cached = self.cache:get('uptime', max_age) - if cached ~= nil then - return true, cached - end - - local raw, err = read_file('/proc/uptime') - if not raw then - return false, "failed to read /proc/uptime: " .. err - end - - local uptime_s = tonumber(raw:match("([%d%.]+)")) - if not uptime_s then - return false, "failed to parse /proc/uptime" - end - - self.cache:set('uptime', uptime_s) - return true, uptime_s + if opts == nil or getmetatable(opts) ~= cap_args.PlatformGetOpts then + return false, "invalid opts" + end + local field = opts.field + local max_age = opts.max_age + + if field == 'identity' then + return true, copy_plain(self.identity) + end + + if self.identity and self.identity[field] ~= nil then + return true, self.identity[field] + end + + if field ~= 'uptime' then + return false, "unsupported field: " .. tostring(field) + end + + local cached = self.cache:get('uptime', max_age) + if cached ~= nil then + return true, cached + end + + local raw, err = read_file('/proc/uptime') + if not raw then + return false, "failed to read /proc/uptime: " .. err + end + + local uptime_s = tonumber(raw:match("([%d%.]+)")) + if not uptime_s then + return false, "failed to parse /proc/uptime" + end + + self.cache:set('uptime', uptime_s) + return true, uptime_s end ---- control manager ---- function PlatformDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- public interface ---- ---@return string error function PlatformDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? ---@return string error function PlatformDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "platform driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.PlatformCapability('1', self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return nil, "platform driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.PlatformCapability('1', self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function PlatformDriver:start() - if not self.initialised then - return false, "platform driver not initialised" - end - if self.cap_emit_ch then - -- Publish identity as a retained state sub-topic (consistent with modem state/card). - local state_payload, state_err = hal_types.new.Emit( - 'platform', '1', 'state', 'identity', self.identity) - if state_payload then - self.cap_emit_ch:put(state_payload) - else - dlog(self.logger, 'debug', { what = 'state_identity_emit_failed', err = tostring(state_err) }) - end - - -- Publish meta. - local meta_payload, meta_err = hal_types.new.Emit('platform', '1', 'meta', 'info', { - provider = 'hal', - version = 1, - }) - if meta_payload then - self.cap_emit_ch:put(meta_payload) - else - dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) - end - end - - local ok, spawn_err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(spawn_err) - end - return true, "" + if not self.initialised then + return false, "platform driver not initialised" + end + if self.cap_emit_ch then + -- Publish identity as a retained state sub-topic (consistent with modem state/card). + local state_payload, state_err = hal_types.new.Emit( + 'platform', '1', 'state', 'identity', self.identity) + if state_payload then + self.cap_emit_ch:put(state_payload) + else + dlog(self.logger, 'debug', { what = 'state_identity_emit_failed', err = tostring(state_err) }) + end + + -- Publish meta. + local meta_payload, meta_err = hal_types.new.Emit('platform', '1', 'meta', 'info', { + provider = 'hal', + version = 1, + }) + if meta_payload then + self.cap_emit_ch:put(meta_payload) + else + dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) + end + end + + local ok, spawn_err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(spawn_err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function PlatformDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel('platform driver stopped') - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, "platform driver stop timeout" - end - return true, "" + timeout = timeout or 5 + self.scope:cancel('platform driver stopped') + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, "platform driver stop timeout" + end + return true, "" end ---@param logger Logger? ---@return PlatformDriver? ---@return string error local function new(logger) - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - local identity = read_identity() - - return setmetatable({ - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - cache = cache_mod.new(), - identity = identity, - logger = logger, - initialised = false, - }, PlatformDriver), "" + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + local identity = read_identity() + + return setmetatable({ + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + cache = cache_mod.new(), + identity = identity, + logger = logger, + initialised = false, + }, PlatformDriver), "" end return { new = new } diff --git a/src/services/hal/drivers/power.lua b/src/services/hal/drivers/power.lua index ad431b39..6c8407ad 100644 --- a/src/services/hal/drivers/power.lua +++ b/src/services/hal/drivers/power.lua @@ -22,9 +22,9 @@ local perform = fibers.perform local CONTROL_Q_LEN = 8 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end -- Delay (seconds) before executing a power command. This gives the reply @@ -45,167 +45,167 @@ PowerDriver.__index = PowerDriver ---@return boolean ok ---@return nil reason function PowerDriver:shutdown(opts) - if opts ~= nil and getmetatable(opts) ~= cap_args.PowerActionOpts then - return false, "invalid opts" - end - local delay = (opts and opts.delay) or EXEC_DELAY - self.scope:spawn(function() - -- Give caller time to receive the reply before the system shuts down. - perform(sleep.sleep_op(delay)) - dlog(self.logger, 'info', { what = 'executing_shutdown' }) - local cmd = exec.command { 'shutdown', '-h', 'now', stdin = 'null', stdout = 'null', stderr = 'null' } - perform(cmd:run_op()) - end) - return true, nil + if opts ~= nil and getmetatable(opts) ~= cap_args.PowerActionOpts then + return false, "invalid opts" + end + local delay = (opts and opts.delay) or EXEC_DELAY + self.scope:spawn(function() + -- Give caller time to receive the reply before the system shuts down. + perform(sleep.sleep_op(delay)) + dlog(self.logger, 'info', { what = 'executing_shutdown' }) + local cmd = exec.command { 'shutdown', '-h', 'now', stdin = 'null', stdout = 'null', stderr = 'null' } + perform(cmd:run_op()) + end) + return true, nil end ---@param opts PowerActionOpts? ---@return boolean ok ---@return nil reason function PowerDriver:reboot(opts) - if opts ~= nil and getmetatable(opts) ~= cap_args.PowerActionOpts then - return false, "invalid opts" - end - local delay = (opts and opts.delay) or EXEC_DELAY - self.scope:spawn(function() - perform(sleep.sleep_op(delay)) - dlog(self.logger, 'info', { what = 'executing_reboot' }) - local cmd = exec.command { 'reboot', stdin = 'null', stdout = 'null', stderr = 'null' } - perform(cmd:run_op()) - end) - return true, nil + if opts ~= nil and getmetatable(opts) ~= cap_args.PowerActionOpts then + return false, "invalid opts" + end + local delay = (opts and opts.delay) or EXEC_DELAY + self.scope:spawn(function() + perform(sleep.sleep_op(delay)) + dlog(self.logger, 'info', { what = 'executing_reboot' }) + local cmd = exec.command { 'reboot', stdin = 'null', stdout = 'null', stderr = 'null' } + perform(cmd:run_op()) + end) + return true, nil end ---- control manager ---- function PowerDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - -- Reply is sent BEFORE the exec fiber (spawned by shutdown/reboot) runs. - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + -- Reply is sent BEFORE the exec fiber (spawned by shutdown/reboot) runs. + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- public interface ---- ---@return string error function PowerDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? ---@return string error function PowerDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "power driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.PowerCapability('1', self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return nil, "power driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.PowerCapability('1', self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function PowerDriver:start() - if not self.initialised then - return false, "power driver not initialised" - end - if self.cap_emit_ch then - local meta_payload, meta_err = hal_types.new.Emit('power', '1', 'meta', 'info', { - provider = 'hal', - version = 1, - }) - if meta_payload then - self.cap_emit_ch:put(meta_payload) - else - dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) - end - end - - local ok, spawn_err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(spawn_err) - end - return true, "" + if not self.initialised then + return false, "power driver not initialised" + end + if self.cap_emit_ch then + local meta_payload, meta_err = hal_types.new.Emit('power', '1', 'meta', 'info', { + provider = 'hal', + version = 1, + }) + if meta_payload then + self.cap_emit_ch:put(meta_payload) + else + dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) + end + end + + local ok, spawn_err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(spawn_err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function PowerDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel('power driver stopped') - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, "power driver stop timeout" - end - return true, "" + timeout = timeout or 5 + self.scope:cancel('power driver stopped') + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, "power driver stop timeout" + end + return true, "" end ---@param logger Logger? ---@return PowerDriver? ---@return string error local function new(logger) - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - return setmetatable({ - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - logger = logger, - initialised = false, - }, PowerDriver), "" + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + return setmetatable({ + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + logger = logger, + initialised = false, + }, PowerDriver), "" end return { new = new } diff --git a/src/services/hal/drivers/radio.lua b/src/services/hal/drivers/radio.lua index 03b30813..1865060d 100644 --- a/src/services/hal/drivers/radio.lua +++ b/src/services/hal/drivers/radio.lua @@ -13,38 +13,38 @@ local INT32_MAX = 2147483647 local VALID_BANDS = { '2g', '5g' } local VALID_HTMODES = { - 'HE20', 'HE40+', 'HE40-', 'HE80', 'HE160', - 'HT20', 'HT40+', 'HT40-', - 'VHT20', 'VHT40+', 'VHT40-', 'VHT80', 'VHT160', + 'HE20', 'HE40+', 'HE40-', 'HE80', 'HE160', + 'HT20', 'HT40+', 'HT40-', + 'VHT20', 'VHT40+', 'VHT40-', 'VHT80', 'VHT160', } local VALID_ENCRYPTIONS = { - 'none', 'wep', 'psk', 'psk2', 'psk-mixed', - 'sae', 'sae-mixed', 'owe', 'wpa', 'wpa2', 'wpa3', + 'none', 'wep', 'psk', 'psk2', 'psk-mixed', + 'sae', 'sae-mixed', 'owe', 'wpa', 'wpa2', 'wpa3', } local VALID_MODES = { 'ap', 'sta', 'adhoc', 'mesh', 'monitor' } local function is_in(value, list) - for _, v in ipairs(list) do - if v == value then return true end - end - return false + for _, v in ipairs(list) do + if v == value then return true end + end + return false end local function is_list(t) - if type(t) ~= 'table' then return false end - local i = 1 - for k in pairs(t) do - if k ~= i then return false end - i = i + 1 - end - return true + if type(t) ~= 'table' then return false end + local i = 1 + for k in pairs(t) do + if k ~= i then return false end + i = i + 1 + end + return true end local function fix_underflow(n) - if type(n) == 'number' and n > INT32_MAX then - return n - 4294967296 - end - return n + if type(n) == 'number' and n > INT32_MAX then + return n - 4294967296 + end + return n end ---@class RadioDriver @@ -64,13 +64,13 @@ local RadioDriver = {} RadioDriver.__index = RadioDriver local function emit_event(emit_ch, id, key, data) - local payload = hal_types.new.Emit('radio', id, 'event', key, data) - if payload then emit_ch:put(payload) end + local payload = hal_types.new.Emit('radio', id, 'event', key, data) + if payload then emit_ch:put(payload) end end local function emit_state(emit_ch, id, key, data) - local payload = hal_types.new.Emit('radio', id, 'state', key, data) - if payload then emit_ch:put(payload) end + local payload = hal_types.new.Emit('radio', id, 'state', key, data) + if payload then emit_ch:put(payload) end end ------------------------------------------------------------------------ @@ -81,186 +81,186 @@ end ---@return boolean ok ---@return string? reason function RadioDriver:set_channels(opts) - if getmetatable(opts) ~= cap_args.RadioSetChannelsOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioSetChannelsOpts(opts.band, opts.channel, opts.htmode, opts.channels) - if not casted then return false, err end - opts = casted - end - if type(opts.band) ~= 'string' or not is_in(opts.band, VALID_BANDS) then - return false, 'band must be one of: ' .. table.concat(VALID_BANDS, ', ') - end - if type(opts.htmode) ~= 'string' or not is_in(opts.htmode, VALID_HTMODES) then - return false, 'htmode must be one of: ' .. table.concat(VALID_HTMODES, ', ') - end - if opts.channel == 'auto' then - if not is_list(opts.channels) or #opts.channels == 0 then - return false, 'channels must be a non-empty list when channel is "auto"' - end - elseif type(opts.channel) ~= 'number' and type(opts.channel) ~= 'string' then - return false, 'channel must be a number, string, or "auto"' - end - - self.staged.band = opts.band - self.staged.channel = opts.channel - self.staged.htmode = opts.htmode - self.staged.channels = (opts.channel == 'auto') and opts.channels or nil - return true + if getmetatable(opts) ~= cap_args.RadioSetChannelsOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioSetChannelsOpts(opts.band, opts.channel, opts.htmode, opts.channels) + if not casted then return false, err end + opts = casted + end + if type(opts.band) ~= 'string' or not is_in(opts.band, VALID_BANDS) then + return false, 'band must be one of: ' .. table.concat(VALID_BANDS, ', ') + end + if type(opts.htmode) ~= 'string' or not is_in(opts.htmode, VALID_HTMODES) then + return false, 'htmode must be one of: ' .. table.concat(VALID_HTMODES, ', ') + end + if opts.channel == 'auto' then + if not is_list(opts.channels) or #opts.channels == 0 then + return false, 'channels must be a non-empty list when channel is "auto"' + end + elseif type(opts.channel) ~= 'number' and type(opts.channel) ~= 'string' then + return false, 'channel must be a number, string, or "auto"' + end + + self.staged.band = opts.band + self.staged.channel = opts.channel + self.staged.htmode = opts.htmode + self.staged.channels = (opts.channel == 'auto') and opts.channels or nil + return true end ---@param opts RadioSetTxpowerOpts ---@return boolean ok ---@return string? reason function RadioDriver:set_txpower(opts) - if getmetatable(opts) ~= cap_args.RadioSetTxpowerOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioSetTxpowerOpts(opts.txpower) - if not casted then return false, err end - opts = casted - end - if type(opts.txpower) ~= 'number' and type(opts.txpower) ~= 'string' then - return false, 'txpower must be a number or string' - end - self.staged.txpower = opts.txpower - return true + if getmetatable(opts) ~= cap_args.RadioSetTxpowerOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioSetTxpowerOpts(opts.txpower) + if not casted then return false, err end + opts = casted + end + if type(opts.txpower) ~= 'number' and type(opts.txpower) ~= 'string' then + return false, 'txpower must be a number or string' + end + self.staged.txpower = opts.txpower + return true end ---@param opts RadioSetCountryOpts ---@return boolean ok ---@return string? reason function RadioDriver:set_country(opts) - if getmetatable(opts) ~= cap_args.RadioSetCountryOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioSetCountryOpts(opts.country) - if not casted then return false, err end - opts = casted - end - if type(opts.country) ~= 'string' or #opts.country ~= 2 then - return false, 'country must be a 2-character string' - end - self.staged.country = opts.country:upper() - return true + if getmetatable(opts) ~= cap_args.RadioSetCountryOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioSetCountryOpts(opts.country) + if not casted then return false, err end + opts = casted + end + if type(opts.country) ~= 'string' or #opts.country ~= 2 then + return false, 'country must be a 2-character string' + end + self.staged.country = opts.country:upper() + return true end ---@param opts RadioSetEnabledOpts ---@return boolean ok ---@return string? reason function RadioDriver:set_enabled(opts) - if getmetatable(opts) ~= cap_args.RadioSetEnabledOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioSetEnabledOpts(opts.enabled) - if not casted then return false, err end - opts = casted - end - if type(opts.enabled) ~= 'boolean' then - return false, 'enabled must be a boolean' - end - -- UCI disabled flag is inverted - self.staged.disabled = opts.enabled and '0' or '1' - return true + if getmetatable(opts) ~= cap_args.RadioSetEnabledOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioSetEnabledOpts(opts.enabled) + if not casted then return false, err end + opts = casted + end + if type(opts.enabled) ~= 'boolean' then + return false, 'enabled must be a boolean' + end + -- UCI disabled flag is inverted + self.staged.disabled = opts.enabled and '0' or '1' + return true end ---@param opts RadioAddInterfaceOpts ---@return boolean ok ---@return string? iface_name generated interface name on success function RadioDriver:add_interface(opts) - if getmetatable(opts) ~= cap_args.RadioAddInterfaceOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioAddInterfaceOpts( - opts.ssid, opts.encryption, opts.password, opts.network, opts.mode, opts.enable_steering) - if not casted then return false, err end - opts = casted - end - if type(opts.ssid) ~= 'string' or opts.ssid == '' then - return false, 'ssid must be a non-empty string' - end - if type(opts.encryption) ~= 'string' or not is_in(opts.encryption, VALID_ENCRYPTIONS) then - return false, 'encryption must be one of: ' .. table.concat(VALID_ENCRYPTIONS, ', ') - end - if type(opts.password) ~= 'string' then - return false, 'password must be a string' - end - if type(opts.network) ~= 'string' or opts.network == '' then - return false, 'network must be a non-empty string' - end - if type(opts.mode) ~= 'string' or not is_in(opts.mode, VALID_MODES) then - return false, 'mode must be one of: ' .. table.concat(VALID_MODES, ', ') - end - if type(opts.enable_steering) ~= 'boolean' then - return false, 'enable_steering must be a boolean' - end - - local iface_name = self.id .. '_i' .. tostring(self.iface_counter) - self.iface_counter = self.iface_counter + 1 - - table.insert(self.staged.interfaces, { - name = iface_name, - ssid = opts.ssid, - encryption = opts.encryption, - password = opts.password, - network = opts.network, - mode = opts.mode, - enable_steering = opts.enable_steering, - }) - self.iface_update_ch:put({ op = 'add', name = iface_name }) - -- Reply carries the generated interface name in reason - return true, iface_name + if getmetatable(opts) ~= cap_args.RadioAddInterfaceOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioAddInterfaceOpts( + opts.ssid, opts.encryption, opts.password, opts.network, opts.mode, opts.enable_steering) + if not casted then return false, err end + opts = casted + end + if type(opts.ssid) ~= 'string' or opts.ssid == '' then + return false, 'ssid must be a non-empty string' + end + if type(opts.encryption) ~= 'string' or not is_in(opts.encryption, VALID_ENCRYPTIONS) then + return false, 'encryption must be one of: ' .. table.concat(VALID_ENCRYPTIONS, ', ') + end + if type(opts.password) ~= 'string' then + return false, 'password must be a string' + end + if type(opts.network) ~= 'string' or opts.network == '' then + return false, 'network must be a non-empty string' + end + if type(opts.mode) ~= 'string' or not is_in(opts.mode, VALID_MODES) then + return false, 'mode must be one of: ' .. table.concat(VALID_MODES, ', ') + end + if type(opts.enable_steering) ~= 'boolean' then + return false, 'enable_steering must be a boolean' + end + + local iface_name = self.id .. '_i' .. tostring(self.iface_counter) + self.iface_counter = self.iface_counter + 1 + + table.insert(self.staged.interfaces, { + name = iface_name, + ssid = opts.ssid, + encryption = opts.encryption, + password = opts.password, + network = opts.network, + mode = opts.mode, + enable_steering = opts.enable_steering, + }) + self.iface_update_ch:put({ op = 'add', name = iface_name }) + -- Reply carries the generated interface name in reason + return true, iface_name end ---@param opts RadioDeleteInterfaceOpts ---@return boolean ok ---@return string? reason function RadioDriver:delete_interface(opts) - if getmetatable(opts) ~= cap_args.RadioDeleteInterfaceOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioDeleteInterfaceOpts(opts.interface) - if not casted then return false, err end - opts = casted - end - if type(opts.interface) ~= 'string' or opts.interface == '' then - return false, 'interface must be a non-empty string' - end - -- Remove from staged interfaces list - for i, iface in ipairs(self.staged.interfaces) do - if iface.name == opts.interface then - table.remove(self.staged.interfaces, i) - table.insert(self.staged.deleted_interfaces, opts.interface) - self.iface_update_ch:put({ op = 'remove', name = opts.interface }) - return true - end - end - return false, 'interface not found in staged config: ' .. opts.interface + if getmetatable(opts) ~= cap_args.RadioDeleteInterfaceOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioDeleteInterfaceOpts(opts.interface) + if not casted then return false, err end + opts = casted + end + if type(opts.interface) ~= 'string' or opts.interface == '' then + return false, 'interface must be a non-empty string' + end + -- Remove from staged interfaces list + for i, iface in ipairs(self.staged.interfaces) do + if iface.name == opts.interface then + table.remove(self.staged.interfaces, i) + table.insert(self.staged.deleted_interfaces, opts.interface) + self.iface_update_ch:put({ op = 'remove', name = opts.interface }) + return true + end + end + return false, 'interface not found in staged config: ' .. opts.interface end ---@return boolean ok function RadioDriver:clear_radio_config() - self.staged = { - name = self.id, - path = self.staged.path, - type = self.staged.type, - interfaces = {}, - deleted_interfaces = {}, - } - self.iface_update_ch:put({ op = 'reset' }) - return true + self.staged = { + name = self.id, + path = self.staged.path, + type = self.staged.type, + interfaces = {}, + deleted_interfaces = {}, + } + self.iface_update_ch:put({ op = 'reset' }) + return true end ---@param opts RadioSetReportPeriodOpts ---@return boolean ok ---@return string? reason function RadioDriver:set_report_period(opts) - if getmetatable(opts) ~= cap_args.RadioSetReportPeriodOpts then - opts = opts or {} - local casted, err = cap_args.new.RadioSetReportPeriodOpts(opts.period) - if not casted then return false, err end - opts = casted - end - local period = opts.period - if type(period) ~= 'number' or period <= 0 then - return false, 'period must be a positive number' - end - self.report_period_ch:put(period) - return true + if getmetatable(opts) ~= cap_args.RadioSetReportPeriodOpts then + opts = opts or {} + local casted, err = cap_args.new.RadioSetReportPeriodOpts(opts.period) + if not casted then return false, err end + opts = casted + end + local period = opts.period + if type(period) ~= 'number' or period <= 0 then + return false, 'period must be a positive number' + end + self.report_period_ch:put(period) + return true end ---Clear all UCI config owned by this radio, resetting it to a blank state. @@ -268,46 +268,46 @@ end ---@return boolean ok ---@return string? reason function RadioDriver:clear() - local ok, err = pcall(function() self.backend:clear() end) - if not ok then - return false, tostring(err) - end - self.staged = { - name = self.id, - path = self.staged.path, - type = self.staged.type, - interfaces = {}, - deleted_interfaces = {}, - } - self.iface_update_ch:put({ op = 'reset' }) - self.iface_counter = 0 - return true + local ok, err = pcall(function() self.backend:clear() end) + if not ok then + return false, tostring(err) + end + self.staged = { + name = self.id, + path = self.staged.path, + type = self.staged.type, + interfaces = {}, + deleted_interfaces = {}, + } + self.iface_update_ch:put({ op = 'reset' }) + self.iface_counter = 0 + return true end ---@return boolean ok ---@return string? reason function RadioDriver:apply() - local ok, err = pcall(function() self.backend:apply(self.staged) end) - if not ok then - return false, tostring(err) - end - -- Reset interface counter on successful apply - self.iface_counter = 0 - return true + local ok, err = pcall(function() self.backend:apply(self.staged) end) + if not ok then + return false, tostring(err) + end + -- Reset interface counter on successful apply + self.iface_counter = 0 + return true end ---@return boolean ok function RadioDriver:rollback() - self.staged = { - name = self.id, - path = self.staged.path, - type = self.staged.type, - interfaces = {}, - deleted_interfaces = {}, - } - self.iface_update_ch:put({ op = 'reset' }) - self.iface_counter = 0 - return true + self.staged = { + name = self.id, + path = self.staged.path, + type = self.staged.type, + interfaces = {}, + deleted_interfaces = {}, + } + self.iface_update_ch:put({ op = 'reset' }) + self.iface_counter = 0 + return true end ------------------------------------------------------------------------ @@ -315,38 +315,38 @@ end ------------------------------------------------------------------------ local function dispatch_rpc(driver, request) - local fn = driver[request.verb] - local ok, reason - if type(fn) ~= 'function' then - ok, reason = false, 'unknown verb: ' .. tostring(request.verb) - else - local call_ok, r1, r2 = pcall(fn, driver, request.opts) - if not call_ok then - ok, reason = false, tostring(r1) - else - ok, reason = r1, r2 - end - end - local reply = hal_types.new.Reply(ok, reason) - if reply then - request.reply_ch:put(reply) - end + local fn = driver[request.verb] + local ok, reason + if type(fn) ~= 'function' then + ok, reason = false, 'unknown verb: ' .. tostring(request.verb) + else + local call_ok, r1, r2 = pcall(fn, driver, request.opts) + if not call_ok then + ok, reason = false, tostring(r1) + else + ok, reason = r1, r2 + end + end + local reply = hal_types.new.Reply(ok, reason) + if reply then + request.reply_ch:put(reply) + end end function RadioDriver:control_manager() - fibers.current_scope():finally(function() - self.log:debug({ what = 'radio_driver_stopped', id = self.id }) - end) - - while true do - local source, request = fibers.perform(fibers.named_choice({ - rpc = self.control_ch:get_op(), - cancel = fibers.current_scope():cancel_op(), - })) - - if source == 'cancel' then break end - dispatch_rpc(self, request) - end + fibers.current_scope():finally(function() + self.log:debug({ what = 'radio_driver_stopped', id = self.id }) + end) + + while true do + local source, request = fibers.perform(fibers.named_choice({ + rpc = self.control_ch:get_op(), + cancel = fibers.current_scope():cancel_op(), + })) + + if source == 'cancel' then break end + dispatch_rpc(self, request) + end end ------------------------------------------------------------------------ @@ -355,98 +355,98 @@ end ---Emit all interface-level stats for one interface name. local function tick_iface(emit_ch, id, backend, iface_name, connected) - local info, _ = backend:get_iface_info(iface_name) - if info then - if info.txpower ~= nil then - emit_state(emit_ch, id, 'iface_power', { - interface = iface_name, - value = fix_underflow(info.txpower), - }) - end - if info.channel ~= nil then - emit_state(emit_ch, id, 'iface_channel', { - interface = iface_name, - value = fix_underflow(info.channel), - }) - end - end - - local noise, _ = backend:get_iface_survey(iface_name) - if noise ~= nil then - emit_state(emit_ch, id, 'iface_noise', { - interface = iface_name, - value = fix_underflow(noise), - }) - end - - for _, stat in ipairs(backend.SYSFS_STATS or {}) do - local sval, _ = backend:read_sysfs_stat(iface_name, stat) - if sval ~= nil then - emit_state(emit_ch, id, 'iface_' .. stat, { - interface = iface_name, - value = fix_underflow(sval), - }) - end - end - - if connected[iface_name] then - for mac in pairs(connected[iface_name]) do - local sta, _ = backend:get_station_info(iface_name, mac) - if sta then - if sta.signal ~= nil then - emit_state(emit_ch, id, 'client_signal', { - mac = mac, - interface = iface_name, - value = fix_underflow(sta.signal), - }) - end - if sta.tx_bytes ~= nil then - emit_state(emit_ch, id, 'client_tx_bytes', { - mac = mac, - interface = iface_name, - value = fix_underflow(sta.tx_bytes), - }) - end - if sta.rx_bytes ~= nil then - emit_state(emit_ch, id, 'client_rx_bytes', { - mac = mac, - interface = iface_name, - value = fix_underflow(sta.rx_bytes), - }) - end - end - end - end + local info, _ = backend:get_iface_info(iface_name) + if info then + if info.txpower ~= nil then + emit_state(emit_ch, id, 'iface_power', { + interface = iface_name, + value = fix_underflow(info.txpower), + }) + end + if info.channel ~= nil then + emit_state(emit_ch, id, 'iface_channel', { + interface = iface_name, + value = fix_underflow(info.channel), + }) + end + end + + local noise, _ = backend:get_iface_survey(iface_name) + if noise ~= nil then + emit_state(emit_ch, id, 'iface_noise', { + interface = iface_name, + value = fix_underflow(noise), + }) + end + + for _, stat in ipairs(backend.SYSFS_STATS or {}) do + local sval, _ = backend:read_sysfs_stat(iface_name, stat) + if sval ~= nil then + emit_state(emit_ch, id, 'iface_' .. stat, { + interface = iface_name, + value = fix_underflow(sval), + }) + end + end + + if connected[iface_name] then + for mac in pairs(connected[iface_name]) do + local sta, _ = backend:get_station_info(iface_name, mac) + if sta then + if sta.signal ~= nil then + emit_state(emit_ch, id, 'client_signal', { + mac = mac, + interface = iface_name, + value = fix_underflow(sta.signal), + }) + end + if sta.tx_bytes ~= nil then + emit_state(emit_ch, id, 'client_tx_bytes', { + mac = mac, + interface = iface_name, + value = fix_underflow(sta.tx_bytes), + }) + end + if sta.rx_bytes ~= nil then + emit_state(emit_ch, id, 'client_rx_bytes', { + mac = mac, + interface = iface_name, + value = fix_underflow(sta.rx_bytes), + }) + end + end + end + end end ---Emit stats for every currently-tracked interface. local function on_tick(emit_ch, id, backend, interfaces_set, connected) - for iface_name in pairs(interfaces_set) do - tick_iface(emit_ch, id, backend, iface_name, connected) - end + for iface_name in pairs(interfaces_set) do + tick_iface(emit_ch, id, backend, iface_name, connected) + end end ---Update connected-station bookkeeping and emit a client_event. local function on_client_event(emit_ch, id, connected, ev) - if not ev then return end - local mac = ev.mac - local iface = ev.interface + if not ev then return end + local mac = ev.mac + local iface = ev.interface - if not connected[iface] then connected[iface] = {} end + if not connected[iface] then connected[iface] = {} end - if ev.added then - connected[iface][mac] = true - else - connected[iface][mac] = nil - end + if ev.added then + connected[iface][mac] = true + else + connected[iface][mac] = nil + end - emit_event(emit_ch, id, 'client_event', { - mac = mac, - connected = ev.added, - interface = iface, - timestamp = os.time(), - }) + emit_event(emit_ch, id, 'client_event', { + mac = mac, + connected = ev.added, + interface = iface, + timestamp = os.time(), + }) end ------------------------------------------------------------------------ @@ -454,64 +454,64 @@ end ------------------------------------------------------------------------ function RadioDriver:stats_loop() - local emit_ch = self.cap_emit_ch - local id = self.id - local report_period = DEFAULT_REPORT_PERIOD - local backend = self.backend - local connected = {} - local interfaces_set = {} - - backend:start_client_monitor() - - fibers.current_scope():finally(function() - if backend and backend.terminate then - backend:terminate('radio stats loop stopped') - end - self.log:debug({ what = 'radio_stats_loop_stopped', id = id }) - end) - - local function update_interfaces(op, name) - if op == 'add' then - interfaces_set[name] = true - -- Seed connected set with any clients already associated before startup - local macs, _ = backend:get_connected_macs(name) - for _, mac in ipairs(macs) do - on_client_event(emit_ch, id, connected, { mac = mac, interface = name, added = true }) - end - elseif op == 'remove' then - interfaces_set[name] = nil - elseif op == 'reset' then - interfaces_set = {} - end - end - - while true do - local name, val = fibers.perform(fibers.named_choice({ - client_event = backend:watch_clients_op(), - iface_update = self.iface_update_ch:get_op(), - tick = sleep.sleep_op(report_period), - new_period = self.report_period_ch:get_op(), - cancel = fibers.current_scope():cancel_op(), - })) - - if name == 'cancel' then - break - elseif name == 'new_period' then - report_period = val - elseif name == 'iface_update' then - update_interfaces(val.op, val.name) - elseif name == 'client_event' then - if val and interfaces_set[val.interface] then - on_client_event(emit_ch, id, connected, val) - end - elseif name == 'tick' then - on_tick(emit_ch, id, backend, interfaces_set, connected) - end - end - - if backend and backend.stop_client_monitor_op then - fibers.perform(backend:stop_client_monitor_op(0.2)) - end + local emit_ch = self.cap_emit_ch + local id = self.id + local report_period = DEFAULT_REPORT_PERIOD + local backend = self.backend + local connected = {} + local interfaces_set = {} + + backend:start_client_monitor() + + fibers.current_scope():finally(function() + if backend and backend.terminate then + backend:terminate('radio stats loop stopped') + end + self.log:debug({ what = 'radio_stats_loop_stopped', id = id }) + end) + + local function update_interfaces(op, name) + if op == 'add' then + interfaces_set[name] = true + -- Seed connected set with any clients already associated before startup + local macs, _ = backend:get_connected_macs(name) + for _, mac in ipairs(macs) do + on_client_event(emit_ch, id, connected, { mac = mac, interface = name, added = true }) + end + elseif op == 'remove' then + interfaces_set[name] = nil + elseif op == 'reset' then + interfaces_set = {} + end + end + + while true do + local name, val = fibers.perform(fibers.named_choice({ + client_event = backend:watch_clients_op(), + iface_update = self.iface_update_ch:get_op(), + tick = sleep.sleep_op(report_period), + new_period = self.report_period_ch:get_op(), + cancel = fibers.current_scope():cancel_op(), + })) + + if name == 'cancel' then + break + elseif name == 'new_period' then + report_period = val + elseif name == 'iface_update' then + update_interfaces(val.op, val.name) + elseif name == 'client_event' then + if val and interfaces_set[val.interface] then + on_client_event(emit_ch, id, connected, val) + end + elseif name == 'tick' then + on_tick(emit_ch, id, backend, interfaces_set, connected) + end + end + + if backend and backend.stop_client_monitor_op then + fibers.perform(backend:stop_client_monitor_op(0.2)) + end end ------------------------------------------------------------------------ @@ -522,66 +522,66 @@ end ---@param rtype string driver type (e.g. "mac80211") ---@return string err empty string on success function RadioDriver:init(path, rtype) - if not path or path == '' then - return "init failed: path is required" - end - self.staged.path = path - self.staged.type = rtype or '' - self.initialised = true - return "" + if not path or path == '' then + return "init failed: path is required" + end + self.staged.path = path + self.staged.type = rtype or '' + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? caps ---@return string err function RadioDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "driver not initialised" - end - if self.caps_applied then - return nil, "capabilities already applied" - end - self.cap_emit_ch = emit_ch - - local cap, cap_err = cap_types.new.Capability( - 'radio', - self.id, - self.control_ch, - { - 'set_channels', - 'set_txpower', - 'set_country', - 'set_enabled', - 'add_interface', - 'delete_interface', - 'clear_radio_config', - 'set_report_period', - 'clear', - 'apply', - 'rollback', - } - ) - if not cap then - return nil, cap_err - end - - self.caps_applied = true - return { cap }, "" + if not self.initialised then + return nil, "driver not initialised" + end + if self.caps_applied then + return nil, "capabilities already applied" + end + self.cap_emit_ch = emit_ch + + local cap, cap_err = cap_types.new.Capability( + 'radio', + self.id, + self.control_ch, + { + 'set_channels', + 'set_txpower', + 'set_country', + 'set_enabled', + 'add_interface', + 'delete_interface', + 'clear_radio_config', + 'set_report_period', + 'clear', + 'apply', + 'rollback', + } + ) + if not cap then + return nil, cap_err + end + + self.caps_applied = true + return { cap }, "" end ---@return boolean ok ---@return string err function RadioDriver:start() - if not self.initialised then - return false, "driver not initialised" - end - if not self.caps_applied then - return false, "capabilities not applied" - end - - self.scope:spawn(function() self:control_manager() end) - self.scope:spawn(function() self:stats_loop() end) - return true, "" + if not self.initialised then + return false, "driver not initialised" + end + if not self.caps_applied then + return false, "capabilities not applied" + end + + self.scope:spawn(function() self:control_manager() end) + self.scope:spawn(function() self:stats_loop() end) + return true, "" end ---Create a new RadioDriver instance. @@ -590,45 +590,45 @@ end ---@return RadioDriver? driver ---@return string err local function new(name, logger) - if type(name) ~= 'string' or name == '' then - return nil, "invalid radio name" - end - - local bknd, berr = provider.new(name) - if not bknd then - return nil, "no radio backend: " .. tostring(berr) - end - - local scope, serr = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(serr) - end - - local driver = setmetatable({ - id = name, - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - iface_update_ch = channel.new(16), - report_period_ch = channel.new(1), - staged = { - name = name, - path = '', - type = '', - interfaces = {}, - deleted_interfaces = {}, - }, - iface_counter = 0, - initialised = false, - caps_applied = false, - log = logger, - backend = bknd, - }, RadioDriver) - - return driver, "" + if type(name) ~= 'string' or name == '' then + return nil, "invalid radio name" + end + + local bknd, berr = provider.new(name) + if not bknd then + return nil, "no radio backend: " .. tostring(berr) + end + + local scope, serr = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(serr) + end + + local driver = setmetatable({ + id = name, + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + iface_update_ch = channel.new(16), + report_period_ch = channel.new(1), + staged = { + name = name, + path = '', + type = '', + interfaces = {}, + deleted_interfaces = {}, + }, + iface_counter = 0, + initialised = false, + caps_applied = false, + log = logger, + backend = bknd, + }, RadioDriver) + + return driver, "" end return { - new = new, - Driver = RadioDriver, + new = new, + Driver = RadioDriver, } diff --git a/src/services/hal/drivers/thermal.lua b/src/services/hal/drivers/thermal.lua index 5d87d635..6a314e0e 100644 --- a/src/services/hal/drivers/thermal.lua +++ b/src/services/hal/drivers/thermal.lua @@ -22,9 +22,9 @@ local perform = fibers.perform local CONTROL_Q_LEN = 8 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class ThermalDriver @@ -45,16 +45,16 @@ ThermalDriver.__index = ThermalDriver ---@return string? content ---@return string error local function read_file(path) - local f, open_err = file.open(path, 'r') - if not f then - return nil, tostring(open_err) - end - local content, read_err = f:read_all() - f:close() - if not content then - return nil, tostring(read_err) - end - return content, "" + local f, open_err = file.open(path, 'r') + if not f then + return nil, tostring(open_err) + end + local content, read_err = f:read_all() + f:close() + if not content then + return nil, tostring(read_err) + end + return content, "" end --- Read the zone's type string (e.g. "cpu-thermal"). @@ -62,12 +62,12 @@ end ---@param logger Logger? ---@return string? zone_type local function read_zone_type(sysfs_dir, logger) - local raw, err = read_file(sysfs_dir .. '/type') - if not raw then - dlog(logger, 'debug', { what = 'zone_type_read_failed', err = tostring(err), path = sysfs_dir .. '/type' }) - return nil - end - return raw:match("^%s*(.-)%s*$") + local raw, err = read_file(sysfs_dir .. '/type') + if not raw then + dlog(logger, 'debug', { what = 'zone_type_read_failed', err = tostring(err), path = sysfs_dir .. '/type' }) + return nil + end + return raw:match("^%s*(.-)%s*$") end ---- capability verbs ---- @@ -76,135 +76,135 @@ end ---@return boolean ok ---@return any value_or_err function ThermalDriver:get(opts) - if opts == nil or getmetatable(opts) ~= cap_args.ThermalGetOpts then - return false, "invalid opts" - end - local max_age = opts.max_age - - local cached = self.cache:get('temp', max_age) - if cached ~= nil then - return true, cached - end - - local raw, err = read_file(self.sysfs_dir .. '/temp') - if not raw then - return false, "failed to read temperature: " .. err - end - - local millideg = tonumber(raw:match("%d+")) - if not millideg then - return false, "failed to parse temperature value" - end - - local temp_c = millideg / 1000 - self.cache:set('temp', temp_c) - return true, temp_c + if opts == nil or getmetatable(opts) ~= cap_args.ThermalGetOpts then + return false, "invalid opts" + end + local max_age = opts.max_age + + local cached = self.cache:get('temp', max_age) + if cached ~= nil then + return true, cached + end + + local raw, err = read_file(self.sysfs_dir .. '/temp') + if not raw then + return false, "failed to read temperature: " .. err + end + + local millideg = tonumber(raw:match("%d+")) + if not millideg then + return false, "failed to parse temperature value" + end + + local temp_c = millideg / 1000 + self.cache:set('temp', temp_c) + return true, temp_c end ---- control manager ---- function ThermalDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- public interface ---- ---@return string error function ThermalDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? ---@return string error function ThermalDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "thermal driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.ThermalCapability(self.zone_id, self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return nil, "thermal driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.ThermalCapability(self.zone_id, self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function ThermalDriver:start() - if not self.initialised then - return false, "thermal driver not initialised" - end - local meta_payload, emit_err = hal_types.new.Emit('thermal', self.zone_id, 'meta', 'info', { - provider = 'hal', - version = 1, - zone = self.zone_id, - path = self.sysfs_dir, - zone_type = self.zone_type, - }) - if meta_payload and self.cap_emit_ch then - self.cap_emit_ch:put(meta_payload) - elseif not meta_payload then - dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(emit_err) }) - end - - local ok, spawn_err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(spawn_err) - end - return true, "" + if not self.initialised then + return false, "thermal driver not initialised" + end + local meta_payload, emit_err = hal_types.new.Emit('thermal', self.zone_id, 'meta', 'info', { + provider = 'hal', + version = 1, + zone = self.zone_id, + path = self.sysfs_dir, + zone_type = self.zone_type, + }) + if meta_payload and self.cap_emit_ch then + self.cap_emit_ch:put(meta_payload) + elseif not meta_payload then + dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(emit_err) }) + end + + local ok, spawn_err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(spawn_err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function ThermalDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel(('thermal driver [%s] stopped'):format(self.zone_id)) - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, ("thermal driver [%s] stop timeout"):format(self.zone_id) - end - return true, "" + timeout = timeout or 5 + self.scope:cancel(('thermal driver [%s] stopped'):format(self.zone_id)) + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, ("thermal driver [%s] stop timeout"):format(self.zone_id) + end + return true, "" end ---@param zone_id string canonical zone id, e.g. "zone0" @@ -213,35 +213,35 @@ end ---@return ThermalDriver? ---@return string error local function new(zone_id, sysfs_dir, logger) - assert(type(zone_id) == 'string' and zone_id ~= '', "zone_id must be a non-empty string") - assert(type(sysfs_dir) == 'string' and sysfs_dir ~= '', "sysfs_dir must be a non-empty string") - - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - local zone_type = read_zone_type(sysfs_dir, logger) - - return setmetatable({ - zone_id = zone_id, - sysfs_dir = sysfs_dir, - zone_type = zone_type, - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - cache = cache_mod.new(), - logger = logger, - initialised = false, - }, ThermalDriver), "" + assert(type(zone_id) == 'string' and zone_id ~= '', "zone_id must be a non-empty string") + assert(type(sysfs_dir) == 'string' and sysfs_dir ~= '', "sysfs_dir must be a non-empty string") + + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + local zone_type = read_zone_type(sysfs_dir, logger) + + return setmetatable({ + zone_id = zone_id, + sysfs_dir = sysfs_dir, + zone_type = zone_type, + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + cache = cache_mod.new(), + logger = logger, + initialised = false, + }, ThermalDriver), "" end return { new = new } diff --git a/src/services/hal/drivers/time.lua b/src/services/hal/drivers/time.lua index 39238818..6ec1d741 100644 --- a/src/services/hal/drivers/time.lua +++ b/src/services/hal/drivers/time.lua @@ -39,9 +39,9 @@ local CONTROL_Q_LEN = 4 ---@param level string ---@param payload any local function dlog(self, level, payload) - if self.logger and self.logger[level] then - self.logger[level](self.logger, payload) - end + if self.logger and self.logger[level] then + self.logger[level](self.logger, payload) + end end ---Emit a capability state, meta, or event via the cap emit channel. @@ -53,12 +53,12 @@ end ---@return boolean ok ---@return string? error local function emit(emit_ch, id, mode, key, data) - local payload, err = hal_types.new.Emit('time', id, mode, key, data) - if not payload then - return false, err - end - emit_ch:put(payload) - return true + local payload, err = hal_types.new.Emit('time', id, mode, key, data) + if not payload then + return false, err + end + emit_ch:put(payload) + return true end ---Convert NTP stratum to an estimated absolute accuracy in seconds. @@ -66,36 +66,36 @@ end ---@param stratum number ---@return number? accuracy_seconds local function accuracy_for_stratum(stratum) - if type(stratum) ~= 'number' then - return nil - end - if stratum >= 16 then - return nil - end - - -- Coarse operational heuristic: - -- lower stratum generally implies lower clock error. - if stratum <= 1 then - return 0.001 - elseif stratum <= 4 then - return 0.01 - elseif stratum <= 8 then - return 0.1 - else - return 1.0 - end + if type(stratum) ~= 'number' then + return nil + end + if stratum >= 16 then + return nil + end + + -- Coarse operational heuristic: + -- lower stratum generally implies lower clock error. + if stratum <= 1 then + return 0.001 + elseif stratum <= 4 then + return 0.01 + elseif stratum <= 8 then + return 0.1 + else + return 1.0 + end end ---Build a meta payload table for this time source. ---@param accuracy_seconds number? ---@return table local function build_meta(accuracy_seconds) - return { - provider = 'hal', - source = 'ntp', - version = 1, - accuracy_seconds = accuracy_seconds, - } + return { + provider = 'hal', + source = 'ntp', + version = 1, + accuracy_seconds = accuracy_seconds, + } end ---- Monitor Fiber ---- @@ -106,98 +106,98 @@ end ---sync state is always published as a retained state emit on every hotplug event. ---@return nil function TimeDriver:_ntpd_monitor() - fibers.current_scope():finally(function() - if self.backend and self.backend.terminate then - self.backend:terminate('time monitor stopped') - end - dlog(self, 'debug', { what = 'ntpd_monitor_exit' }) - end) - - -- Start the backend monitor here so the ubus command is bound to this fiber's - -- scope (the driver child scope). Cancelling the driver scope will then kill - -- the underlying process automatically. - local ok, start_err = self.backend:start_ntp_monitor() - if not ok then - dlog(self, 'error', { what = 'ntp_backend_start_failed', err = tostring(start_err) }) - return - end - - dlog(self, 'debug', { what = 'ntpd_monitor_started' }) - - while true do - local ntp_event, err = fibers.perform(self.backend:ntp_event_op()) - if err ~= nil then - -- Fatal: stream closed or read error - dlog(self, 'warn', { what = 'ntp_event_stream_closed', err = tostring(err) }) - break - end - if ntp_event then - local stratum = ntp_event.stratum - -- NTPEvent constructor guarantees stratum is a number, but guard anyway - if type(stratum) ~= 'number' then - dlog(self, 'warn', { what = 'ntp_event_invalid_stratum', stratum = tostring(stratum) }) - else - local now_synced = stratum ~= 16 - local was_synced = self.synced - local accuracy_seconds = accuracy_for_stratum(stratum) - - -- Always update retained state, even if sync status did not change, - -- so that the latest stratum value is always visible to subscribers. - local emit_ok, emit_err = emit( - self.cap_emit_ch, self.id, 'state', 'synced', - { - synced = now_synced, - stratum = stratum, - accuracy_seconds = accuracy_seconds, - } - ) - if not emit_ok then - dlog(self, 'warn', { what = 'emit_state_failed', err = tostring(emit_err) }) - end - - -- Update accuracy metadata only on a sync/unsync transition. - if now_synced ~= was_synced then - local meta_ok, meta_err = emit( - self.cap_emit_ch, self.id, 'meta', 'source', - build_meta(accuracy_seconds) - ) - if not meta_ok then - dlog(self, 'warn', { what = 'emit_meta_failed', err = tostring(meta_err) }) - end - end - - -- Emit non-retained transition events. - if now_synced and not was_synced then - dlog(self, 'debug', { what = 'ntp_synced', stratum = stratum }) - local ev_ok, ev_err = emit( - self.cap_emit_ch, self.id, 'event', 'synced', - { - stratum = stratum, - accuracy_seconds = accuracy_seconds, - } - ) - if not ev_ok then - dlog(self, 'warn', { what = 'emit_synced_event_failed', err = tostring(ev_err) }) - end - elseif not now_synced and was_synced then - dlog(self, 'debug', { what = 'ntp_unsynced', stratum = stratum }) - local ev_ok, ev_err = emit( - self.cap_emit_ch, self.id, 'event', 'unsynced', - { - stratum = stratum, - accuracy_seconds = accuracy_seconds, - } - ) - if not ev_ok then - dlog(self, 'warn', { what = 'emit_unsynced_event_failed', err = tostring(ev_err) }) - end - end - - self.synced = now_synced - end - end - -- ntp_event == nil and err == nil cannot occur: all parse failures are fatal - end + fibers.current_scope():finally(function() + if self.backend and self.backend.terminate then + self.backend:terminate('time monitor stopped') + end + dlog(self, 'debug', { what = 'ntpd_monitor_exit' }) + end) + + -- Start the backend monitor here so the ubus command is bound to this fiber's + -- scope (the driver child scope). Cancelling the driver scope will then kill + -- the underlying process automatically. + local ok, start_err = self.backend:start_ntp_monitor() + if not ok then + dlog(self, 'error', { what = 'ntp_backend_start_failed', err = tostring(start_err) }) + return + end + + dlog(self, 'debug', { what = 'ntpd_monitor_started' }) + + while true do + local ntp_event, err = fibers.perform(self.backend:ntp_event_op()) + if err ~= nil then + -- Fatal: stream closed or read error + dlog(self, 'warn', { what = 'ntp_event_stream_closed', err = tostring(err) }) + break + end + if ntp_event then + local stratum = ntp_event.stratum + -- NTPEvent constructor guarantees stratum is a number, but guard anyway + if type(stratum) ~= 'number' then + dlog(self, 'warn', { what = 'ntp_event_invalid_stratum', stratum = tostring(stratum) }) + else + local now_synced = stratum ~= 16 + local was_synced = self.synced + local accuracy_seconds = accuracy_for_stratum(stratum) + + -- Always update retained state, even if sync status did not change, + -- so that the latest stratum value is always visible to subscribers. + local emit_ok, emit_err = emit( + self.cap_emit_ch, self.id, 'state', 'synced', + { + synced = now_synced, + stratum = stratum, + accuracy_seconds = accuracy_seconds, + } + ) + if not emit_ok then + dlog(self, 'warn', { what = 'emit_state_failed', err = tostring(emit_err) }) + end + + -- Update accuracy metadata only on a sync/unsync transition. + if now_synced ~= was_synced then + local meta_ok, meta_err = emit( + self.cap_emit_ch, self.id, 'meta', 'source', + build_meta(accuracy_seconds) + ) + if not meta_ok then + dlog(self, 'warn', { what = 'emit_meta_failed', err = tostring(meta_err) }) + end + end + + -- Emit non-retained transition events. + if now_synced and not was_synced then + dlog(self, 'debug', { what = 'ntp_synced', stratum = stratum }) + local ev_ok, ev_err = emit( + self.cap_emit_ch, self.id, 'event', 'synced', + { + stratum = stratum, + accuracy_seconds = accuracy_seconds, + } + ) + if not ev_ok then + dlog(self, 'warn', { what = 'emit_synced_event_failed', err = tostring(ev_err) }) + end + elseif not now_synced and was_synced then + dlog(self, 'debug', { what = 'ntp_unsynced', stratum = stratum }) + local ev_ok, ev_err = emit( + self.cap_emit_ch, self.id, 'event', 'unsynced', + { + stratum = stratum, + accuracy_seconds = accuracy_seconds, + } + ) + if not ev_ok then + dlog(self, 'warn', { what = 'emit_unsynced_event_failed', err = tostring(ev_err) }) + end + end + + self.synced = now_synced + end + end + -- ntp_event == nil and err == nil cannot occur: all parse failures are fatal + end end ---- Driver Lifecycle ---- @@ -206,18 +206,18 @@ end ---Must be called from inside a fiber. ---@return string error Empty string on success. function TimeDriver:init() - dlog(self, 'debug', { what = 'init_begin' }) - - local status, code, _, err = fibers.perform( - (exec.command { "/etc/init.d/sysntpd", "restart", stdin = "null", stdout = "null", stderr = "null" }):run_op() - ) - if status ~= 'exited' or code ~= 0 then - return "sysntpd restart failed: " .. tostring(err or ("exit code " .. tostring(code))) - end - - self.initialised = true - dlog(self, 'debug', { what = 'init_done' }) - return "" + dlog(self, 'debug', { what = 'init_begin' }) + + local status, code, _, err = fibers.perform( + (exec.command { "/etc/init.d/sysntpd", "restart", stdin = "null", stdout = "null", stderr = "null" }):run_op() + ) + if status ~= 'exited' or code ~= 0 then + return "sysntpd restart failed: " .. tostring(err or ("exit code " .. tostring(code))) + end + + self.initialised = true + dlog(self, 'debug', { what = 'init_done' }) + return "" end ---Connect the driver to the capability emit channel and return the capability list. @@ -226,19 +226,19 @@ end ---@return Capability[]? capabilities ---@return string error Empty string on success. function TimeDriver:capabilities(cap_emit_ch) - if not self.initialised then - return nil, "driver not initialised" - end + if not self.initialised then + return nil, "driver not initialised" + end - self.cap_emit_ch = cap_emit_ch + self.cap_emit_ch = cap_emit_ch - local cap, cap_err = cap_types.new.TimeCapability(self.id, self.control_ch) - if not cap then - return nil, "failed to create time capability: " .. tostring(cap_err) - end + local cap, cap_err = cap_types.new.TimeCapability(self.id, self.control_ch) + if not cap then + return nil, "failed to create time capability: " .. tostring(cap_err) + end - self.caps_applied = true - return { cap }, "" + self.caps_applied = true + return { cap }, "" end ---Start the time driver. Emits initial meta and state, then spawns the NTP monitor @@ -246,35 +246,35 @@ end ---@return boolean ok ---@return string? error function TimeDriver:start() - if not self.initialised then - return false, "driver not initialised" - end - if not self.caps_applied then - return false, "capabilities not applied" - end - - -- Publish initial meta (accuracy unknown until first NTP update). - local meta_ok, meta_err = emit( - self.cap_emit_ch, self.id, 'meta', 'source', - build_meta(nil) - ) - if not meta_ok then - dlog(self, 'warn', { what = 'emit_initial_meta_failed', err = tostring(meta_err) }) - end - - -- Publish initial retained state: not yet synced, stratum unknown. - local state_ok, state_err = emit( - self.cap_emit_ch, self.id, 'state', 'synced', - { synced = false, stratum = nil } - ) - if not state_ok then - dlog(self, 'warn', { what = 'emit_initial_state_failed', err = tostring(state_err) }) - end - - self.scope:spawn(function() self:_ntpd_monitor() end) - - dlog(self, 'debug', { what = 'started' }) - return true, nil + if not self.initialised then + return false, "driver not initialised" + end + if not self.caps_applied then + return false, "capabilities not applied" + end + + -- Publish initial meta (accuracy unknown until first NTP update). + local meta_ok, meta_err = emit( + self.cap_emit_ch, self.id, 'meta', 'source', + build_meta(nil) + ) + if not meta_ok then + dlog(self, 'warn', { what = 'emit_initial_meta_failed', err = tostring(meta_err) }) + end + + -- Publish initial retained state: not yet synced, stratum unknown. + local state_ok, state_err = emit( + self.cap_emit_ch, self.id, 'state', 'synced', + { synced = false, stratum = nil } + ) + if not state_ok then + dlog(self, 'warn', { what = 'emit_initial_state_failed', err = tostring(state_err) }) + end + + self.scope:spawn(function() self:_ntpd_monitor() end) + + dlog(self, 'debug', { what = 'started' }) + return true, nil end ---Stop the time driver. Cancels the driver scope, terminating the NTP monitor fiber @@ -283,25 +283,25 @@ end ---@return boolean ok ---@return string? error function TimeDriver:stop(timeout) - timeout = timeout or DEFAULT_STOP_TIMEOUT - if self.backend and self.backend.shutdown_op then - local ok, stop_err = fibers.perform(self.backend:shutdown_op(0.2)) - if not ok then - dlog(self, 'warn', { what = 'ntp_backend_stop_failed', err = tostring(stop_err) }) - end - end - - self.scope:cancel() - - local source = fibers.perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - - if source == 'timeout' then - return false, "time driver stop timeout" - end - return true, nil + timeout = timeout or DEFAULT_STOP_TIMEOUT + if self.backend and self.backend.shutdown_op then + local ok, stop_err = fibers.perform(self.backend:shutdown_op(0.2)) + if not ok then + dlog(self, 'warn', { what = 'ntp_backend_stop_failed', err = tostring(stop_err) }) + end + end + + self.scope:cancel() + + local source = fibers.perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + + if source == 'timeout' then + return false, "time driver stop timeout" + end + return true, nil end ---- Constructor ---- @@ -312,26 +312,26 @@ end ---@return TimeDriver? driver ---@return string error Empty string on success. local function new(logger) - local scope, sc_err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(sc_err) - end - - local backend = time_backend_provider.new() - - return setmetatable({ - id = uuid.new(), - cap_emit_ch = nil, - scope = scope, - backend = backend, - control_ch = channel.new(CONTROL_Q_LEN), - logger = logger, - initialised = false, - caps_applied = false, - synced = false, - }, TimeDriver), "" + local scope, sc_err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(sc_err) + end + + local backend = time_backend_provider.new() + + return setmetatable({ + id = uuid.new(), + cap_emit_ch = nil, + scope = scope, + backend = backend, + control_ch = channel.new(CONTROL_Q_LEN), + logger = logger, + initialised = false, + caps_applied = false, + synced = false, + }, TimeDriver), "" end return { - new = new, + new = new, } diff --git a/src/services/hal/drivers/uart.lua b/src/services/hal/drivers/uart.lua index 4375540a..c74f8b1d 100644 --- a/src/services/hal/drivers/uart.lua +++ b/src/services/hal/drivers/uart.lua @@ -53,699 +53,699 @@ local Driver = {} Driver.__index = Driver local function dlog(self, level, payload) - if self.logger and self.logger[level] then - self.logger[level](self.logger, payload) - end + if self.logger and self.logger[level] then + self.logger[level](self.logger, payload) + end end local function finalise_shell_scope(self, shell_scope, status, primary) - if self.scope ~= shell_scope then - return - end - - local session = self.active_session - if session then - resource.terminate_checked( - session, - primary or status or 'uart shell closed', - 'UART session cleanup failed' - ) - end - - self.started = false - self.scope = nil - self.active_session = nil - self.active_lease_id = nil - self.closing_lease_id = nil + if self.scope ~= shell_scope then + return + end + + local session = self.active_session + if session then + resource.terminate_checked( + session, + primary or status or 'uart shell closed', + 'UART session cleanup failed' + ) + end + + self.started = false + self.scope = nil + self.active_session = nil + self.active_lease_id = nil + self.closing_lease_id = nil end local function emit_op(emit_ch, class, id, mode, key, data) - return op.guard(function () - local payload, err = hal_types.new.Emit(class, id, mode, key, data) - if not payload then - return op.always(false, tostring(err)) - end + return op.guard(function () + local payload, err = hal_types.new.Emit(class, id, mode, key, data) + if not payload then + return op.always(false, tostring(err)) + end - return emit_ch:put_op(payload):wrap(function () - return true, nil - end) - end) + return emit_ch:put_op(payload):wrap(function () + return true, nil + end) + end) end local function status_payload(self) - return { - state = 'available', - available = true, - open = self.active_session ~= nil or self.closing_lease_id ~= nil, - lease_id = self.active_lease_id or self.closing_lease_id, - path = self.path, - baud = self.default_baud, - mode = self.default_mode, - config_source = 'devicetree', - termios_ok = self.termios_ok == true, - termios_error = self.termios_error, - } + return { + state = 'available', + available = true, + open = self.active_session ~= nil or self.closing_lease_id ~= nil, + lease_id = self.active_lease_id or self.closing_lease_id, + path = self.path, + baud = self.default_baud, + mode = self.default_mode, + config_source = 'devicetree', + termios_ok = self.termios_ok == true, + termios_error = self.termios_error, + } end local function meta_payload(self) - return { - kind = 'uart', - path = self.path, - baud = self.default_baud, - mode = self.default_mode, - config_source = 'devicetree', - termios = { - configured = self.termios_ok == true, - error = self.termios_error, - }, - } + return { + kind = 'uart', + path = self.path, + baud = self.default_baud, + mode = self.default_mode, + config_source = 'devicetree', + termios = { + configured = self.termios_ok == true, + error = self.termios_error, + }, + } end local function reply_request_op(reply_ch, ok, value_or_err) - return op.guard(function () - local reply, err = hal_types.new.Reply(ok, value_or_err) - if not reply then - return op.always(false, 'invalid reply: ' .. tostring(err)) - end - - return reply_ch:put_op(reply):wrap(function (sent, send_err) - if sent == true then - return true, nil - end - if sent == nil then - return false, tostring(send_err or 'reply channel closed') - end - return false, tostring(send_err or 'reply delivery failed') - end) - end) + return op.guard(function () + local reply, err = hal_types.new.Reply(ok, value_or_err) + if not reply then + return op.always(false, 'invalid reply: ' .. tostring(err)) + end + + return reply_ch:put_op(reply):wrap(function (sent, send_err) + if sent == true then + return true, nil + end + if sent == nil then + return false, tostring(send_err or 'reply channel closed') + end + return false, tostring(send_err or 'reply delivery failed') + end) + end) end local function release_session_now(driver, lease_id, _reason) - if driver.active_lease_id ~= lease_id and driver.closing_lease_id ~= lease_id then - return true, nil - end + if driver.active_lease_id ~= lease_id and driver.closing_lease_id ~= lease_id then + return true, nil + end - driver.active_session = nil - driver.active_lease_id = nil - if driver.closing_lease_id == lease_id then - driver.closing_lease_id = nil - end - return true, nil + driver.active_session = nil + driver.active_lease_id = nil + if driver.closing_lease_id == lease_id then + driver.closing_lease_id = nil + end + return true, nil end local function session_release_lease_now(session, reason) - if session.lease_released then - return true, nil - end + if session.lease_released then + return true, nil + end - session.lease_released = true + session.lease_released = true - if type(session.release_lease_now) ~= 'function' then - return true, nil - end + if type(session.release_lease_now) ~= 'function' then + return true, nil + end - return session.release_lease_now(session.lease_id, reason) + return session.release_lease_now(session.lease_id, reason) end local function new_session(lease_id, stream, release_lease_now, release_lease_op) - return setmetatable({ - lease_id = lease_id, - stream = stream, - release_lease_now = release_lease_now, - release_lease_op = release_lease_op, - closed = false, - lease_released = false, - }, UARTSession) + return setmetatable({ + lease_id = lease_id, + stream = stream, + release_lease_now = release_lease_now, + release_lease_op = release_lease_op, + closed = false, + lease_released = false, + }, UARTSession) end function UARTSession:read_some_op(max) - return op.guard(function () - if self.closed then - return op.always(nil, 'uart session closed') - end - return self.stream:read_some_op(max) - end) + return op.guard(function () + if self.closed then + return op.always(nil, 'uart session closed') + end + return self.stream:read_some_op(max) + end) end function UARTSession:read_exactly_op(n) - return op.guard(function () - if self.closed then - return op.always(nil, 'uart session closed') - end - return self.stream:read_exactly_op(n) - end) + return op.guard(function () + if self.closed then + return op.always(nil, 'uart session closed') + end + return self.stream:read_exactly_op(n) + end) end function UARTSession:read_line_op(opts) - return op.guard(function () - if self.closed then - return op.always(nil, 'uart session closed') - end - return self.stream:read_line_op(opts) - end) + return op.guard(function () + if self.closed then + return op.always(nil, 'uart session closed') + end + return self.stream:read_line_op(opts) + end) end function UARTSession:read_all_op() - return op.guard(function () - if self.closed then - return op.always('', 'uart session closed') - end - return self.stream:read_all_op() - end) + return op.guard(function () + if self.closed then + return op.always('', 'uart session closed') + end + return self.stream:read_all_op() + end) end function UARTSession:write_op(...) - local parts = { ... } - return op.guard(function () - if self.closed then - return op.always(nil, 'uart session closed') - end - return self.stream:write_op(unpack(parts)) - end) + local parts = { ... } + return op.guard(function () + if self.closed then + return op.always(nil, 'uart session closed') + end + return self.stream:write_op(unpack(parts)) + end) end function UARTSession:flush_op() - return op.guard(function () - if self.closed then - return op.always(nil, 'uart session closed') - end - return self.stream:flush_op() - end) + return op.guard(function () + if self.closed then + return op.always(nil, 'uart session closed') + end + return self.stream:flush_op() + end) end -- Gracefully close the underlying stream and release the active driver lease. function UARTSession:close_op() - return fibers.run_scope_op(function () - if self.closed then - return session_release_lease_now(self, 'uart session already closed') - end - - local stream = self.stream - local ok, err = fibers.perform(stream:close_op()) - if ok == nil then - return false, tostring(err) - end - - self.stream = nil - self.closed = true - - local ok_release, release_err - if type(self.release_lease_op) == 'function' then - ok_release, release_err = fibers.perform(self.release_lease_op(self.lease_id, 'uart session closed')) - else - ok_release, release_err = session_release_lease_now(self, 'uart session closed') - end - if ok_release ~= true then - return false, tostring(release_err or 'uart session lease release failed') - end - self.lease_released = true - - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + if self.closed then + return session_release_lease_now(self, 'uart session already closed') + end + + local stream = self.stream + local ok, err = fibers.perform(stream:close_op()) + if ok == nil then + return false, tostring(err) + end + + self.stream = nil + self.closed = true + + local ok_release, release_err + if type(self.release_lease_op) == 'function' then + ok_release, release_err = fibers.perform(self.release_lease_op(self.lease_id, 'uart session closed')) + else + ok_release, release_err = session_release_lease_now(self, 'uart session closed') + end + if ok_release ~= true then + return false, tostring(release_err or 'uart session lease release failed') + end + self.lease_released = true + + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end function UARTSession:terminate(reason) - local why = reason or 'uart session terminated' - local first_err + local why = reason or 'uart session terminated' + local first_err - if self.closed and self.lease_released then - return true, nil - end + if self.closed and self.lease_released then + return true, nil + end - self.closed = true + self.closed = true - local stream = self.stream - self.stream = nil + local stream = self.stream + self.stream = nil - local ok_stream, stream_err = resource.terminate(stream, why) - if ok_stream ~= true and first_err == nil then - first_err = stream_err or 'uart stream termination failed' - end + local ok_stream, stream_err = resource.terminate(stream, why) + if ok_stream ~= true and first_err == nil then + first_err = stream_err or 'uart stream termination failed' + end - local ok_release, release_err = session_release_lease_now(self, why) - if ok_release ~= true and first_err == nil then - first_err = release_err or 'uart session lease release failed' - end + local ok_release, release_err = session_release_lease_now(self, why) + if ok_release ~= true and first_err == nil then + first_err = release_err or 'uart session lease release failed' + end - if first_err then - return nil, first_err - end + if first_err then + return nil, first_err + end - return true, nil + return true, nil end local function mode_stty_args(mode) - mode = mode or '8N1' - if mode == '8N1' then - return { 'cs8', '-cstopb', '-parenb' } - elseif mode == '7E1' then - return { 'cs7', '-cstopb', 'parenb', '-parodd' } - elseif mode == '8O1' then - return { 'cs8', '-cstopb', 'parenb', 'parodd' } - end - return nil, 'unsupported uart mode: ' .. tostring(mode) + mode = mode or '8N1' + if mode == '8N1' then + return { 'cs8', '-cstopb', '-parenb' } + elseif mode == '7E1' then + return { 'cs7', '-cstopb', 'parenb', '-parodd' } + elseif mode == '8O1' then + return { 'cs8', '-cstopb', 'parenb', 'parodd' } + end + return nil, 'unsupported uart mode: ' .. tostring(mode) end local function stty_args_for(self) - local baud = self.default_baud or 115200 - local mode_args, merr = mode_stty_args(self.default_mode) - if not mode_args then - return nil, merr - end - - local args = { - 'stty', '-F', tostring(self.path), tostring(baud), - } - for _, a in ipairs(mode_args) do args[#args + 1] = a end - for _, a in ipairs({ - '-crtscts', - '-ixon', '-ixoff', - '-icrnl', - '-icanon', '-echo', '-isig', '-iexten', - '-opost', '-onlcr', - 'min', '1', 'time', '0', - 'clocal', 'cread', - }) do - args[#args + 1] = a - end - return args, nil + local baud = self.default_baud or 115200 + local mode_args, merr = mode_stty_args(self.default_mode) + if not mode_args then + return nil, merr + end + + local args = { + 'stty', '-F', tostring(self.path), tostring(baud), + } + for _, a in ipairs(mode_args) do args[#args + 1] = a end + for _, a in ipairs({ + '-crtscts', + '-ixon', '-ixoff', + '-icrnl', + '-icanon', '-echo', '-isig', '-iexten', + '-opost', '-onlcr', + 'min', '1', 'time', '0', + 'clocal', 'cread', + }) do + args[#args + 1] = a + end + return args, nil end local function configure_termios_op(self, why) - return fibers.run_scope_op(function () - local args, aerr = stty_args_for(self) - if not args then - self.termios_ok = false - self.termios_error = tostring(aerr) - return false, self.termios_error - end - - local spec = { stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - for i = 1, #args do spec[i] = args[i] end - local cmd = exec.command(spec) - local output, status, code, sig, err = fibers.perform(cmd:combined_output_op()) - if status == 'exited' and code == 0 then - self.termios_ok = true - self.termios_error = nil - dlog(self, 'debug', { - what = 'uart_termios_configured', - why = why, - path = self.path, - baud = self.default_baud or 115200, - mode = self.default_mode or '8N1', - }) - return true, nil - end - - local detail = tostring(err or output or ('status=' .. tostring(status))) - if status == 'exited' then - detail = detail .. ' (exit ' .. tostring(code) .. ')' - elseif status == 'signalled' then - detail = detail .. ' (signal ' .. tostring(sig) .. ')' - end - self.termios_ok = false - self.termios_error = detail - return false, 'uart termios stty failed for ' .. tostring(self.path) .. ': ' .. detail - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - self.termios_ok = false - self.termios_error = tostring(err or rep) - return false, self.termios_error - end - return ok, err - end) + return fibers.run_scope_op(function () + local args, aerr = stty_args_for(self) + if not args then + self.termios_ok = false + self.termios_error = tostring(aerr) + return false, self.termios_error + end + + local spec = { stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + for i = 1, #args do spec[i] = args[i] end + local cmd = exec.command(spec) + local output, status, code, sig, err = fibers.perform(cmd:combined_output_op()) + if status == 'exited' and code == 0 then + self.termios_ok = true + self.termios_error = nil + dlog(self, 'debug', { + what = 'uart_termios_configured', + why = why, + path = self.path, + baud = self.default_baud or 115200, + mode = self.default_mode or '8N1', + }) + return true, nil + end + + local detail = tostring(err or output or ('status=' .. tostring(status))) + if status == 'exited' then + detail = detail .. ' (exit ' .. tostring(code) .. ')' + elseif status == 'signalled' then + detail = detail .. ' (signal ' .. tostring(sig) .. ')' + end + self.termios_ok = false + self.termios_error = detail + return false, 'uart termios stty failed for ' .. tostring(self.path) .. ': ' .. detail + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + self.termios_ok = false + self.termios_error = tostring(err or rep) + return false, self.termios_error + end + return ok, err + end) end local function open_stream_op(path) - return fibers.run_scope_op(function () - -- Box the currently synchronous file.open(...) impurity inside one - -- operation-owned subtree. Surface remains op-native. - local stream, err = file.open(path, 'r+') - if not stream then - return false, tostring(err) - end - return true, stream - end):wrap(function (st, rep, ok, value_or_err) - if st ~= 'ok' then - return false, tostring(value_or_err or rep) - end - return ok, value_or_err - end) + return fibers.run_scope_op(function () + -- Box the currently synchronous file.open(...) impurity inside one + -- operation-owned subtree. Surface remains op-native. + local stream, err = file.open(path, 'r+') + if not stream then + return false, tostring(err) + end + return true, stream + end):wrap(function (st, rep, ok, value_or_err) + if st ~= 'ok' then + return false, tostring(value_or_err or rep) + end + return ok, value_or_err + end) end local release_session_gracefully_op local function open_session_op(self) - return fibers.run_scope_op(function (scope) - local ok_cfg, cfg_err = fibers.perform(configure_termios_op(self, 'open')) - if ok_cfg ~= true then - return false, tostring(cfg_err) - end - - local ok, stream_or_err = fibers.perform(open_stream_op(self.path)) - if not ok then - return false, stream_or_err - end - - local stream = stream_or_err - local handed_off = false - scope:finally(function (_, status, primary) - if not handed_off then - resource.terminate_checked(stream, primary or status or 'uart open failed', 'uart open stream cleanup failed') - end - end) - - local lease_id = uuid.new() - local session = new_session( - lease_id, - stream, - function (active_lease_id, reason) - return release_session_now(self, active_lease_id, reason) - end, - function (active_lease_id, _reason) - return release_session_gracefully_op(self, active_lease_id, true) - end - ) - - local reply, rerr = hal_types.new.UARTOpenReply( - lease_id, - session, - self.path, - self.default_baud, - self.default_mode - ) - if not reply then - return false, tostring(rerr) - end - - handed_off = true - return true, reply - end):wrap(function (st, rep, ok, value_or_err) - if st ~= 'ok' then - return false, tostring(value_or_err or rep) - end - return ok, value_or_err - end) + return fibers.run_scope_op(function (scope) + local ok_cfg, cfg_err = fibers.perform(configure_termios_op(self, 'open')) + if ok_cfg ~= true then + return false, tostring(cfg_err) + end + + local ok, stream_or_err = fibers.perform(open_stream_op(self.path)) + if not ok then + return false, stream_or_err + end + + local stream = stream_or_err + local handed_off = false + scope:finally(function (_, status, primary) + if not handed_off then + resource.terminate_checked(stream, primary or status or 'uart open failed', 'uart open stream cleanup failed') + end + end) + + local lease_id = uuid.new() + local session = new_session( + lease_id, + stream, + function (active_lease_id, reason) + return release_session_now(self, active_lease_id, reason) + end, + function (active_lease_id, _reason) + return release_session_gracefully_op(self, active_lease_id, true) + end + ) + + local reply, rerr = hal_types.new.UARTOpenReply( + lease_id, + session, + self.path, + self.default_baud, + self.default_mode + ) + if not reply then + return false, tostring(rerr) + end + + handed_off = true + return true, reply + end):wrap(function (st, rep, ok, value_or_err) + if st ~= 'ok' then + return false, tostring(value_or_err or rep) + end + return ok, value_or_err + end) end local function publish_status_op(self) - return emit_op(self.emit_ch, 'uart', self.id, 'state', 'status', status_payload(self)) + return emit_op(self.emit_ch, 'uart', self.id, 'state', 'status', status_payload(self)) end local function publish_event_op(self, event_name, data) - return emit_op(self.emit_ch, 'uart', self.id, 'event', event_name, data) + return emit_op(self.emit_ch, 'uart', self.id, 'event', event_name, data) end function release_session_gracefully_op(self, lease_id, emit_closed_event) - return fibers.run_scope_op(function () - if self.active_lease_id ~= lease_id then - return true, nil - end - - self.active_session = nil - self.active_lease_id = nil - if self.closing_lease_id == lease_id then - self.closing_lease_id = nil - end - - local ok_status, status_err = fibers.perform(publish_status_op(self)) - if not ok_status then - return false, tostring(status_err) - end - - if emit_closed_event then - local ok_event, event_err = fibers.perform(publish_event_op(self, 'closed', { - lease_id = lease_id, - path = self.path, - })) - if not ok_event then - return false, tostring(event_err) - end - end - - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + if self.active_lease_id ~= lease_id then + return true, nil + end + + self.active_session = nil + self.active_lease_id = nil + if self.closing_lease_id == lease_id then + self.closing_lease_id = nil + end + + local ok_status, status_err = fibers.perform(publish_status_op(self)) + if not ok_status then + return false, tostring(status_err) + end + + if emit_closed_event then + local ok_event, event_err = fibers.perform(publish_event_op(self, 'closed', { + lease_id = lease_id, + path = self.path, + })) + if not ok_event then + return false, tostring(event_err) + end + end + + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function methods_for(self) - return { - status = function (_opts, _request) - return op.always(true, status_payload(self)) - end, - - open = function (opts, _request) - if opts ~= nil and (type(opts) ~= 'table' or getmetatable(opts) ~= cap_args.UARTOpenOpts) then - return op.always(false, 'invalid open opts') - end - - if self.active_session ~= nil or self.closing_lease_id ~= nil then - return op.always(false, 'busy') - end - - return fibers.run_scope_op(function (scope) - local ok, reply_or_err = fibers.perform(open_session_op(self)) - if not ok then - return false, reply_or_err - end - - local reply = reply_or_err - local handed_off = false - - scope:finally(function () - if not handed_off and self.active_session == reply.session then - resource.terminate_checked(reply.session, 'uart open abandoned', 'UART open session cleanup failed') - self.active_session = nil - self.active_lease_id = nil - if self.closing_lease_id == reply.lease_id then - self.closing_lease_id = nil - end - end - end) - - self.active_session = reply.session - self.active_lease_id = reply.lease_id - - local ok_status, status_err = fibers.perform(publish_status_op(self)) - if not ok_status then - return false, tostring(status_err) - end - - local ok_event, event_err = fibers.perform(publish_event_op(self, 'opened', { - lease_id = reply.lease_id, - path = self.path, - })) - if not ok_event then - return false, tostring(event_err) - end - - handed_off = true - return true, reply - end):wrap(function (st, rep, ok, value_or_err) - if st ~= 'ok' then - return false, tostring(value_or_err or rep) - end - return ok, value_or_err - end) - end, - } + return { + status = function (_opts, _request) + return op.always(true, status_payload(self)) + end, + + open = function (opts, _request) + if opts ~= nil and (type(opts) ~= 'table' or getmetatable(opts) ~= cap_args.UARTOpenOpts) then + return op.always(false, 'invalid open opts') + end + + if self.active_session ~= nil or self.closing_lease_id ~= nil then + return op.always(false, 'busy') + end + + return fibers.run_scope_op(function (scope) + local ok, reply_or_err = fibers.perform(open_session_op(self)) + if not ok then + return false, reply_or_err + end + + local reply = reply_or_err + local handed_off = false + + scope:finally(function () + if not handed_off and self.active_session == reply.session then + resource.terminate_checked(reply.session, 'uart open abandoned', 'UART open session cleanup failed') + self.active_session = nil + self.active_lease_id = nil + if self.closing_lease_id == reply.lease_id then + self.closing_lease_id = nil + end + end + end) + + self.active_session = reply.session + self.active_lease_id = reply.lease_id + + local ok_status, status_err = fibers.perform(publish_status_op(self)) + if not ok_status then + return false, tostring(status_err) + end + + local ok_event, event_err = fibers.perform(publish_event_op(self, 'opened', { + lease_id = reply.lease_id, + path = self.path, + })) + if not ok_event then + return false, tostring(event_err) + end + + handed_off = true + return true, reply + end):wrap(function (st, rep, ok, value_or_err) + if st ~= 'ok' then + return false, tostring(value_or_err or rep) + end + return ok, value_or_err + end) + end, + } end local function handle_request_op(self, request) - return fibers.run_scope_op(function () - local methods = methods_for(self) - local fn = methods[request.verb] - - local ok, value_or_err - if type(fn) ~= 'function' then - ok = false - value_or_err = 'unsupported verb: ' .. tostring(request.verb) - else - ok, value_or_err = fibers.perform(fn(request.opts, request)) - end - - local replied, reply_err = - fibers.perform(reply_request_op(request.reply_ch, ok, value_or_err)) - - if not replied then - return false, tostring(reply_err) - end - - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + local methods = methods_for(self) + local fn = methods[request.verb] + + local ok, value_or_err + if type(fn) ~= 'function' then + ok = false + value_or_err = 'unsupported verb: ' .. tostring(request.verb) + else + ok, value_or_err = fibers.perform(fn(request.opts, request)) + end + + local replied, reply_err = + fibers.perform(reply_request_op(request.reply_ch, ok, value_or_err)) + + if not replied then + return false, tostring(reply_err) + end + + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function shell_main(self) - local shell_scope = assert(self.scope, 'uart shell without scope') - assert(self.emit_ch, 'uart shell without emit channel') - - shell_scope:finally(function (_, status, primary) - finalise_shell_scope(self, shell_scope, status, primary) - end) - - local ok_meta, meta_err = - fibers.perform(emit_op(self.emit_ch, 'uart', self.id, 'meta', 'details', meta_payload(self))) - if ok_meta ~= true then - error(tostring(meta_err or 'initial uart meta emit failed'), 0) - end - - local ok_status, status_err = fibers.perform(publish_status_op(self)) - if ok_status ~= true then - error(tostring(status_err or 'initial uart status emit failed'), 0) - end - - while true do - local request = fibers.perform(self.control_ch:get_op()) - if not request then - return - end - - local ok_req, req_err = fibers.perform(handle_request_op(self, request)) - if not ok_req then - dlog(self, 'warn', { - what = 'uart_request_failed', - err = tostring(req_err), - }) - end - end + local shell_scope = assert(self.scope, 'uart shell without scope') + assert(self.emit_ch, 'uart shell without emit channel') + + shell_scope:finally(function (_, status, primary) + finalise_shell_scope(self, shell_scope, status, primary) + end) + + local ok_meta, meta_err = + fibers.perform(emit_op(self.emit_ch, 'uart', self.id, 'meta', 'details', meta_payload(self))) + if ok_meta ~= true then + error(tostring(meta_err or 'initial uart meta emit failed'), 0) + end + + local ok_status, status_err = fibers.perform(publish_status_op(self)) + if ok_status ~= true then + error(tostring(status_err or 'initial uart status emit failed'), 0) + end + + while true do + local request = fibers.perform(self.control_ch:get_op()) + if not request then + return + end + + local ok_req, req_err = fibers.perform(handle_request_op(self, request)) + if not ok_req then + dlog(self, 'warn', { + what = 'uart_request_failed', + err = tostring(req_err), + }) + end + end end function Driver:capabilities_op(emit_ch) - return op.guard(function () - if self.caps_applied then - return op.always(false, 'capabilities already applied') - end + return op.guard(function () + if self.caps_applied then + return op.always(false, 'capabilities already applied') + end - self.emit_ch = emit_ch + self.emit_ch = emit_ch - local cap, err = cap_types.new.UARTCapability(self.id, self.control_ch) - if not cap then - return op.always(false, tostring(err)) - end + local cap, err = cap_types.new.UARTCapability(self.id, self.control_ch) + if not cap then + return op.always(false, tostring(err)) + end - self.caps_applied = true - return op.always(true, { cap }) - end) + self.caps_applied = true + return op.always(true, { cap }) + end) end ---@param owner_scope Scope function Driver:start_op(owner_scope) - assert(owner_scope ~= nil, 'uart driver start_op: owner_scope is required') - - return fibers.run_scope_op(function () - if self.started then - return false, 'already started' - end - if not self.caps_applied then - return false, 'capabilities not applied' - end - if not self.emit_ch then - return false, 'missing emit channel' - end - - local ok_cfg, cfg_err = fibers.perform(configure_termios_op(self, 'start')) - if ok_cfg ~= true then - return false, tostring(cfg_err) - end - - local shell_scope, serr = owner_scope:child() - if not shell_scope then - return false, tostring(serr) - end - - self.scope = shell_scope - - local ok, err = shell_scope:spawn(function () - return shell_main(self) - end) - if not ok then - self.scope = nil - shell_scope:cancel(tostring(err or 'uart shell spawn failed')) - return false, tostring(err) - end - - self.started = true - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + assert(owner_scope ~= nil, 'uart driver start_op: owner_scope is required') + + return fibers.run_scope_op(function () + if self.started then + return false, 'already started' + end + if not self.caps_applied then + return false, 'capabilities not applied' + end + if not self.emit_ch then + return false, 'missing emit channel' + end + + local ok_cfg, cfg_err = fibers.perform(configure_termios_op(self, 'start')) + if ok_cfg ~= true then + return false, tostring(cfg_err) + end + + local shell_scope, serr = owner_scope:child() + if not shell_scope then + return false, tostring(serr) + end + + self.scope = shell_scope + + local ok, err = shell_scope:spawn(function () + return shell_main(self) + end) + if not ok then + self.scope = nil + shell_scope:cancel(tostring(err or 'uart shell spawn failed')) + return false, tostring(err) + end + + self.started = true + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end function Driver:terminate(reason) - if self.scope then - self.scope:cancel(reason or 'uart driver terminated') - end - if self.active_session then - self.active_session:terminate(reason or 'uart driver terminated') - end - self.started = false - self.scope = nil - self.active_session = nil - self.active_lease_id = nil - self.closing_lease_id = nil - return true, nil + if self.scope then + self.scope:cancel(reason or 'uart driver terminated') + end + if self.active_session then + self.active_session:terminate(reason or 'uart driver terminated') + end + self.started = false + self.scope = nil + self.active_session = nil + self.active_lease_id = nil + self.closing_lease_id = nil + return true, nil end function Driver:shutdown_op(timeout) - timeout = timeout or DEFAULT_STOP_TIMEOUT - - return op.guard(function () - if not self.started or not self.scope then - return op.always(true, nil) - end - - local shell_scope = self.scope - local session = self.active_session - - shell_scope:cancel() - - return fibers.boolean_choice( - shell_scope:join_op():wrap(function () - -- Make the post-stop contract explicit: any previously returned - -- session wrapper is no longer usable, even if the shell stop - -- path did not get to mark it in time. - if session then - session.closed = true - end - - finalise_shell_scope(self, shell_scope, 'ok', nil) - return true, nil - end), - sleep.sleep_op(timeout):wrap(function () - return false, 'uart driver stop timeout' - end) - ):wrap(function (completed, _a, b) - if completed then - return true, nil - end - return false, b - end) - end) + timeout = timeout or DEFAULT_STOP_TIMEOUT + + return op.guard(function () + if not self.started or not self.scope then + return op.always(true, nil) + end + + local shell_scope = self.scope + local session = self.active_session + + shell_scope:cancel() + + return fibers.boolean_choice( + shell_scope:join_op():wrap(function () + -- Make the post-stop contract explicit: any previously returned + -- session wrapper is no longer usable, even if the shell stop + -- path did not get to mark it in time. + if session then + session.closed = true + end + + finalise_shell_scope(self, shell_scope, 'ok', nil) + return true, nil + end), + sleep.sleep_op(timeout):wrap(function () + return false, 'uart driver stop timeout' + end) + ):wrap(function (completed, _a, b) + if completed then + return true, nil + end + return false, b + end) + end) end function Driver:fault_op() - if self.scope and self.started then - return self.scope:fault_op() - end - return op.never() + if self.scope and self.started then + return self.scope:fault_op() + end + return op.never() end ---@param id string @@ -755,26 +755,26 @@ end ---@param logger table|nil ---@return UARTDriver function M.new(id, path, baud, mode, logger) - assert(type(id) == 'string' and id ~= '', 'uart.new: invalid id') - assert(type(path) == 'string' and path ~= '', 'uart.new: invalid path') - - return setmetatable({ - id = id, - path = path, - default_baud = baud, - default_mode = mode, - scope = nil, - control_ch = channel.new(CONTROL_Q_LEN), - emit_ch = nil, - logger = logger, - termios_ok = false, - termios_error = nil, - started = false, - caps_applied = false, - active_session = nil, - active_lease_id = nil, - closing_lease_id = nil, - }, Driver) + assert(type(id) == 'string' and id ~= '', 'uart.new: invalid id') + assert(type(path) == 'string' and path ~= '', 'uart.new: invalid path') + + return setmetatable({ + id = id, + path = path, + default_baud = baud, + default_mode = mode, + scope = nil, + control_ch = channel.new(CONTROL_Q_LEN), + emit_ch = nil, + logger = logger, + termios_ok = false, + termios_error = nil, + started = false, + caps_applied = false, + active_session = nil, + active_lease_id = nil, + closing_lease_id = nil, + }, Driver) end M.Driver = Driver diff --git a/src/services/hal/drivers/usb.lua b/src/services/hal/drivers/usb.lua index c59dd0eb..2c29acf4 100644 --- a/src/services/hal/drivers/usb.lua +++ b/src/services/hal/drivers/usb.lua @@ -21,9 +21,9 @@ local perform = fibers.perform local CONTROL_Q_LEN = 8 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end -- Sysfs base path for USB hubs on bigbox-ss hardware (BCM2711 / VL805). @@ -32,8 +32,8 @@ local USB_HUB_PREFIX = "/sys/devices/platform/scb/fd500000.pcie/pci0000:00/0000: -- Sysfs subpaths for each hub port, relative to USB_HUB_PREFIX .. hub_number. -- Hub 1 = USB 2.0 host; Hub 2 = USB 3.0 host. local USB_PORT_SUBPATHS = { - [1] = { [1] = "1-1/1-1.1", [2] = "1-1/1-1.2", [3] = "1-1/1-1.3", [4] = "1-1/1-1.4" }, - [2] = { [1] = "2-1", [2] = "2-2" }, + [1] = { [1] = "1-1/1-1.1", [2] = "1-1/1-1.2", [3] = "1-1/1-1.3", [4] = "1-1/1-1.4" }, + [2] = { [1] = "2-1", [2] = "2-2" }, } -- Minimum VL805 firmware timestamp for hub power control support (2019-09-10). @@ -55,16 +55,16 @@ UsbDriver.__index = UsbDriver --- Returns true (enabled) as a safe default when unavailable. ---@return boolean local function probe_usb3_state() - local path = USB_HUB_PREFIX .. '2/authorized_default' - local f, _ = file.open(path, 'r') - if not f then return true end - local raw, _ = f:read_all() - f:close() - if raw then - local val = tonumber(raw:match("%d+")) - return val ~= nil and val ~= 0 - end - return true + local path = USB_HUB_PREFIX .. '2/authorized_default' + local f, _ = file.open(path, 'r') + if not f then return true end + local raw, _ = f:read_all() + f:close() + if raw then + local val = tonumber(raw:match("%d+")) + return val ~= nil and val ~= 0 + end + return true end --- Return true if a USB device is present at the given hub/port sysfs path. @@ -73,20 +73,20 @@ end ---@return boolean present ---@return string? error local function is_device_on_hub_port(hub, port) - local subpaths = USB_PORT_SUBPATHS[hub] - if not subpaths then return false, "invalid hub" end - if not subpaths[port] then return false, "invalid port" end - local path = USB_HUB_PREFIX .. hub .. '/' .. subpaths[port] - local cmd = exec.command { 'test', '-e', path, stdin = 'null', stdout = 'null', stderr = 'null' } - local status, code, signal, run_err = perform(cmd:run_op()) - if status == 'exited' and code == 0 then - return true, nil - end - if status == 'exited' and code == 1 then - return false, nil - end - return false, ("test -e %s failed (status=%s exit=%s signal=%s err=%s)"):format( - path, tostring(status), tostring(code), tostring(signal), tostring(run_err)) + local subpaths = USB_PORT_SUBPATHS[hub] + if not subpaths then return false, "invalid hub" end + if not subpaths[port] then return false, "invalid port" end + local path = USB_HUB_PREFIX .. hub .. '/' .. subpaths[port] + local cmd = exec.command { 'test', '-e', path, stdin = 'null', stdout = 'null', stderr = 'null' } + local status, code, signal, run_err = perform(cmd:run_op()) + if status == 'exited' and code == 0 then + return true, nil + end + if status == 'exited' and code == 1 then + return false, nil + end + return false, ("test -e %s failed (status=%s exit=%s signal=%s err=%s)"):format( + path, tostring(status), tostring(code), tostring(signal), tostring(run_err)) end --- Write an integer to a sysfs path. @@ -94,27 +94,27 @@ end ---@param value integer ---@return string? error local function exec_write_to_file(path, value) - local f, ferr = file.open(path, 'w') - if not f then - return ("open %s failed: %s"):format(path, tostring(ferr or 'unknown')) - end - - local n, werr = perform(f:write_op(tostring(value) .. '\n')) - local ok, cerr = perform(f:close_op()) - if n == nil then - return ("write to %s failed: %s"):format(path, tostring(werr or 'unknown')) - end - if not ok then - return ("close %s failed: %s"):format(path, tostring(cerr or 'unknown')) - end - return nil + local f, ferr = file.open(path, 'w') + if not f then + return ("open %s failed: %s"):format(path, tostring(ferr or 'unknown')) + end + + local n, werr = perform(f:write_op(tostring(value) .. '\n')) + local ok, cerr = perform(f:close_op()) + if n == nil then + return ("write to %s failed: %s"):format(path, tostring(werr or 'unknown')) + end + if not ok then + return ("close %s failed: %s"):format(path, tostring(cerr or 'unknown')) + end + return nil end ---@param enabled boolean ---@param hub integer ---@return string? error local function set_usb_hub_auth_default(enabled, hub) - return exec_write_to_file(USB_HUB_PREFIX .. hub .. '/authorized_default', enabled and 1 or 0) + return exec_write_to_file(USB_HUB_PREFIX .. hub .. '/authorized_default', enabled and 1 or 0) end ---@param enabled boolean @@ -122,43 +122,43 @@ end ---@param port integer ---@return string? error local function set_usb_port_auth(enabled, hub, port) - return exec_write_to_file( - USB_HUB_PREFIX .. hub .. '/' .. USB_PORT_SUBPATHS[hub][port] .. '/authorized', - enabled and 1 or 0) + return exec_write_to_file( + USB_HUB_PREFIX .. hub .. '/' .. USB_PORT_SUBPATHS[hub][port] .. '/authorized', + enabled and 1 or 0) end --- Deauthorize all occupied USB3 (hub 2) ports. ---@return boolean hub_was_used ---@return string? error local function clear_usb3_hub() - local hub_used = false - for i = 1, #USB_PORT_SUBPATHS[2] do - local present, err = is_device_on_hub_port(2, i) - if err then return false, "error checking port " .. i .. ": " .. err end - if present then - hub_used = true - err = set_usb_port_auth(false, 2, i) - if err then return true, "error deauthorising port " .. i .. ": " .. err end - end - end - return hub_used, nil + local hub_used = false + for i = 1, #USB_PORT_SUBPATHS[2] do + local present, err = is_device_on_hub_port(2, i) + if err then return false, "error checking port " .. i .. ": " .. err end + if present then + hub_used = true + err = set_usb_port_auth(false, 2, i) + if err then return true, "error deauthorising port " .. i .. ": " .. err end + end + end + return hub_used, nil end --- Re-authorize all occupied USB3 (hub 2) ports. ---@return boolean hub_was_used ---@return string? error local function repopulate_usb3_hub() - local hub_used = false - for i = 1, #USB_PORT_SUBPATHS[2] do - local present, err = is_device_on_hub_port(2, i) - if err then return false, "error checking port " .. i .. ": " .. err end - if present then - hub_used = true - err = set_usb_port_auth(true, 2, i) - if err then return true, "error reauthorising port " .. i .. ": " .. err end - end - end - return hub_used, nil + local hub_used = false + for i = 1, #USB_PORT_SUBPATHS[2] do + local present, err = is_device_on_hub_port(2, i) + if err then return false, "error checking port " .. i .. ": " .. err end + if present then + hub_used = true + err = set_usb_port_auth(true, 2, i) + if err then return true, "error reauthorising port " .. i .. ": " .. err end + end + end + return hub_used, nil end --- Control USB hub power via uhubctl. @@ -166,42 +166,42 @@ end ---@param hub integer ---@return string? error local function set_usb_hub_power(enabled, hub) - local cmd = exec.command { 'uhubctl', '-e', '-l', tostring(hub), '-a', tostring(enabled and 1 or 0), stdin = 'null', stdout = 'null', stderr = 'null' } - local status, code, signal, run_err = perform(cmd:run_op()) - if status ~= 'exited' or code ~= 0 then - return ("uhubctl hub %d power %s failed (status=%s exit=%s signal=%s err=%s)"):format( - hub, enabled and 'on' or 'off', tostring(status), tostring(code), - tostring(signal), tostring(run_err)) - end - return nil + local cmd = exec.command { 'uhubctl', '-e', '-l', tostring(hub), '-a', tostring(enabled and 1 or 0), stdin = 'null', stdout = 'null', stderr = 'null' } + local status, code, signal, run_err = perform(cmd:run_op()) + if status ~= 'exited' or code ~= 0 then + return ("uhubctl hub %d power %s failed (status=%s exit=%s signal=%s err=%s)"):format( + hub, enabled and 'on' or 'off', tostring(status), tostring(code), + tostring(signal), tostring(run_err)) + end + return nil end --- Read the VL805 hub controller firmware timestamp via vcgencmd. ---@return integer? timestamp ---@return string? error local function get_vl805_timestamp() - local cmd = exec.command { 'vcgencmd', 'bootloader_version', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, _, code = perform(cmd:output_op()) - if code ~= 0 or not out then - return nil, "vcgencmd bootloader_version failed" - end - local timestamp = out:match("timestamp%s+(%d+)") - if not timestamp then - return nil, "timestamp not found in bootloader version output" - end - return tonumber(timestamp), nil + local cmd = exec.command { 'vcgencmd', 'bootloader_version', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, _, code = perform(cmd:output_op()) + if code ~= 0 or not out then + return nil, "vcgencmd bootloader_version failed" + end + local timestamp = out:match("timestamp%s+(%d+)") + if not timestamp then + return nil, "timestamp not found in bootloader version output" + end + return tonumber(timestamp), nil end ---@param enabled boolean ---@param logger Logger? local function emit_state(emit_ch, bus_id, enabled, logger) - if not emit_ch then return end - local payload, err = hal_types.new.Emit('usb', bus_id, 'state', 'bus', { enabled = enabled }) - if not payload then - dlog(logger, 'debug', { what = 'state_emit_failed', err = tostring(err) }) - return - end - emit_ch:put(payload) + if not emit_ch then return end + local payload, err = hal_types.new.Emit('usb', bus_id, 'state', 'bus', { enabled = enabled }) + if not payload then + dlog(logger, 'debug', { what = 'state_emit_failed', err = tostring(err) }) + return + end + emit_ch:put(payload) end ---- capability verbs ---- @@ -210,203 +210,203 @@ end ---@return boolean ok ---@return any value_or_err function UsbDriver:enable(_opts) - if self.enabled then - return true, nil - end - - local auth_err = set_usb_hub_auth_default(true, 2) - if auth_err then - dlog(self.logger, 'warn', { what = 'restore_authorized_default_failed', err = tostring(auth_err) }) - end - - local power_err = set_usb_hub_power(true, 2) - if power_err then - return false, power_err - end - - self.enabled = true - emit_state(self.cap_emit_ch, self.bus_id, true) - return true, nil + if self.enabled then + return true, nil + end + + local auth_err = set_usb_hub_auth_default(true, 2) + if auth_err then + dlog(self.logger, 'warn', { what = 'restore_authorized_default_failed', err = tostring(auth_err) }) + end + + local power_err = set_usb_hub_power(true, 2) + if power_err then + return false, power_err + end + + self.enabled = true + emit_state(self.cap_emit_ch, self.bus_id, true) + return true, nil end ---@param _opts table? ---@return boolean ok ---@return any value_or_err function UsbDriver:disable(_opts) - -- Do not trust the cached state here. It is initially inferred from - -- authorized_default, which does not prove that the hub is powered down or - -- that an already-enumerated USB3 device is disconnected. - - -- Verify VL805 firmware supports hub power control. - local vl805_ts, ts_err = get_vl805_timestamp() - if ts_err then - return false, "could not verify VL805 firmware: " .. ts_err - end - if vl805_ts < VL805_SUPPORTED_FROM then - return false, ("VL805 firmware too old (timestamp %d, need >= %d)"):format( - vl805_ts, VL805_SUPPORTED_FROM) - end - - -- Deauthorize each connected USB3 port so devices fall back to USB2. - local hub_used, clear_err = clear_usb3_hub() - if clear_err then - dlog(self.logger, 'error', { what = 'clear_usb3_hub_failed', err = tostring(clear_err) }) - repopulate_usb3_hub() - set_usb_hub_auth_default(true, 2) - return false, clear_err - end - if not hub_used then - -- No active USB3 connections; still prevent future connections. - dlog(self.logger, 'info', { what = 'no_usb3_devices_connected' }) - set_usb_hub_auth_default(false, 2) - self.enabled = false - emit_state(self.cap_emit_ch, self.bus_id, false, self.logger) - return true, nil - end - - -- Prevent future USB3 connections. - local auth_err = set_usb_hub_auth_default(false, 2) - if auth_err then - dlog(self.logger, 'warn', { what = 'set_authorized_default_failed', err = tostring(auth_err) }) - end - - -- Power down USB3 hub to force devices onto USB2. - local power_err = set_usb_hub_power(false, 2) - if power_err then - dlog(self.logger, 'error', { what = 'power_down_usb3_hub_failed', err = tostring(power_err) }) - set_usb_hub_power(true, 2) - repopulate_usb3_hub() - set_usb_hub_auth_default(true, 2) - return false, power_err - end - - -- Wait up to 10 seconds for USB3 devices to re-enumerate on the USB2 hub. - local awaiting_1 = is_device_on_hub_port(2, 1) - local awaiting_2 = is_device_on_hub_port(2, 2) - local migrated = false - for _ = 1, 10 do - local p1_ok = not (awaiting_1 and not is_device_on_hub_port(1, 1)) - local p2_ok = not (awaiting_2 and not is_device_on_hub_port(1, 2)) - if p1_ok and p2_ok then migrated = true; break end - perform(sleep.sleep_op(1)) - end - if not migrated then - dlog(self.logger, 'warn', { what = 'usb3_migration_incomplete' }) - end - - self.enabled = false - emit_state(self.cap_emit_ch, self.bus_id, false, self.logger) - return true, nil + -- Do not trust the cached state here. It is initially inferred from + -- authorized_default, which does not prove that the hub is powered down or + -- that an already-enumerated USB3 device is disconnected. + + -- Verify VL805 firmware supports hub power control. + local vl805_ts, ts_err = get_vl805_timestamp() + if ts_err then + return false, "could not verify VL805 firmware: " .. ts_err + end + if vl805_ts < VL805_SUPPORTED_FROM then + return false, ("VL805 firmware too old (timestamp %d, need >= %d)"):format( + vl805_ts, VL805_SUPPORTED_FROM) + end + + -- Deauthorize each connected USB3 port so devices fall back to USB2. + local hub_used, clear_err = clear_usb3_hub() + if clear_err then + dlog(self.logger, 'error', { what = 'clear_usb3_hub_failed', err = tostring(clear_err) }) + repopulate_usb3_hub() + set_usb_hub_auth_default(true, 2) + return false, clear_err + end + if not hub_used then + -- No active USB3 connections; still prevent future connections. + dlog(self.logger, 'info', { what = 'no_usb3_devices_connected' }) + set_usb_hub_auth_default(false, 2) + self.enabled = false + emit_state(self.cap_emit_ch, self.bus_id, false, self.logger) + return true, nil + end + + -- Prevent future USB3 connections. + local auth_err = set_usb_hub_auth_default(false, 2) + if auth_err then + dlog(self.logger, 'warn', { what = 'set_authorized_default_failed', err = tostring(auth_err) }) + end + + -- Power down USB3 hub to force devices onto USB2. + local power_err = set_usb_hub_power(false, 2) + if power_err then + dlog(self.logger, 'error', { what = 'power_down_usb3_hub_failed', err = tostring(power_err) }) + set_usb_hub_power(true, 2) + repopulate_usb3_hub() + set_usb_hub_auth_default(true, 2) + return false, power_err + end + + -- Wait up to 10 seconds for USB3 devices to re-enumerate on the USB2 hub. + local awaiting_1 = is_device_on_hub_port(2, 1) + local awaiting_2 = is_device_on_hub_port(2, 2) + local migrated = false + for _ = 1, 10 do + local p1_ok = not (awaiting_1 and not is_device_on_hub_port(1, 1)) + local p2_ok = not (awaiting_2 and not is_device_on_hub_port(1, 2)) + if p1_ok and p2_ok then migrated = true; break end + perform(sleep.sleep_op(1)) + end + if not migrated then + dlog(self.logger, 'warn', { what = 'usb3_migration_incomplete' }) + end + + self.enabled = false + emit_state(self.cap_emit_ch, self.bus_id, false, self.logger) + return true, nil end ---- control manager ---- function UsbDriver:control_manager() - fibers.current_scope():finally(function() - dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) - end) - - while true do - local request, req_err = self.control_ch:get() - if not request then - dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) - break - end - - local fn = self[request.verb] - local ok, value_or_err - if type(fn) ~= 'function' then - ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) - else - local st, _, r1, r2 = fibers.run_scope(function() - return fn(self, request.opts) - end) - if st ~= 'ok' then - ok, value_or_err = false, "internal error: " .. tostring(r1) - else - ok, value_or_err = r1, r2 - end - end - - local reply = hal_types.new.Reply(ok, value_or_err) - if reply then - request.reply_ch:put(reply) - end - end + fibers.current_scope():finally(function() + dlog(self.logger, 'debug', { what = 'control_manager_exiting' }) + end) + + while true do + local request, req_err = self.control_ch:get() + if not request then + dlog(self.logger, 'debug', { what = 'control_ch_closed', err = tostring(req_err) }) + break + end + + local fn = self[request.verb] + local ok, value_or_err + if type(fn) ~= 'function' then + ok, value_or_err = false, "unsupported verb: " .. tostring(request.verb) + else + local st, _, r1, r2 = fibers.run_scope(function() + return fn(self, request.opts) + end) + if st ~= 'ok' then + ok, value_or_err = false, "internal error: " .. tostring(r1) + else + ok, value_or_err = r1, r2 + end + end + + local reply = hal_types.new.Reply(ok, value_or_err) + if reply then + request.reply_ch:put(reply) + end + end end ---- public interface ---- ---@return string error function UsbDriver:init() - if self.initialised then - return "already initialised" - end - self.initialised = true - return "" + if self.initialised then + return "already initialised" + end + self.initialised = true + return "" end ---@param emit_ch Channel ---@return Capability[]? ---@return string error function UsbDriver:capabilities(emit_ch) - if not self.initialised then - return nil, "usb driver not initialised" - end - self.cap_emit_ch = emit_ch - local cap, err = cap_types.new.UsbCapability(self.bus_id, self.control_ch) - if not cap then - return {}, err - end - return { cap }, "" + if not self.initialised then + return nil, "usb driver not initialised" + end + self.cap_emit_ch = emit_ch + local cap, err = cap_types.new.UsbCapability(self.bus_id, self.control_ch) + if not cap then + return {}, err + end + return { cap }, "" end ---@return boolean ok ---@return string error function UsbDriver:start() - if not self.initialised then - return false, "usb driver not initialised" - end - if self.cap_emit_ch then - -- Publish initial bus state. - emit_state(self.cap_emit_ch, self.bus_id, self.enabled, self.logger) - - -- Publish meta. - local meta_payload, meta_err = hal_types.new.Emit('usb', self.bus_id, 'meta', 'info', { - provider = 'hal', - version = 1, - bus_id = self.bus_id, - }) - if meta_payload then - self.cap_emit_ch:put(meta_payload) - else - dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) - end - end - - local ok, spawn_err = self.scope:spawn(function() - self:control_manager() - end) - if not ok then - return false, "failed to spawn control_manager: " .. tostring(spawn_err) - end - return true, "" + if not self.initialised then + return false, "usb driver not initialised" + end + if self.cap_emit_ch then + -- Publish initial bus state. + emit_state(self.cap_emit_ch, self.bus_id, self.enabled, self.logger) + + -- Publish meta. + local meta_payload, meta_err = hal_types.new.Emit('usb', self.bus_id, 'meta', 'info', { + provider = 'hal', + version = 1, + bus_id = self.bus_id, + }) + if meta_payload then + self.cap_emit_ch:put(meta_payload) + else + dlog(self.logger, 'debug', { what = 'meta_emit_failed', err = tostring(meta_err) }) + end + end + + local ok, spawn_err = self.scope:spawn(function() + self:control_manager() + end) + if not ok then + return false, "failed to spawn control_manager: " .. tostring(spawn_err) + end + return true, "" end ---@param timeout number? ---@return boolean ok ---@return string error function UsbDriver:stop(timeout) - timeout = timeout or 5 - self.scope:cancel(('usb driver [%s] stopped'):format(self.bus_id)) - local source = perform(op.named_choice { - join = self.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - if source == 'timeout' then - return false, ("usb driver [%s] stop timeout"):format(self.bus_id) - end - return true, "" + timeout = timeout or 5 + self.scope:cancel(('usb driver [%s] stopped'):format(self.bus_id)) + local source = perform(op.named_choice { + join = self.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + if source == 'timeout' then + return false, ("usb driver [%s] stop timeout"):format(self.bus_id) + end + return true, "" end ---@param bus_id string capability id, e.g. "usb3" @@ -414,32 +414,32 @@ end ---@return UsbDriver? ---@return string error local function new(bus_id, logger) - bus_id = bus_id or 'usb3' - - local scope, err = fibers.current_scope():child() - if not scope then - return nil, "failed to create child scope: " .. tostring(err) - end - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(logger, 'debug', { what = 'stopped' }) - end) - - local enabled = probe_usb3_state() - - return setmetatable({ - bus_id = bus_id, - scope = scope, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - enabled = enabled, - logger = logger, - initialised = false, - }, UsbDriver), "" + bus_id = bus_id or 'usb3' + + local scope, err = fibers.current_scope():child() + if not scope then + return nil, "failed to create child scope: " .. tostring(err) + end + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(logger, 'debug', { what = 'stopped' }) + end) + + local enabled = probe_usb3_state() + + return setmetatable({ + bus_id = bus_id, + scope = scope, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + enabled = enabled, + logger = logger, + initialised = false, + }, UsbDriver), "" end return { new = new } diff --git a/src/services/hal/logger.lua b/src/services/hal/logger.lua index 4b497095..40da4c7d 100644 --- a/src/services/hal/logger.lua +++ b/src/services/hal/logger.lua @@ -11,14 +11,14 @@ local Logger = {} Logger.__index = Logger local function merge(a, b) - local out = {} - if type(a) == 'table' then - for k, v in pairs(a) do out[k] = v end - end - if type(b) == 'table' then - for k, v in pairs(b) do out[k] = v end - end - return out + local out = {} + if type(a) == 'table' then + for k, v in pairs(a) do out[k] = v end + end + if type(b) == 'table' then + for k, v in pairs(b) do out[k] = v end + end + return out end ---Create a new Logger. @@ -26,47 +26,47 @@ end ---@param fields table ---@return Logger function Logger.new(emit_fn, fields) - return setmetatable({ - _emit = emit_fn, - _fields = fields or {}, - }, Logger) + return setmetatable({ + _emit = emit_fn, + _fields = fields or {}, + }, Logger) end ---Create a child logger that inherits fields and adds extra ones. ---@param extra_fields table ---@return Logger function Logger:child(extra_fields) - return Logger.new(self._emit, merge(self._fields, extra_fields)) + return Logger.new(self._emit, merge(self._fields, extra_fields)) end ---@param payload any function Logger:debug(payload) - if type(payload) == 'table' then payload = merge(self._fields, payload) end - self._emit('debug', payload) + if type(payload) == 'table' then payload = merge(self._fields, payload) end + self._emit('debug', payload) end ---@param payload any function Logger:info(payload) - if type(payload) == 'table' then payload = merge(self._fields, payload) end - self._emit('info', payload) + if type(payload) == 'table' then payload = merge(self._fields, payload) end + self._emit('info', payload) end ---@param payload any function Logger:warn(payload) - if type(payload) == 'table' then payload = merge(self._fields, payload) end - self._emit('warn', payload) + if type(payload) == 'table' then payload = merge(self._fields, payload) end + self._emit('warn', payload) end ---@param payload any function Logger:error(payload) - if type(payload) == 'table' then payload = merge(self._fields, payload) end - self._emit('error', payload) + if type(payload) == 'table' then payload = merge(self._fields, payload) end + self._emit('error', payload) end ---@param payload any function Logger:trace(payload) - if type(payload) == 'table' then payload = merge(self._fields, payload) end - self._emit('trace', payload) + if type(payload) == 'table' then payload = merge(self._fields, payload) end + self._emit('trace', payload) end return Logger diff --git a/src/services/hal/managers/control_store.lua b/src/services/hal/managers/control_store.lua index abad7f05..ee4d1f48 100644 --- a/src/services/hal/managers/control_store.lua +++ b/src/services/hal/managers/control_store.lua @@ -12,7 +12,7 @@ local resource = require 'devicecode.support.resource' local driver_mod = require 'services.hal.drivers.control_store' local M = { - api_mode = 'op_only', + api_mode = 'op_only', } @@ -28,14 +28,14 @@ local STOP_TIMEOUT = 5.0 ---@field generation integer ---@field drivers table local S = { - started = false, - scope = nil, - logger = nil, - dev_ev_ch = nil, - cap_emit_ch = nil, - cfg_ch = nil, + started = false, + scope = nil, + logger = nil, + dev_ev_ch = nil, + cap_emit_ch = nil, + cfg_ch = nil, generation = 0, - drivers = {}, + drivers = {}, } local function finalise_manager_scope(scope, generation) @@ -57,21 +57,21 @@ local function finalise_manager_scope(scope, generation) end local function validate_config(namespaces) - if type(namespaces) ~= 'table' then - return false, 'config must be a list' - end - for _, ns in ipairs(namespaces) do - if type(ns) ~= 'table' then - return false, 'each namespace must be a table' - end - if type(ns.name) ~= 'string' or ns.name == '' then - return false, 'namespace.name must be a non-empty string' - end - if type(ns.root) ~= 'string' or ns.root == '' then - return false, 'namespace.root must be a non-empty string' - end - end - return true, nil + if type(namespaces) ~= 'table' then + return false, 'config must be a list' + end + for _, ns in ipairs(namespaces) do + if type(ns) ~= 'table' then + return false, 'each namespace must be a table' + end + if type(ns.name) ~= 'string' or ns.name == '' then + return false, 'namespace.name must be a non-empty string' + end + if type(ns.root) ~= 'string' or ns.root == '' then + return false, 'namespace.root must be a non-empty string' + end + end + return true, nil end local function emit_device_added_op(driver, caps) @@ -83,73 +83,73 @@ local function emit_device_removed_op(driver) end local function start_driver_op(name, root) - return fibers.run_scope_op(function () - local driver = driver_mod.new(name, root, S.logger) - - local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(S.cap_emit_ch)) - if not ok_caps then - return false, tostring(caps_or_err) - end - local caps = caps_or_err - - local started = false - local handed_off = false - - local function cleanup_started_driver() - if started and not handed_off then - -- Best-effort rollback; this resource belongs to this start attempt - resource.terminate_checked(driver, 'manager cleanup', 'HAL manager driver rollback failed') - end - end - - -- If anything below errors, rollback the started driver. - fibers.current_scope():finally(function () - cleanup_started_driver() - end) - - local ok_start, start_err = - fibers.perform(driver:start_op(assert(S.scope, 'control_store manager scope missing'))) - if not ok_start then - return false, tostring(start_err) - end - started = true - - local ok_emit, emit_err = fibers.perform(emit_device_added_op(driver, caps)) - if not ok_emit then - return false, tostring(emit_err) - end - - S.drivers[name] = driver - handed_off = true - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + local driver = driver_mod.new(name, root, S.logger) + + local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(S.cap_emit_ch)) + if not ok_caps then + return false, tostring(caps_or_err) + end + local caps = caps_or_err + + local started = false + local handed_off = false + + local function cleanup_started_driver() + if started and not handed_off then + -- Best-effort rollback; this resource belongs to this start attempt + resource.terminate_checked(driver, 'manager cleanup', 'HAL manager driver rollback failed') + end + end + + -- If anything below errors, rollback the started driver. + fibers.current_scope():finally(function () + cleanup_started_driver() + end) + + local ok_start, start_err = + fibers.perform(driver:start_op(assert(S.scope, 'control_store manager scope missing'))) + if not ok_start then + return false, tostring(start_err) + end + started = true + + local ok_emit, emit_err = fibers.perform(emit_device_added_op(driver, caps)) + if not ok_emit then + return false, tostring(emit_err) + end + + S.drivers[name] = driver + handed_off = true + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function stop_driver_op(name, driver) - return fibers.run_scope_op(function () - local ok_emit, emit_err = fibers.perform(emit_device_removed_op(driver)) - if not ok_emit then - return false, tostring(emit_err) - end - - local ok_stop, stop_err = fibers.perform(driver:shutdown_op()) - if not ok_stop then - return false, tostring(stop_err) - end - - S.drivers[name] = nil - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + local ok_emit, emit_err = fibers.perform(emit_device_removed_op(driver)) + if not ok_emit then + return false, tostring(emit_err) + end + + local ok_stop, stop_err = fibers.perform(driver:shutdown_op()) + if not ok_stop then + return false, tostring(stop_err) + end + + S.drivers[name] = nil + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function reconcile_op(namespaces) @@ -211,47 +211,47 @@ local function shell_loop(generation, cfg_ch) end function M.start_op(logger, dev_ev_ch, cap_emit_ch) - -- Capture the owning long-lived HAL scope now, not at perform time. - -- This op may be passed around before being performed; the driver shell must - -- still be parented to the manager/service scope that initiated startup. - local owner_scope = fibers.current_scope() - assert(owner_scope ~= nil, 'control_store.start_op must be called from inside a fiber') - - return op.guard(function () - if S.started then - return op.always(false, 'already started') - end - - local scope, err = owner_scope:child() - if not scope then - return op.always(false, tostring(err)) - end - - S.scope = scope - S.logger = logger - S.dev_ev_ch = dev_ev_ch - S.cap_emit_ch = cap_emit_ch + -- Capture the owning long-lived HAL scope now, not at perform time. + -- This op may be passed around before being performed; the driver shell must + -- still be parented to the manager/service scope that initiated startup. + local owner_scope = fibers.current_scope() + assert(owner_scope ~= nil, 'control_store.start_op must be called from inside a fiber') + + return op.guard(function () + if S.started then + return op.always(false, 'already started') + end + + local scope, err = owner_scope:child() + if not scope then + return op.always(false, tostring(err)) + end + + S.scope = scope + S.logger = logger + S.dev_ev_ch = dev_ev_ch + S.cap_emit_ch = cap_emit_ch S.cfg_ch = channel.new(8) S.generation = S.generation + 1 local generation = S.generation local cfg_ch = S.cfg_ch - local detach_finaliser = scope:finally(function () - finalise_manager_scope(scope, generation) - end) - - local ok, serr = scope:spawn(function () shell_loop(generation, cfg_ch) end) - if not ok then - detach_finaliser() - finalise_manager_scope(scope, generation) - scope:cancel(tostring(serr or 'manager shell spawn failed')) - return op.always(false, tostring(serr)) - end - - S.started = true - return op.always(true, nil) - end) + local detach_finaliser = scope:finally(function () + finalise_manager_scope(scope, generation) + end) + + local ok, serr = scope:spawn(function () shell_loop(generation, cfg_ch) end) + if not ok then + detach_finaliser() + finalise_manager_scope(scope, generation) + scope:cancel(tostring(serr or 'manager shell spawn failed')) + return op.always(false, tostring(serr)) + end + + S.started = true + return op.always(true, nil) + end) end function M.apply_config_op(namespaces) @@ -300,32 +300,32 @@ function M.apply_config_op(namespaces) end function M.shutdown_op(timeout) - timeout = timeout or STOP_TIMEOUT + timeout = timeout or STOP_TIMEOUT - return op.guard(function () - if not S.started or not S.scope then - return op.always(true, nil) - end + return op.guard(function () + if not S.started or not S.scope then + return op.always(true, nil) + end - local scope = S.scope + local scope = S.scope local generation = S.generation - scope:cancel() - - return fibers.boolean_choice( - scope:join_op():wrap(function () - finalise_manager_scope(scope, generation) - return true, nil - end), - sleep.sleep_op(timeout):wrap(function () - return false, 'control_store manager stop timeout' - end) - ):wrap(function (completed, a, b) - if completed then - return true, nil - end - return false, b - end) - end) + scope:cancel() + + return fibers.boolean_choice( + scope:join_op():wrap(function () + finalise_manager_scope(scope, generation) + return true, nil + end), + sleep.sleep_op(timeout):wrap(function () + return false, 'control_store manager stop timeout' + end) + ):wrap(function (completed, a, b) + if completed then + return true, nil + end + return false, b + end) + end) end function M.terminate(reason) diff --git a/src/services/hal/managers/filesystem.lua b/src/services/hal/managers/filesystem.lua index ddb5d010..6da6e89f 100644 --- a/src/services/hal/managers/filesystem.lua +++ b/src/services/hal/managers/filesystem.lua @@ -23,48 +23,48 @@ local STOP_TIMEOUT = 5.0 -- seconds ---@field logger table? ---@field last_names string[] local FilesystemManager = { - started = false, - drivers = {}, - dev_ev_ch = nil, - cap_emit_ch = nil, - logger = nil, - last_names = {}, + started = false, + drivers = {}, + dev_ev_ch = nil, + cap_emit_ch = nil, + logger = nil, + last_names = {}, } ---@param namespaces string[] local function emit_removed_device(namespaces) - local removed_event, removed_err = hal_types.new.DeviceEvent( - "removed", - "fs", - "main", - { namespaces = namespaces } - ) - if not removed_event then - FilesystemManager.logger:error({ what = 'removed_device_event_create_failed', err = tostring(removed_err) }) - return - end - FilesystemManager.dev_ev_ch:put(removed_event) + local removed_event, removed_err = hal_types.new.DeviceEvent( + "removed", + "fs", + "main", + { namespaces = namespaces } + ) + if not removed_event then + FilesystemManager.logger:error({ what = 'removed_device_event_create_failed', err = tostring(removed_err) }) + return + end + FilesystemManager.dev_ev_ch:put(removed_event) end ---@param driver FSDriver local function stop_driver(driver) - FilesystemManager.logger:debug({ what = 'stopping_previous_driver' }) - local stop_ok, stop_err = driver:stop(STOP_TIMEOUT) - if not stop_ok then - FilesystemManager.logger:warn({ what = 'stop_previous_driver_failed', err = tostring(stop_err) }) - end + FilesystemManager.logger:debug({ what = 'stopping_previous_driver' }) + local stop_ok, stop_err = driver:stop(STOP_TIMEOUT) + if not stop_ok then + FilesystemManager.logger:warn({ what = 'stop_previous_driver_failed', err = tostring(stop_err) }) + end end local function stop_previous_driver_and_emit() - local prev_driver = FilesystemManager.drivers["main"] - if not prev_driver then - return - end + local prev_driver = FilesystemManager.drivers["main"] + if not prev_driver then + return + end - stop_driver(prev_driver) + stop_driver(prev_driver) - emit_removed_device(FilesystemManager.last_names) - FilesystemManager.drivers["main"] = nil + emit_removed_device(FilesystemManager.last_names) + FilesystemManager.drivers["main"] = nil end ---@param namespaces Namespace[] @@ -72,81 +72,81 @@ end ---@return string[] names ---@return string error local function build_roots(namespaces) - local roots = {} - local names = {} - for _, ns in ipairs(namespaces) do - if type(ns.name) ~= 'string' or type(ns.root) ~= 'string' then - return {}, {}, "invalid namespace config" - end - roots[ns.name] = ns.root - names[#names + 1] = ns.name - end - return roots, names, "" + local roots = {} + local names = {} + for _, ns in ipairs(namespaces) do + if type(ns.name) ~= 'string' or type(ns.root) ~= 'string' then + return {}, {}, "invalid namespace config" + end + roots[ns.name] = ns.root + names[#names + 1] = ns.name + end + return roots, names, "" end ---@param roots table ---@return FSDriver? driver ---@return string error local function init_driver(roots) - local driver_logger = nil - if FilesystemManager.logger and FilesystemManager.logger.child then - driver_logger = FilesystemManager.logger:child({ component = 'driver', driver = 'filesystem', id = 'main' }) - end - - ---@type any - local fs_driver_any = fs_driver - local driver, drv_err = fs_driver_any.new(roots, driver_logger) - if not driver then - return nil, "failed to create filesystem driver: " .. tostring(drv_err) - end - - local init_err = driver:init() - if init_err ~= "" then - return nil, "failed to init filesystem driver: " .. tostring(init_err) - end - - return driver, "" + local driver_logger = nil + if FilesystemManager.logger and FilesystemManager.logger.child then + driver_logger = FilesystemManager.logger:child({ component = 'driver', driver = 'filesystem', id = 'main' }) + end + + ---@type any + local fs_driver_any = fs_driver + local driver, drv_err = fs_driver_any.new(roots, driver_logger) + if not driver then + return nil, "failed to create filesystem driver: " .. tostring(drv_err) + end + + local init_err = driver:init() + if init_err ~= "" then + return nil, "failed to init filesystem driver: " .. tostring(init_err) + end + + return driver, "" end ---@param driver FSDriver ---@return Capability[]? capabilities ---@return string error local function apply_driver_capabilities(driver) - local capabilities, cap_err = driver:capabilities(FilesystemManager.cap_emit_ch) - if cap_err ~= "" then - return nil, "failed to apply capabilities: " .. tostring(cap_err) - end + local capabilities, cap_err = driver:capabilities(FilesystemManager.cap_emit_ch) + if cap_err ~= "" then + return nil, "failed to apply capabilities: " .. tostring(cap_err) + end - return capabilities, "" + return capabilities, "" end ---@param driver FSDriver ---@return string error local function start_driver(driver) - local ok, start_err = driver:start() - if not ok then - return "failed to start driver: " .. tostring(start_err) - end + local ok, start_err = driver:start() + if not ok then + return "failed to start driver: " .. tostring(start_err) + end - return "" + return "" end ---@param namespaces string[] ---@param capabilities Capability[] local function emit_added_device(namespaces, capabilities) - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", - "fs", - "main", - { namespaces = namespaces }, - capabilities - ) - if not device_event then - FilesystemManager.logger:error({ what = 'added_device_event_create_failed', err = tostring(ev_err) }) - return - end - - FilesystemManager.dev_ev_ch:put(device_event) + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", + "fs", + "main", + { namespaces = namespaces }, + capabilities + ) + if not device_event then + FilesystemManager.logger:error({ what = 'added_device_event_create_failed', err = tostring(ev_err) }) + return + end + + FilesystemManager.dev_ev_ch:put(device_event) end @@ -156,31 +156,31 @@ end ---@param cap_emit_ch Channel ---@return string error function FilesystemManager.start(logger, dev_ev_ch, cap_emit_ch) - if FilesystemManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - FilesystemManager.scope = scope - FilesystemManager.dev_ev_ch = dev_ev_ch - FilesystemManager.cap_emit_ch = cap_emit_ch - FilesystemManager.logger = logger - - -- Print out manager stack trace if scope closes on a failure - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - FilesystemManager.logger:error({ what = 'scope_failed', err = tostring(primary), status = st }) - end - FilesystemManager.logger:debug({ what = 'stopped' }) - end) - - FilesystemManager.started = true - FilesystemManager.logger:debug({ what = 'started' }) - return "" + if FilesystemManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + FilesystemManager.scope = scope + FilesystemManager.dev_ev_ch = dev_ev_ch + FilesystemManager.cap_emit_ch = cap_emit_ch + FilesystemManager.logger = logger + + -- Print out manager stack trace if scope closes on a failure + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + FilesystemManager.logger:error({ what = 'scope_failed', err = tostring(primary), status = st }) + end + FilesystemManager.logger:debug({ what = 'stopped' }) + end) + + FilesystemManager.started = true + FilesystemManager.logger:debug({ what = 'started' }) + return "" end ---Stops the Filesystem Manager. @@ -188,36 +188,36 @@ end ---@return boolean ok ---@return string error function FilesystemManager.stop(timeout) - if not FilesystemManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - FilesystemManager.scope:cancel() - - local source = fibers.perform(op.named_choice { - join = FilesystemManager.scope:join_op(), - timeout = sleep.sleep_op(timeout) - }) - - if source == "timeout" then - return false, "filesystem manager stop timeout" - end - FilesystemManager.started = false - return true, "" + if not FilesystemManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + FilesystemManager.scope:cancel() + + local source = fibers.perform(op.named_choice { + join = FilesystemManager.scope:join_op(), + timeout = sleep.sleep_op(timeout) + }) + + if source == "timeout" then + return false, "filesystem manager stop timeout" + end + FilesystemManager.started = false + return true, "" end --- Check that config is a set of name-root pairs and that paths are valid ---@param namespaces Namespace[] local function validate_config(namespaces) - if type(namespaces) ~= 'table' then - return false, "config must be a table of namespaces" - end - for _, ns in ipairs(namespaces) do - if type(ns.name) ~= 'string' or type(ns.root) ~= 'string' then - return false, "each namespace must have a name and root string" - end - end - return true, "" + if type(namespaces) ~= 'table' then + return false, "config must be a table of namespaces" + end + for _, ns in ipairs(namespaces) do + if type(ns.name) ~= 'string' or type(ns.root) ~= 'string' then + return false, "each namespace must have a name and root string" + end + end + return true, "" end ---Apply filesystem configuration by creating a driver with the given namespaces. @@ -227,58 +227,58 @@ end ---@return boolean ok ---@return string error function FilesystemManager.apply_config(namespaces) - local valid, validate_err = validate_config(namespaces) - if not valid then - return false, validate_err - end - - if not FilesystemManager.started then - return false, "filesystem manager not started" - end - - if FilesystemManager.dev_ev_ch == nil or FilesystemManager.cap_emit_ch == nil then - return false, "channels not initialized (start must be called first)" - end - - -- Spawn non-blocking fiber to create and initialize driver - local ok, spawn_err = FilesystemManager.scope:spawn(function() - stop_previous_driver_and_emit() - - local roots, names, roots_err = build_roots(namespaces) - if roots_err ~= "" then - FilesystemManager.logger:error({ what = 'build_roots_failed', err = tostring(roots_err) }) - return - end - - local driver, init_err = init_driver(roots) - if not driver then - FilesystemManager.logger:error({ what = 'driver_init_failed', err = tostring(init_err) }) - return - end - - local capabilities, cap_err = apply_driver_capabilities(driver) - if not capabilities then - FilesystemManager.logger:error({ what = 'apply_capabilities_failed', err = tostring(cap_err) }) - return - end - - local start_err = start_driver(driver) - if start_err ~= "" then - FilesystemManager.logger:error({ what = 'driver_start_failed', err = tostring(start_err) }) - return - end - - FilesystemManager.drivers["main"] = driver - FilesystemManager.last_names = names - emit_added_device(names, capabilities) - FilesystemManager.logger:debug({ what = 'applied_config_created_driver', namespaces = names }) - end) - - if not ok then - return false, "failed to spawn driver initialization: " .. tostring(spawn_err) - end - - return true, "" + local valid, validate_err = validate_config(namespaces) + if not valid then + return false, validate_err + end + + if not FilesystemManager.started then + return false, "filesystem manager not started" + end + + if FilesystemManager.dev_ev_ch == nil or FilesystemManager.cap_emit_ch == nil then + return false, "channels not initialized (start must be called first)" + end + + -- Spawn non-blocking fiber to create and initialize driver + local ok, spawn_err = FilesystemManager.scope:spawn(function() + stop_previous_driver_and_emit() + + local roots, names, roots_err = build_roots(namespaces) + if roots_err ~= "" then + FilesystemManager.logger:error({ what = 'build_roots_failed', err = tostring(roots_err) }) + return + end + + local driver, init_err = init_driver(roots) + if not driver then + FilesystemManager.logger:error({ what = 'driver_init_failed', err = tostring(init_err) }) + return + end + + local capabilities, cap_err = apply_driver_capabilities(driver) + if not capabilities then + FilesystemManager.logger:error({ what = 'apply_capabilities_failed', err = tostring(cap_err) }) + return + end + + local start_err = start_driver(driver) + if start_err ~= "" then + FilesystemManager.logger:error({ what = 'driver_start_failed', err = tostring(start_err) }) + return + end + + FilesystemManager.drivers["main"] = driver + FilesystemManager.last_names = names + emit_added_device(names, capabilities) + FilesystemManager.logger:debug({ what = 'applied_config_created_driver', namespaces = names }) + end) + + if not ok then + return false, "failed to spawn driver initialization: " .. tostring(spawn_err) + end + + return true, "" end return FilesystemManager diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua index c9d28a64..8f101a19 100644 --- a/src/services/hal/managers/modemcard.lua +++ b/src/services/hal/managers/modemcard.lua @@ -30,131 +30,131 @@ local STOP_TIMEOUT = 5.0 -- seconds ---@field modems table ---@field monitor ModemMonitor? local ModemcardManager = { - started = false, - modem_remove_ch = channel.new(), - modem_detect_ch = channel.new(), - driver_ch = channel.new(), - modems = {}, - monitor = nil, + started = false, + modem_remove_ch = channel.new(), + modem_detect_ch = channel.new(), + driver_ch = channel.new(), + modems = {}, + monitor = nil, } ---Continuously monitors modem add/remove events and publishes them onto ---`ModemcardManager.modem_detect_ch` and `ModemcardManager.modem_remove_ch`. ---@param scope Scope local function detector(scope) - log:debug("Modem Detector: started") - - local monitor - - scope:finally(function () - if monitor and monitor.terminate then - monitor:terminate("modem_detector_scope_exit") - end - if ModemcardManager.monitor == monitor then - ModemcardManager.monitor = nil - end - log:debug("Modem Detector: closed") - end) - - local err - monitor, err = modem_provider.new_monitor() - if not monitor then - error("Modem Detector: failed to create monitor: " .. tostring(err)) - end - ModemcardManager.monitor = monitor - - while true do - local event, mon_err = fibers.perform(monitor:next_event_op()) - if mon_err == "Command closed" then - break - elseif mon_err and mon_err ~= "" then - log:warn({ what = 'unparse_monitor_line', err = tostring(mon_err) }) - elseif event then - ---@cast event ModemMonitorEvent - if event.is_added then - log:debug({ what = 'modem_detected', summary = string.format('modem detected %s', tostring(event.address)), address = event.address }) - ModemcardManager.modem_detect_ch:put(event.address) - else - log:debug({ what = 'modem_removed', summary = string.format('modem removed %s', tostring(event.address)), address = event.address }) - ModemcardManager.modem_remove_ch:put(event.address) - end - end - end + log:debug("Modem Detector: started") + + local monitor + + scope:finally(function () + if monitor and monitor.terminate then + monitor:terminate("modem_detector_scope_exit") + end + if ModemcardManager.monitor == monitor then + ModemcardManager.monitor = nil + end + log:debug("Modem Detector: closed") + end) + + local err + monitor, err = modem_provider.new_monitor() + if not monitor then + error("Modem Detector: failed to create monitor: " .. tostring(err)) + end + ModemcardManager.monitor = monitor + + while true do + local event, mon_err = fibers.perform(monitor:next_event_op()) + if mon_err == "Command closed" then + break + elseif mon_err and mon_err ~= "" then + log:warn({ what = 'unparse_monitor_line', err = tostring(mon_err) }) + elseif event then + ---@cast event ModemMonitorEvent + if event.is_added then + log:debug({ what = 'modem_detected', summary = string.format('modem detected %s', tostring(event.address)), address = event.address }) + ModemcardManager.modem_detect_ch:put(event.address) + else + log:debug({ what = 'modem_removed', summary = string.format('modem removed %s', tostring(event.address)), address = event.address }) + ModemcardManager.modem_remove_ch:put(event.address) + end + end + end end ---Handle modem removal. ---@param dev_ev_ch Channel Device event channel (DeviceEvent messages) ---@param address ModemAddress local function on_remove(dev_ev_ch, address) - if type(address) ~= 'string' or address == '' then - log:error({ what = 'invalid_address_removal' }) - return - end - - log:debug({ what = 'removing_modem', address = address }) - - local driver = ModemcardManager.modems[address] - if driver == nil then - log:error({ what = 'modem_not_found', address = address }) - return - end - - -- Get device, no need to have a fresh value so set cache lifetime to infinity - -- Also asking for a fresh value when the modem may have disconnected could cause errors - local get_ok, primary = driver:get(capability_args.new.ModemGetOpts("device", math.huge)) - if not get_ok then - log:error({ what = 'get_device_failed', address = address, err = tostring(primary) }) - return - end - local device = primary - - fibers.current_scope():spawn(function() - local ok, stop_err = driver:stop(STOP_TIMEOUT) - if not ok then - log:error({ what = 'stop_driver_failed', address = address, err = tostring(stop_err) }) - end - end) - - ModemcardManager.modems[address] = nil - - local device_event, ev_err = hal_types.new.DeviceEvent( - "removed", - "modemcard", - device - ) - if not device_event then - log:error({ what = 'create_device_event_failed', address = address, err = tostring(ev_err) }) - return - end - - dev_ev_ch:put(device_event) + if type(address) ~= 'string' or address == '' then + log:error({ what = 'invalid_address_removal' }) + return + end + + log:debug({ what = 'removing_modem', address = address }) + + local driver = ModemcardManager.modems[address] + if driver == nil then + log:error({ what = 'modem_not_found', address = address }) + return + end + + -- Get device, no need to have a fresh value so set cache lifetime to infinity + -- Also asking for a fresh value when the modem may have disconnected could cause errors + local get_ok, primary = driver:get(capability_args.new.ModemGetOpts("device", math.huge)) + if not get_ok then + log:error({ what = 'get_device_failed', address = address, err = tostring(primary) }) + return + end + local device = primary + + fibers.current_scope():spawn(function() + local ok, stop_err = driver:stop(STOP_TIMEOUT) + if not ok then + log:error({ what = 'stop_driver_failed', address = address, err = tostring(stop_err) }) + end + end) + + ModemcardManager.modems[address] = nil + + local device_event, ev_err = hal_types.new.DeviceEvent( + "removed", + "modemcard", + device + ) + if not device_event then + log:error({ what = 'create_device_event_failed', address = address, err = tostring(ev_err) }) + return + end + + dev_ev_ch:put(device_event) end ---Handle modem detection by creating and initializing a driver. ---@param address ModemAddress ---@return nil local function on_detection(address) - if type(address) ~= 'string' or address == '' then - log:error({ what = 'invalid_address_detection' }) - return - end - - log:debug({ what = 'creating_modem', summary = string.format('creating modem %s', tostring(address)), address = address }) - - local driver, drv_err = modem_driver.new(address, log:child({ modem = address })) - if not driver then - log:error({ what = 'create_driver_failed', address = address, err = tostring(drv_err) }) - return - end - - fibers.current_scope():spawn(function() - local init_err = driver:init() - if init_err ~= "" then - log:error({ what = 'init_driver_failed', address = address, err = init_err }) - return - end - ModemcardManager.driver_ch:put(driver) - end) + if type(address) ~= 'string' or address == '' then + log:error({ what = 'invalid_address_detection' }) + return + end + + log:debug({ what = 'creating_modem', summary = string.format('creating modem %s', tostring(address)), address = address }) + + local driver, drv_err = modem_driver.new(address, log:child({ modem = address })) + if not driver then + log:error({ what = 'create_driver_failed', address = address, err = tostring(drv_err) }) + return + end + + fibers.current_scope():spawn(function() + local init_err = driver:init() + if init_err ~= "" then + log:error({ what = 'init_driver_failed', address = address, err = init_err }) + return + end + ModemcardManager.driver_ch:put(driver) + end) end ---Handle a fully initialized driver by creating the modem device, applying @@ -164,66 +164,66 @@ end ---@param driver Modem ---@return nil local function on_driver(dev_ev_ch, cap_emit_ch, driver) - local address = driver.address - -- Get device, no need to have a fresh value so set cache lifetime to infinity - local get_ok, primary = driver:get(capability_args.new.ModemGetOpts("device", math.huge)) - if not get_ok then - log:error({ what = 'get_device_failed', address = address, err = tostring(primary) }) - return - end - local device = primary - - ModemcardManager.modems[driver.address] = driver - - -- Build capabilities - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if cap_err then - log:error({ what = 'apply_capabilities_failed', address = address, err = tostring(cap_err) }) - return - end - - -- Register the capability before starting the driver. The modem driver - -- starts lifecycle workers that immediately emit retained state such as - -- card/sim changes. If those emissions occur before HAL has registered the - -- capability they are dropped by hal.lua:on_cap_emit(), leaving GSM waiting - -- for a retained state that may never exist. This mirrors the platform, - -- power and sysmon managers: publish the DeviceEvent, wait until HAL has - -- registered it, then start the emitting workers. - local ready_cond = cond.new() - - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", - "modemcard", - device, - { - address = address, - port = device -- the device field holds the usb or pcie port info - }, - capabilities, - ready_cond - ) - if not device_event then - log:error({ what = 'create_device_event_failed', address = address, err = tostring(ev_err) }) - return - end - - -- Notify HAL of the new modem device and wait until the device and its - -- capabilities are registered before starting the driver. - dev_ev_ch:put(device_event) - ready_cond:wait() - - -- Start the driver only after capability registration, so initial retained - -- state emissions cannot race ahead of the registry. - local ok, start_err = driver:start() - if not ok then - log:error({ what = 'start_driver_failed', address = address, err = tostring(start_err) }) - ModemcardManager.modems[driver.address] = nil - local remove_event = hal_types.new.DeviceEvent("removed", "modemcard", device) - if remove_event then - dev_ev_ch:put(remove_event) - end - return - end + local address = driver.address + -- Get device, no need to have a fresh value so set cache lifetime to infinity + local get_ok, primary = driver:get(capability_args.new.ModemGetOpts("device", math.huge)) + if not get_ok then + log:error({ what = 'get_device_failed', address = address, err = tostring(primary) }) + return + end + local device = primary + + ModemcardManager.modems[driver.address] = driver + + -- Build capabilities + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err then + log:error({ what = 'apply_capabilities_failed', address = address, err = tostring(cap_err) }) + return + end + + -- Register the capability before starting the driver. The modem driver + -- starts lifecycle workers that immediately emit retained state such as + -- card/sim changes. If those emissions occur before HAL has registered the + -- capability they are dropped by hal.lua:on_cap_emit(), leaving GSM waiting + -- for a retained state that may never exist. This mirrors the platform, + -- power and sysmon managers: publish the DeviceEvent, wait until HAL has + -- registered it, then start the emitting workers. + local ready_cond = cond.new() + + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", + "modemcard", + device, + { + address = address, + port = device -- the device field holds the usb or pcie port info + }, + capabilities, + ready_cond + ) + if not device_event then + log:error({ what = 'create_device_event_failed', address = address, err = tostring(ev_err) }) + return + end + + -- Notify HAL of the new modem device and wait until the device and its + -- capabilities are registered before starting the driver. + dev_ev_ch:put(device_event) + ready_cond:wait() + + -- Start the driver only after capability registration, so initial retained + -- state emissions cannot race ahead of the registry. + local ok, start_err = driver:start() + if not ok then + log:error({ what = 'start_driver_failed', address = address, err = tostring(start_err) }) + ModemcardManager.modems[driver.address] = nil + local remove_event = hal_types.new.DeviceEvent("removed", "modemcard", device) + if remove_event then + dev_ev_ch:put(remove_event) + end + return + end end ---Modemcard Manager notifies HAL of modem additions/removals. @@ -232,48 +232,48 @@ end ---@param cap_emit_ch Channel Capability emit channel (Emit messages) ---@return nil local function manager(scope, dev_ev_ch, cap_emit_ch) - log:debug("Modemcard Manager: started") - - scope:finally(function () - log:debug("Modemcard Manager: closed") - end) - - while true do - local fault_ops = {} - for address, driver in pairs(ModemcardManager.modems) do - table.insert(fault_ops, driver.scope:fault_op():wrap(function () return address end)) - end - - local fault_op = op.never() - if #fault_ops > 0 then - fault_op = op.choice(unpack(fault_ops)) - end - - local source, msg, err = fibers.perform(op.named_choice{ - detect = ModemcardManager.modem_detect_ch:get_op(), - remove = ModemcardManager.modem_remove_ch:get_op(), - driver = ModemcardManager.driver_ch:get_op(), - driver_fault = fault_op, - }) - - if not msg then - log:error({ what = 'operation_failed', err = tostring(err) }) - break - end - - if source == "detect" then - on_detection(msg) - elseif source == "remove" then - on_remove(dev_ev_ch, msg) - elseif source == "driver" then - on_driver(dev_ev_ch, cap_emit_ch, msg) - elseif source == "driver_fault" then - log:error({ what = 'driver_fault', address = tostring(msg) }) - on_remove(dev_ev_ch, msg) - else - log:error({ what = 'unknown_source', source = tostring(source) }) - end - end + log:debug("Modemcard Manager: started") + + scope:finally(function () + log:debug("Modemcard Manager: closed") + end) + + while true do + local fault_ops = {} + for address, driver in pairs(ModemcardManager.modems) do + table.insert(fault_ops, driver.scope:fault_op():wrap(function () return address end)) + end + + local fault_op = op.never() + if #fault_ops > 0 then + fault_op = op.choice(unpack(fault_ops)) + end + + local source, msg, err = fibers.perform(op.named_choice{ + detect = ModemcardManager.modem_detect_ch:get_op(), + remove = ModemcardManager.modem_remove_ch:get_op(), + driver = ModemcardManager.driver_ch:get_op(), + driver_fault = fault_op, + }) + + if not msg then + log:error({ what = 'operation_failed', err = tostring(err) }) + break + end + + if source == "detect" then + on_detection(msg) + elseif source == "remove" then + on_remove(dev_ev_ch, msg) + elseif source == "driver" then + on_driver(dev_ev_ch, cap_emit_ch, msg) + elseif source == "driver_fault" then + log:error({ what = 'driver_fault', address = tostring(msg) }) + on_remove(dev_ev_ch, msg) + else + log:error({ what = 'unknown_source', source = tostring(source) }) + end + end end ---Starts the Modemcard Manager's detector and manager fibers. @@ -282,33 +282,33 @@ end ---@param cap_emit_ch Channel ---@return string error function ModemcardManager.start(logger, dev_ev_ch, cap_emit_ch) - log = logger - if ModemcardManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - ModemcardManager.scope = scope - - -- Print out manager stack trace if scope closes on a failure - scope:finally(function () - local st, primary = scope:status() - if st == 'failed' then - log:error({ what = 'scope_error', err = tostring(primary) }) - log:debug({ what = 'scope_exit', status = st }) - end - log:debug("Modem Manager: stopped") - end) - - ModemcardManager.scope:spawn(detector) - ModemcardManager.scope:spawn(manager, dev_ev_ch, cap_emit_ch) - - ModemcardManager.started = true - log:debug("Modemcard Manager: started") - return "" + log = logger + if ModemcardManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + ModemcardManager.scope = scope + + -- Print out manager stack trace if scope closes on a failure + scope:finally(function () + local st, primary = scope:status() + if st == 'failed' then + log:error({ what = 'scope_error', err = tostring(primary) }) + log:debug({ what = 'scope_exit', status = st }) + end + log:debug("Modem Manager: stopped") + end) + + ModemcardManager.scope:spawn(detector) + ModemcardManager.scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + ModemcardManager.started = true + log:debug("Modemcard Manager: started") + return "" end ---Stops the Modemcard Manager. @@ -316,40 +316,40 @@ end ---@return boolean ok ---@return string error function ModemcardManager.stop(timeout) - if not ModemcardManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - - for address, driver in pairs(ModemcardManager.modems) do - local ok, stop_err = driver:stop(STOP_TIMEOUT) - if not ok then - log:error({ what = "stop_driver_failed", address = address, err = tostring(stop_err) }) - end - end - ModemcardManager.modems = {} - - local monitor = ModemcardManager.monitor - if monitor and monitor.shutdown_op then - local ok, mon_err = fibers.perform(monitor:shutdown_op(1.0)) - if not ok then - log:error({ what = "stop_modem_monitor_failed", err = tostring(mon_err) }) - end - end - ModemcardManager.monitor = nil - - ModemcardManager.scope:cancel("modemcard manager stopping") - - local source = fibers.perform(op.named_choice { - join = ModemcardManager.scope:join_op(), - timeout = sleep.sleep_op(timeout) - }) - - if source == "timeout" then - return false, "modemcard manager stop timeout" - end - ModemcardManager.started = false - return true, "" + if not ModemcardManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + + for address, driver in pairs(ModemcardManager.modems) do + local ok, stop_err = driver:stop(STOP_TIMEOUT) + if not ok then + log:error({ what = "stop_driver_failed", address = address, err = tostring(stop_err) }) + end + end + ModemcardManager.modems = {} + + local monitor = ModemcardManager.monitor + if monitor and monitor.shutdown_op then + local ok, mon_err = fibers.perform(monitor:shutdown_op(1.0)) + if not ok then + log:error({ what = "stop_modem_monitor_failed", err = tostring(mon_err) }) + end + end + ModemcardManager.monitor = nil + + ModemcardManager.scope:cancel("modemcard manager stopping") + + local source = fibers.perform(op.named_choice { + join = ModemcardManager.scope:join_op(), + timeout = sleep.sleep_op(timeout) + }) + + if source == "timeout" then + return false, "modemcard manager stop timeout" + end + ModemcardManager.started = false + return true, "" end ---Apply configuration for modemcard manager (no-op, kept for interface consistency). @@ -357,8 +357,8 @@ end ---@return boolean ok ---@return string error function ModemcardManager.apply_config(namespaces) -- luacheck: ignore - -- No-op: modemcard manager does not support dynamic configuration - return true, "" + -- No-op: modemcard manager does not support dynamic configuration + return true, "" end return ModemcardManager diff --git a/src/services/hal/managers/platform.lua b/src/services/hal/managers/platform.lua index 5ee0f8ee..eb65906d 100644 --- a/src/services/hal/managers/platform.lua +++ b/src/services/hal/managers/platform.lua @@ -18,9 +18,9 @@ local cond = require "fibers.cond" local STOP_TIMEOUT = 5.0 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class PlatformManager @@ -28,9 +28,9 @@ end ---@field started boolean ---@field logger Logger? local PlatformManager = { - started = false, - scope = nil, - logger = nil, + started = false, + scope = nil, + logger = nil, } ---- manager fiber ---- @@ -39,47 +39,47 @@ local PlatformManager = { ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function manager(scope, dev_ev_ch, cap_emit_ch) - dlog(PlatformManager.logger, 'debug', { what = 'started' }) - - scope:finally(function() - dlog(PlatformManager.logger, 'debug', { what = 'closed' }) - end) - - local driver_logger = nil - if PlatformManager.logger and PlatformManager.logger.child then - driver_logger = PlatformManager.logger:child({ component = 'driver', driver = 'platform', id = '1' }) - end - - local driver, drv_err = platform_driver_any.new(driver_logger) - if not driver then - error("Platform Manager: failed to create platform driver: " .. tostring(drv_err)) - end - - local init_err = driver:init() - if init_err ~= "" then - error("Platform Manager: failed to init driver: " .. tostring(init_err)) - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if cap_err ~= "" then - error("Platform Manager: failed to bind capabilities: " .. tostring(cap_err)) - end - - local ready_cond = cond.new() - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", "platform", "1", {}, capabilities, ready_cond) - if not device_event then - error("Platform Manager: failed to create DeviceEvent: " .. tostring(ev_err)) - end - dev_ev_ch:put(device_event) - ready_cond:wait() - - local ok, start_err = driver:start() - if not ok then - error("Platform Manager: failed to start driver: " .. tostring(start_err)) - end - - dlog(PlatformManager.logger, 'debug', { what = 'device_registered' }) + dlog(PlatformManager.logger, 'debug', { what = 'started' }) + + scope:finally(function() + dlog(PlatformManager.logger, 'debug', { what = 'closed' }) + end) + + local driver_logger = nil + if PlatformManager.logger and PlatformManager.logger.child then + driver_logger = PlatformManager.logger:child({ component = 'driver', driver = 'platform', id = '1' }) + end + + local driver, drv_err = platform_driver_any.new(driver_logger) + if not driver then + error("Platform Manager: failed to create platform driver: " .. tostring(drv_err)) + end + + local init_err = driver:init() + if init_err ~= "" then + error("Platform Manager: failed to init driver: " .. tostring(init_err)) + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err ~= "" then + error("Platform Manager: failed to bind capabilities: " .. tostring(cap_err)) + end + + local ready_cond = cond.new() + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", "platform", "1", {}, capabilities, ready_cond) + if not device_event then + error("Platform Manager: failed to create DeviceEvent: " .. tostring(ev_err)) + end + dev_ev_ch:put(device_event) + ready_cond:wait() + + local ok, start_err = driver:start() + if not ok then + error("Platform Manager: failed to start driver: " .. tostring(start_err)) + end + + dlog(PlatformManager.logger, 'debug', { what = 'device_registered' }) end ---- public interface ---- @@ -89,59 +89,59 @@ end ---@param cap_emit_ch Channel ---@return string error function PlatformManager.start(logger, dev_ev_ch, cap_emit_ch) - if PlatformManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - PlatformManager.scope = scope - PlatformManager.logger = logger - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(PlatformManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(PlatformManager.logger, 'debug', { what = 'stopped' }) - end) - - scope:spawn(manager, dev_ev_ch, cap_emit_ch) - - PlatformManager.started = true - dlog(PlatformManager.logger, 'debug', { what = 'start_called' }) - return "" + if PlatformManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + PlatformManager.scope = scope + PlatformManager.logger = logger + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(PlatformManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(PlatformManager.logger, 'debug', { what = 'stopped' }) + end) + + scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + PlatformManager.started = true + dlog(PlatformManager.logger, 'debug', { what = 'start_called' }) + return "" end ---@param timeout number? ---@return boolean ok ---@return string error function PlatformManager.stop(timeout) - if not PlatformManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - PlatformManager.scope:cancel('platform manager stopped') - - local source = fibers.perform(op.named_choice { - join = PlatformManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - - if source == 'timeout' then - return false, "platform manager stop timeout" - end - PlatformManager.started = false - return true, "" + if not PlatformManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + PlatformManager.scope:cancel('platform manager stopped') + + local source = fibers.perform(op.named_choice { + join = PlatformManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + + if source == 'timeout' then + return false, "platform manager stop timeout" + end + PlatformManager.started = false + return true, "" end ---@param namespaces table ---@return boolean ok ---@return string error function PlatformManager.apply_config(namespaces) -- luacheck: ignore - return true, "" + return true, "" end return PlatformManager diff --git a/src/services/hal/managers/power.lua b/src/services/hal/managers/power.lua index 07605928..947c3357 100644 --- a/src/services/hal/managers/power.lua +++ b/src/services/hal/managers/power.lua @@ -18,9 +18,9 @@ local cond = require "fibers.cond" local STOP_TIMEOUT = 5.0 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class PowerManager @@ -28,9 +28,9 @@ end ---@field started boolean ---@field logger Logger? local PowerManager = { - started = false, - scope = nil, - logger = nil, + started = false, + scope = nil, + logger = nil, } ---- manager fiber ---- @@ -39,47 +39,47 @@ local PowerManager = { ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function manager(scope, dev_ev_ch, cap_emit_ch) - dlog(PowerManager.logger, 'debug', { what = 'started' }) - - scope:finally(function() - dlog(PowerManager.logger, 'debug', { what = 'closed' }) - end) - - local driver_logger = nil - if PowerManager.logger and PowerManager.logger.child then - driver_logger = PowerManager.logger:child({ component = 'driver', driver = 'power', id = '1' }) - end - - local driver, drv_err = power_driver_any.new(driver_logger) - if not driver then - error("Power Manager: failed to create power driver: " .. tostring(drv_err)) - end - - local init_err = driver:init() - if init_err ~= "" then - error("Power Manager: failed to init driver: " .. tostring(init_err)) - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if cap_err ~= "" then - error("Power Manager: failed to bind capabilities: " .. tostring(cap_err)) - end - - local ready_cond = cond.new() - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", "power", "1", {}, capabilities, ready_cond) - if not device_event then - error("Power Manager: failed to create DeviceEvent: " .. tostring(ev_err)) - end - dev_ev_ch:put(device_event) - ready_cond:wait() - - local ok, start_err = driver:start() - if not ok then - error("Power Manager: failed to start driver: " .. tostring(start_err)) - end - - dlog(PowerManager.logger, 'debug', { what = 'device_registered' }) + dlog(PowerManager.logger, 'debug', { what = 'started' }) + + scope:finally(function() + dlog(PowerManager.logger, 'debug', { what = 'closed' }) + end) + + local driver_logger = nil + if PowerManager.logger and PowerManager.logger.child then + driver_logger = PowerManager.logger:child({ component = 'driver', driver = 'power', id = '1' }) + end + + local driver, drv_err = power_driver_any.new(driver_logger) + if not driver then + error("Power Manager: failed to create power driver: " .. tostring(drv_err)) + end + + local init_err = driver:init() + if init_err ~= "" then + error("Power Manager: failed to init driver: " .. tostring(init_err)) + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err ~= "" then + error("Power Manager: failed to bind capabilities: " .. tostring(cap_err)) + end + + local ready_cond = cond.new() + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", "power", "1", {}, capabilities, ready_cond) + if not device_event then + error("Power Manager: failed to create DeviceEvent: " .. tostring(ev_err)) + end + dev_ev_ch:put(device_event) + ready_cond:wait() + + local ok, start_err = driver:start() + if not ok then + error("Power Manager: failed to start driver: " .. tostring(start_err)) + end + + dlog(PowerManager.logger, 'debug', { what = 'device_registered' }) end ---- public interface ---- @@ -89,59 +89,59 @@ end ---@param cap_emit_ch Channel ---@return string error function PowerManager.start(logger, dev_ev_ch, cap_emit_ch) - if PowerManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - PowerManager.scope = scope - PowerManager.logger = logger - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(PowerManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(PowerManager.logger, 'debug', { what = 'stopped' }) - end) - - scope:spawn(manager, dev_ev_ch, cap_emit_ch) - - PowerManager.started = true - dlog(PowerManager.logger, 'debug', { what = 'start_called' }) - return "" + if PowerManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + PowerManager.scope = scope + PowerManager.logger = logger + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(PowerManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(PowerManager.logger, 'debug', { what = 'stopped' }) + end) + + scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + PowerManager.started = true + dlog(PowerManager.logger, 'debug', { what = 'start_called' }) + return "" end ---@param timeout number? ---@return boolean ok ---@return string error function PowerManager.stop(timeout) - if not PowerManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - PowerManager.scope:cancel('power manager stopped') - - local source = fibers.perform(op.named_choice { - join = PowerManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - - if source == 'timeout' then - return false, "power manager stop timeout" - end - PowerManager.started = false - return true, "" + if not PowerManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + PowerManager.scope:cancel('power manager stopped') + + local source = fibers.perform(op.named_choice { + join = PowerManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + + if source == 'timeout' then + return false, "power manager stop timeout" + end + PowerManager.started = false + return true, "" end ---@param namespaces table ---@return boolean ok ---@return string error function PowerManager.apply_config(namespaces) -- luacheck: ignore - return true, "" + return true, "" end return PowerManager diff --git a/src/services/hal/managers/sysmon.lua b/src/services/hal/managers/sysmon.lua index 3e9550c6..43cb7a76 100644 --- a/src/services/hal/managers/sysmon.lua +++ b/src/services/hal/managers/sysmon.lua @@ -30,9 +30,9 @@ local cond = require "fibers.cond" local STOP_TIMEOUT = 5.0 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class SysmonManager @@ -40,19 +40,19 @@ end ---@field started boolean ---@field logger Logger? local SysmonManager = { - started = false, - scope = nil, - logger = nil, + started = false, + scope = nil, + logger = nil, } ---@param driver string ---@param id string ---@return Logger? local function child_logger(driver, id) - if SysmonManager.logger and SysmonManager.logger.child then - return SysmonManager.logger:child({ component = 'driver', driver = driver, id = id }) - end - return nil + if SysmonManager.logger and SysmonManager.logger.child then + return SysmonManager.logger:child({ component = 'driver', driver = driver, id = id }) + end + return nil end ---- helpers ---- @@ -60,24 +60,24 @@ end --- List /sys/class/thermal/ and return table of {zone_id, sysfs_dir} entries. ---@return table zones list of {zone_id: string, sysfs_dir: string} local function discover_thermal_zones() - local cmd = exec.command { 'ls', '/sys/class/thermal/', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } - local out, status, code = fibers.perform(cmd:output_op()) - if status ~= 'exited' or code ~= 0 then - dlog(SysmonManager.logger, 'warn', { what = 'thermal_discovery_failed', code = tostring(code) }) - return {} - end - - local zones = {} - for entry in (out or ''):gmatch('[^\n]+') do - local n = entry:match('^thermal_zone(%d+)$') - if n then - zones[#zones + 1] = { - zone_id = 'zone' .. n, - sysfs_dir = '/sys/class/thermal/thermal_zone' .. n, - } - end - end - return zones + local cmd = exec.command { 'ls', '/sys/class/thermal/', stdin = 'null', stdout = 'pipe', stderr = 'stdout' } + local out, status, code = fibers.perform(cmd:output_op()) + if status ~= 'exited' or code ~= 0 then + dlog(SysmonManager.logger, 'warn', { what = 'thermal_discovery_failed', code = tostring(code) }) + return {} + end + + local zones = {} + for entry in (out or ''):gmatch('[^\n]+') do + local n = entry:match('^thermal_zone(%d+)$') + if n then + zones[#zones + 1] = { + zone_id = 'zone' .. n, + sysfs_dir = '/sys/class/thermal/thermal_zone' .. n, + } + end + end + return zones end --- Create, bind, start a driver and emit a HAL DeviceEvent. @@ -89,37 +89,37 @@ end ---@param cap_emit_ch Channel ---@return boolean ok local function register_driver(driver, class, id, meta, dev_ev_ch, cap_emit_ch) - local init_err = driver:init() - if init_err ~= "" then - dlog(SysmonManager.logger, 'error', { what = 'driver_init_failed', class = class, id = id, err = init_err }) - return false - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if cap_err ~= "" then - dlog(SysmonManager.logger, 'error', { - what = 'bind_capabilities_failed', class = class, id = id, err = cap_err, - }) - return false - end - - local ready_cond = cond.new() - local device_event, ev_err = hal_types.new.DeviceEvent("added", class, id, meta, capabilities, ready_cond) - if not device_event then - dlog(SysmonManager.logger, 'error', { - what = 'device_event_create_failed', class = class, id = id, err = ev_err, - }) - return false - end - dev_ev_ch:put(device_event) - ready_cond:wait() - - local ok, start_err = driver:start() - if not ok then - dlog(SysmonManager.logger, 'error', { what = 'driver_start_failed', class = class, id = id, err = start_err }) - return false - end - return true + local init_err = driver:init() + if init_err ~= "" then + dlog(SysmonManager.logger, 'error', { what = 'driver_init_failed', class = class, id = id, err = init_err }) + return false + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err ~= "" then + dlog(SysmonManager.logger, 'error', { + what = 'bind_capabilities_failed', class = class, id = id, err = cap_err, + }) + return false + end + + local ready_cond = cond.new() + local device_event, ev_err = hal_types.new.DeviceEvent("added", class, id, meta, capabilities, ready_cond) + if not device_event then + dlog(SysmonManager.logger, 'error', { + what = 'device_event_create_failed', class = class, id = id, err = ev_err, + }) + return false + end + dev_ev_ch:put(device_event) + ready_cond:wait() + + local ok, start_err = driver:start() + if not ok then + dlog(SysmonManager.logger, 'error', { what = 'driver_start_failed', class = class, id = id, err = start_err }) + return false + end + return true end ---- manager fiber ---- @@ -128,54 +128,54 @@ end ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function manager(scope, dev_ev_ch, cap_emit_ch) - dlog(SysmonManager.logger, 'debug', { what = 'started' }) - - scope:finally(function() - dlog(SysmonManager.logger, 'debug', { what = 'closed' }) - end) - - -- ── CPU ── - local cpu_drv, cpu_err = cpu_driver_any.new(child_logger('cpu', '1')) - if not cpu_drv then - dlog(SysmonManager.logger, 'error', { - what = 'driver_create_failed', class = 'cpu', id = '1', err = cpu_err, - }) - else - register_driver(cpu_drv, 'cpu', '1', {}, dev_ev_ch, cap_emit_ch) - end - - -- ── Memory ── - local mem_drv, mem_err = memory_driver_any.new(child_logger('memory', '1')) - if not mem_drv then - dlog(SysmonManager.logger, 'error', { - what = 'driver_create_failed', class = 'memory', id = '1', err = mem_err, - }) - else - register_driver(mem_drv, 'memory', '1', {}, dev_ev_ch, cap_emit_ch) - end - - -- ── Thermal zones ── - local zones = discover_thermal_zones() - if #zones == 0 then - dlog(SysmonManager.logger, 'info', { what = 'no_thermal_zones_discovered' }) - end - for _, zone in ipairs(zones) do - local therm_drv, therm_err = thermal_driver_any.new( - zone.zone_id, - zone.sysfs_dir, - child_logger('thermal', zone.zone_id) - ) - if not therm_drv then - dlog(SysmonManager.logger, 'error', { - what = 'driver_create_failed', class = 'thermal', id = zone.zone_id, err = therm_err, - }) - else - register_driver(therm_drv, 'thermal', zone.zone_id, - { zone = zone.zone_id, path = zone.sysfs_dir }, dev_ev_ch, cap_emit_ch) - end - end - - dlog(SysmonManager.logger, 'debug', { what = 'all_devices_registered' }) + dlog(SysmonManager.logger, 'debug', { what = 'started' }) + + scope:finally(function() + dlog(SysmonManager.logger, 'debug', { what = 'closed' }) + end) + + -- ── CPU ── + local cpu_drv, cpu_err = cpu_driver_any.new(child_logger('cpu', '1')) + if not cpu_drv then + dlog(SysmonManager.logger, 'error', { + what = 'driver_create_failed', class = 'cpu', id = '1', err = cpu_err, + }) + else + register_driver(cpu_drv, 'cpu', '1', {}, dev_ev_ch, cap_emit_ch) + end + + -- ── Memory ── + local mem_drv, mem_err = memory_driver_any.new(child_logger('memory', '1')) + if not mem_drv then + dlog(SysmonManager.logger, 'error', { + what = 'driver_create_failed', class = 'memory', id = '1', err = mem_err, + }) + else + register_driver(mem_drv, 'memory', '1', {}, dev_ev_ch, cap_emit_ch) + end + + -- ── Thermal zones ── + local zones = discover_thermal_zones() + if #zones == 0 then + dlog(SysmonManager.logger, 'info', { what = 'no_thermal_zones_discovered' }) + end + for _, zone in ipairs(zones) do + local therm_drv, therm_err = thermal_driver_any.new( + zone.zone_id, + zone.sysfs_dir, + child_logger('thermal', zone.zone_id) + ) + if not therm_drv then + dlog(SysmonManager.logger, 'error', { + what = 'driver_create_failed', class = 'thermal', id = zone.zone_id, err = therm_err, + }) + else + register_driver(therm_drv, 'thermal', zone.zone_id, + { zone = zone.zone_id, path = zone.sysfs_dir }, dev_ev_ch, cap_emit_ch) + end + end + + dlog(SysmonManager.logger, 'debug', { what = 'all_devices_registered' }) end ---- public interface ---- @@ -185,59 +185,59 @@ end ---@param cap_emit_ch Channel ---@return string error function SysmonManager.start(logger, dev_ev_ch, cap_emit_ch) - if SysmonManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - SysmonManager.scope = scope - SysmonManager.logger = logger - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(SysmonManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(SysmonManager.logger, 'debug', { what = 'stopped' }) - end) - - scope:spawn(manager, dev_ev_ch, cap_emit_ch) - - SysmonManager.started = true - dlog(SysmonManager.logger, 'debug', { what = 'start_called' }) - return "" + if SysmonManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + SysmonManager.scope = scope + SysmonManager.logger = logger + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(SysmonManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(SysmonManager.logger, 'debug', { what = 'stopped' }) + end) + + scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + SysmonManager.started = true + dlog(SysmonManager.logger, 'debug', { what = 'start_called' }) + return "" end ---@param timeout number? ---@return boolean ok ---@return string error function SysmonManager.stop(timeout) - if not SysmonManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - SysmonManager.scope:cancel('sysmon manager stopped') - - local source = fibers.perform(op.named_choice { - join = SysmonManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - - if source == 'timeout' then - return false, "sysmon manager stop timeout" - end - SysmonManager.started = false - return true, "" + if not SysmonManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + SysmonManager.scope:cancel('sysmon manager stopped') + + local source = fibers.perform(op.named_choice { + join = SysmonManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + + if source == 'timeout' then + return false, "sysmon manager stop timeout" + end + SysmonManager.started = false + return true, "" end ---@param namespaces table ---@return boolean ok ---@return string error function SysmonManager.apply_config(namespaces) -- luacheck: ignore - return true, "" + return true, "" end return SysmonManager diff --git a/src/services/hal/managers/time.lua b/src/services/hal/managers/time.lua index f431a86c..3e7742f6 100644 --- a/src/services/hal/managers/time.lua +++ b/src/services/hal/managers/time.lua @@ -26,11 +26,11 @@ local HOTPLUG_SCRIPT_NAME = "ntp" ---@field dev_ev_ch Channel? ---@field cap_emit_ch Channel? local TimeManager = { - started = false, - driver = nil, - dev_ev_ch = nil, - cap_emit_ch = nil, - logger = nil, + started = false, + driver = nil, + dev_ev_ch = nil, + cap_emit_ch = nil, + logger = nil, } ---- Internal Utilities ---- @@ -39,51 +39,51 @@ local TimeManager = { ---@param driver TimeDriverHandle ---@param capabilities Capability[] local function emit_device_added(driver, capabilities) - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", - "time", - driver.id, - { source = "ntp" }, - capabilities - ) - if not device_event then - TimeManager.logger:error({ what = 'device_added_event_failed', err = tostring(ev_err) }) - return - end - TimeManager.dev_ev_ch:put(device_event) + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", + "time", + driver.id, + { source = "ntp" }, + capabilities + ) + if not device_event then + TimeManager.logger:error({ what = 'device_added_event_failed', err = tostring(ev_err) }) + return + end + TimeManager.dev_ev_ch:put(device_event) end ---Emit a HAL device-removed event for the time capability provider. ---@param driver TimeDriverHandle local function emit_device_removed(driver) - local device_event, ev_err = hal_types.new.DeviceEvent( - "removed", - "time", - driver.id, - {} - ) - if not device_event then - TimeManager.logger:error({ what = 'device_removed_event_failed', err = tostring(ev_err) }) - return - end - TimeManager.dev_ev_ch:put(device_event) + local device_event, ev_err = hal_types.new.DeviceEvent( + "removed", + "time", + driver.id, + {} + ) + if not device_event then + TimeManager.logger:error({ what = 'device_removed_event_failed', err = tostring(ev_err) }) + return + end + TimeManager.dev_ev_ch:put(device_event) end ---Stop the currently running driver (if any) and notify HAL that the device was ---removed. Safe to call when no driver is running. ---@return nil local function stop_existing_driver() - local prev = TimeManager.driver - if not prev then return end + local prev = TimeManager.driver + if not prev then return end - TimeManager.logger:debug({ what = 'stopping_existing_driver' }) - local ok, stop_err = prev:stop(STOP_TIMEOUT) - if not ok then - TimeManager.logger:warn({ what = 'driver_stop_failed', err = tostring(stop_err) }) - end + TimeManager.logger:debug({ what = 'stopping_existing_driver' }) + local ok, stop_err = prev:stop(STOP_TIMEOUT) + if not ok then + TimeManager.logger:warn({ what = 'driver_stop_failed', err = tostring(stop_err) }) + end - emit_device_removed(prev) - TimeManager.driver = nil + emit_device_removed(prev) + TimeManager.driver = nil end ---Run a command and require a zero exit status. @@ -91,47 +91,47 @@ end ---@return boolean ok ---@return string? error local function run_checked(...) - local argv = { ... } - local spec = { stdin = 'null', stdout = 'null', stderr = 'null' } - for i = 1, #argv do spec[i] = argv[i] end - local status, code, _, err = fibers.perform(exec.command(spec):run_op()) - if status ~= 'exited' or code ~= 0 then - return false, tostring(err or ("exit code " .. tostring(code))) - end - return true, nil + local argv = { ... } + local spec = { stdin = 'null', stdout = 'null', stderr = 'null' } + for i = 1, #argv do spec[i] = argv[i] end + local status, code, _, err = fibers.perform(exec.command(spec):run_op()) + if status ~= 'exited' or code ~= 0 then + return false, tostring(err or ("exit code " .. tostring(code))) + end + return true, nil end ---Resolve the directory that contains this manager file. ---@return string dir local function manager_dir() - local source = debug.getinfo(1, 'S').source or '' - source = source:gsub('^@', '') - return source:match('^(.*)/[^/]+$') or '.' + local source = debug.getinfo(1, 'S').source or '' + source = source:gsub('^@', '') + return source:match('^(.*)/[^/]+$') or '.' end ---Install the NTP hotplug script into /etc/hotplug.d/ntp. ---@return boolean ok ---@return string? error local function install_ntp_hotplug_script() - local src = manager_dir() .. "/time/" .. HOTPLUG_SCRIPT_NAME - local dst = HOTPLUG_DIR .. "/" .. HOTPLUG_SCRIPT_NAME + local src = manager_dir() .. "/time/" .. HOTPLUG_SCRIPT_NAME + local dst = HOTPLUG_DIR .. "/" .. HOTPLUG_SCRIPT_NAME - local ok, err = run_checked("mkdir", "-p", HOTPLUG_DIR) - if not ok then - return false, "failed to create hotplug directory: " .. tostring(err) - end + local ok, err = run_checked("mkdir", "-p", HOTPLUG_DIR) + if not ok then + return false, "failed to create hotplug directory: " .. tostring(err) + end - ok, err = run_checked("cp", src, dst) - if not ok then - return false, "failed to copy hotplug script from " .. src .. ": " .. tostring(err) - end + ok, err = run_checked("cp", src, dst) + if not ok then + return false, "failed to copy hotplug script from " .. src .. ": " .. tostring(err) + end - ok, err = run_checked("chmod", "+x", dst) - if not ok then - return false, "failed to chmod hotplug script: " .. tostring(err) - end + ok, err = run_checked("chmod", "+x", dst) + if not ok then + return false, "failed to chmod hotplug script: " .. tostring(err) + end - return true, nil + return true, nil end ---Initialise, apply capabilities, and start a new TimeDriver. Stops any previously @@ -139,41 +139,41 @@ end ---operations are safe. ---@return nil local function bring_up_driver() - stop_existing_driver() - - local installed, install_err = install_ntp_hotplug_script() - if not installed then - TimeManager.logger:error({ what = 'hotplug_script_install_failed', err = tostring(install_err) }) - return - end - - local driver, new_err = time_driver.new(TimeManager.logger:child({ component = 'driver' })) - if not driver then - TimeManager.logger:error({ what = 'driver_create_failed', err = tostring(new_err) }) - return - end - - local init_err = driver:init() - if init_err ~= "" then - TimeManager.logger:error({ what = 'driver_init_failed', err = tostring(init_err) }) - return - end - - local capabilities, cap_err = driver:capabilities(TimeManager.cap_emit_ch) - if not capabilities then - TimeManager.logger:error({ what = 'driver_capabilities_failed', err = tostring(cap_err) }) - return - end - - local ok, start_err = driver:start() - if not ok then - TimeManager.logger:error({ what = 'driver_start_failed', err = tostring(start_err) }) - return - end - - TimeManager.driver = driver - emit_device_added(driver, capabilities) - TimeManager.logger:debug({ what = 'driver_started', cap_id = tostring(driver.id) }) + stop_existing_driver() + + local installed, install_err = install_ntp_hotplug_script() + if not installed then + TimeManager.logger:error({ what = 'hotplug_script_install_failed', err = tostring(install_err) }) + return + end + + local driver, new_err = time_driver.new(TimeManager.logger:child({ component = 'driver' })) + if not driver then + TimeManager.logger:error({ what = 'driver_create_failed', err = tostring(new_err) }) + return + end + + local init_err = driver:init() + if init_err ~= "" then + TimeManager.logger:error({ what = 'driver_init_failed', err = tostring(init_err) }) + return + end + + local capabilities, cap_err = driver:capabilities(TimeManager.cap_emit_ch) + if not capabilities then + TimeManager.logger:error({ what = 'driver_capabilities_failed', err = tostring(cap_err) }) + return + end + + local ok, start_err = driver:start() + if not ok then + TimeManager.logger:error({ what = 'driver_start_failed', err = tostring(start_err) }) + return + end + + TimeManager.driver = driver + emit_device_added(driver, capabilities) + TimeManager.logger:debug({ what = 'driver_started', cap_id = tostring(driver.id) }) end ---- Manager Lifecycle ---- @@ -183,31 +183,31 @@ end ---@param cap_emit_ch Channel Capability emit channel (Emit messages to HAL) ---@return string error Empty string on success. function TimeManager.start(logger, dev_ev_ch, cap_emit_ch) - if TimeManager.started then - return "already started" - end - - local scope, sc_err = fibers.current_scope():child() - if not scope then - return "failed to create child scope: " .. tostring(sc_err) - end - - TimeManager.scope = scope - TimeManager.logger = logger - TimeManager.dev_ev_ch = dev_ev_ch - TimeManager.cap_emit_ch = cap_emit_ch - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - logger:error({ what = 'scope_failed', err = tostring(primary), status = st }) - end - logger:debug({ what = 'stopped' }) - end) - - TimeManager.started = true - logger:debug({ what = 'started' }) - return "" + if TimeManager.started then + return "already started" + end + + local scope, sc_err = fibers.current_scope():child() + if not scope then + return "failed to create child scope: " .. tostring(sc_err) + end + + TimeManager.scope = scope + TimeManager.logger = logger + TimeManager.dev_ev_ch = dev_ev_ch + TimeManager.cap_emit_ch = cap_emit_ch + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + logger:error({ what = 'scope_failed', err = tostring(primary), status = st }) + end + logger:debug({ what = 'stopped' }) + end) + + TimeManager.started = true + logger:debug({ what = 'started' }) + return "" end ---Stop the Time Manager and its driver. Cancels the manager scope which will @@ -216,24 +216,24 @@ end ---@return boolean ok ---@return string error function TimeManager.stop(timeout) - if not TimeManager.started then - return false, "not started" - end + if not TimeManager.started then + return false, "not started" + end - timeout = timeout or STOP_TIMEOUT - TimeManager.scope:cancel() + timeout = timeout or STOP_TIMEOUT + TimeManager.scope:cancel() - local source = fibers.perform(op.named_choice { - join = TimeManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) + local source = fibers.perform(op.named_choice { + join = TimeManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) - if source == 'timeout' then - return false, "time manager stop timeout" - end + if source == 'timeout' then + return false, "time manager stop timeout" + end - TimeManager.started = false - return true, "" + TimeManager.started = false + return true, "" end ---Apply time manager configuration. Spawns a fiber to create and start the time @@ -243,23 +243,23 @@ end ---@return boolean ok ---@return string error function TimeManager.apply_config(config) -- luacheck: ignore config - if not TimeManager.started then - return false, "time manager not started" - end - if TimeManager.dev_ev_ch == nil or TimeManager.cap_emit_ch == nil then - return false, "channels not initialized (start must be called first)" - end - - TimeManager.logger:debug({ what = 'config_received' }) - - local ok, spawn_err = TimeManager.scope:spawn(function() - bring_up_driver() - end) - if not ok then - return false, "failed to spawn driver initialization: " .. tostring(spawn_err) - end - - return true, "" + if not TimeManager.started then + return false, "time manager not started" + end + if TimeManager.dev_ev_ch == nil or TimeManager.cap_emit_ch == nil then + return false, "channels not initialized (start must be called first)" + end + + TimeManager.logger:debug({ what = 'config_received' }) + + local ok, spawn_err = TimeManager.scope:spawn(function() + bring_up_driver() + end) + if not ok then + return false, "failed to spawn driver initialization: " .. tostring(spawn_err) + end + + return true, "" end return TimeManager diff --git a/src/services/hal/managers/uart.lua b/src/services/hal/managers/uart.lua index 046aaf16..8f2b5403 100644 --- a/src/services/hal/managers/uart.lua +++ b/src/services/hal/managers/uart.lua @@ -12,7 +12,7 @@ local resource = require 'devicecode.support.resource' local driver_mod = require 'services.hal.drivers.uart' local M = { - api_mode = 'op_only', + api_mode = 'op_only', } @@ -28,14 +28,14 @@ local STOP_TIMEOUT = 5.0 ---@field generation integer ---@field drivers table local S = { - started = false, - scope = nil, - logger = nil, - dev_ev_ch = nil, - cap_emit_ch = nil, - cfg_ch = nil, + started = false, + scope = nil, + logger = nil, + dev_ev_ch = nil, + cap_emit_ch = nil, + cfg_ch = nil, generation = 0, - drivers = {}, + drivers = {}, } local function finalise_manager_scope(scope, generation) @@ -57,66 +57,66 @@ local function finalise_manager_scope(scope, generation) end local function valid_mode(mode) - return mode == nil - or mode == '8N1' - or mode == '7E1' - or mode == '8O1' + return mode == nil + or mode == '8N1' + or mode == '7E1' + or mode == '8O1' end local function is_sequence(t) - if type(t) ~= 'table' then - return false - end - - local n = 0 - for k in pairs(t) do - if type(k) ~= 'number' or k < 1 or k % 1 ~= 0 then - return false - end - n = n + 1 - end - - return n == #t + if type(t) ~= 'table' then + return false + end + + local n = 0 + for k in pairs(t) do + if type(k) ~= 'number' or k < 1 or k % 1 ~= 0 then + return false + end + n = n + 1 + end + + return n == #t end local function normalise_config(raw) - if type(raw) ~= 'table' then - return nil, 'uart config must be a table with serial_ports list' - end + if type(raw) ~= 'table' then + return nil, 'uart config must be a table with serial_ports list' + end - for k in pairs(raw) do - if k ~= 'serial_ports' then - return nil, 'uart config only supports serial_ports' - end - end + for k in pairs(raw) do + if k ~= 'serial_ports' then + return nil, 'uart config only supports serial_ports' + end + end - if not is_sequence(raw.serial_ports) then - return nil, 'uart serial_ports must be a list' - end + if not is_sequence(raw.serial_ports) then + return nil, 'uart serial_ports must be a list' + end - return raw.serial_ports, nil + return raw.serial_ports, nil end local function validate_config(entries) - for _, entry in ipairs(entries) do - if type(entry) ~= 'table' then - return false, 'each uart entry must be a table' - end - if type(entry.id) ~= 'string' or entry.id == '' then - return false, 'uart entry id must be a non-empty string' - end - if type(entry.path) ~= 'string' or entry.path == '' then - return false, 'uart entry path must be a non-empty string' - end - if entry.baud ~= nil and (type(entry.baud) ~= 'number' or entry.baud <= 0 or entry.baud % 1 ~= 0) then - return false, 'uart entry baud must be a positive integer' - end - if not valid_mode(entry.mode) then - return false, 'uart entry mode is invalid' - end - end - - return true, nil + for _, entry in ipairs(entries) do + if type(entry) ~= 'table' then + return false, 'each uart entry must be a table' + end + if type(entry.id) ~= 'string' or entry.id == '' then + return false, 'uart entry id must be a non-empty string' + end + if type(entry.path) ~= 'string' or entry.path == '' then + return false, 'uart entry path must be a non-empty string' + end + if entry.baud ~= nil and (type(entry.baud) ~= 'number' or entry.baud <= 0 or entry.baud % 1 ~= 0) then + return false, 'uart entry baud must be a positive integer' + end + if not valid_mode(entry.mode) then + return false, 'uart entry mode is invalid' + end + end + + return true, nil end local UART_MANAGER_SOURCE_ID = 'uart_manager' @@ -140,83 +140,83 @@ local function emit_device_removed_op(driver) end local function same_driver_config(driver, entry) - return driver.path == entry.path - and driver.default_baud == entry.baud - and driver.default_mode == entry.mode + return driver.path == entry.path + and driver.default_baud == entry.baud + and driver.default_mode == entry.mode end local function start_driver_op(entry) - return fibers.run_scope_op(function () - local driver = driver_mod.new( - entry.id, - entry.path, - entry.baud, - entry.mode, - S.logger - ) - - local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(S.cap_emit_ch)) - if not ok_caps then - return false, tostring(caps_or_err) - end - local caps = caps_or_err - - local started = false - local handed_off = false - - local function cleanup_started_driver() - if started and not handed_off then - resource.terminate_checked(driver, 'manager cleanup', 'HAL manager driver rollback failed') - end - end - - fibers.current_scope():finally(function () - cleanup_started_driver() - end) - - local ok_start, start_err = - fibers.perform(driver:start_op(assert(S.scope, 'uart manager scope missing'))) - if not ok_start then - return false, tostring(start_err) - end - started = true - - local ok_emit, emit_err = fibers.perform(emit_device_added_op(driver, caps)) - if not ok_emit then - return false, tostring(emit_err) - end - - S.drivers[entry.id] = driver - handed_off = true - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + local driver = driver_mod.new( + entry.id, + entry.path, + entry.baud, + entry.mode, + S.logger + ) + + local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(S.cap_emit_ch)) + if not ok_caps then + return false, tostring(caps_or_err) + end + local caps = caps_or_err + + local started = false + local handed_off = false + + local function cleanup_started_driver() + if started and not handed_off then + resource.terminate_checked(driver, 'manager cleanup', 'HAL manager driver rollback failed') + end + end + + fibers.current_scope():finally(function () + cleanup_started_driver() + end) + + local ok_start, start_err = + fibers.perform(driver:start_op(assert(S.scope, 'uart manager scope missing'))) + if not ok_start then + return false, tostring(start_err) + end + started = true + + local ok_emit, emit_err = fibers.perform(emit_device_added_op(driver, caps)) + if not ok_emit then + return false, tostring(emit_err) + end + + S.drivers[entry.id] = driver + handed_off = true + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function stop_driver_op(id, driver) - return fibers.run_scope_op(function () - local ok_emit, emit_err = fibers.perform(emit_device_removed_op(driver)) - if not ok_emit then - return false, tostring(emit_err) - end - - local ok_stop, stop_err = fibers.perform(driver:shutdown_op()) - if not ok_stop then - return false, tostring(stop_err) - end - - S.drivers[id] = nil - return true, nil - end):wrap(function (st, rep, ok, err) - if st ~= 'ok' then - return false, tostring(err or rep) - end - return ok, err - end) + return fibers.run_scope_op(function () + local ok_emit, emit_err = fibers.perform(emit_device_removed_op(driver)) + if not ok_emit then + return false, tostring(emit_err) + end + + local ok_stop, stop_err = fibers.perform(driver:shutdown_op()) + if not ok_stop then + return false, tostring(stop_err) + end + + S.drivers[id] = nil + return true, nil + end):wrap(function (st, rep, ok, err) + if st ~= 'ok' then + return false, tostring(err or rep) + end + return ok, err + end) end local function reconcile_op(entries) @@ -277,44 +277,44 @@ local function shell_loop(generation, cfg_ch) end function M.start_op(logger, dev_ev_ch, cap_emit_ch) - local owner_scope = fibers.current_scope() - assert(owner_scope ~= nil, 'uart.start_op must be called from inside a fiber') + local owner_scope = fibers.current_scope() + assert(owner_scope ~= nil, 'uart.start_op must be called from inside a fiber') - return op.guard(function () - if S.started then - return op.always(false, 'already started') - end + return op.guard(function () + if S.started then + return op.always(false, 'already started') + end - local scope, err = owner_scope:child() - if not scope then - return op.always(false, tostring(err)) - end + local scope, err = owner_scope:child() + if not scope then + return op.always(false, tostring(err)) + end - S.scope = scope - S.logger = logger - S.dev_ev_ch = dev_ev_ch - S.cap_emit_ch = cap_emit_ch + S.scope = scope + S.logger = logger + S.dev_ev_ch = dev_ev_ch + S.cap_emit_ch = cap_emit_ch S.cfg_ch = channel.new(8) S.generation = S.generation + 1 local generation = S.generation local cfg_ch = S.cfg_ch - local detach_finaliser = scope:finally(function () - finalise_manager_scope(scope, generation) - end) - - local ok, serr = scope:spawn(function () shell_loop(generation, cfg_ch) end) - if not ok then - detach_finaliser() - finalise_manager_scope(scope, generation) - scope:cancel(tostring(serr or 'manager shell spawn failed')) - return op.always(false, tostring(serr)) - end - - S.started = true - return op.always(true, nil) - end) + local detach_finaliser = scope:finally(function () + finalise_manager_scope(scope, generation) + end) + + local ok, serr = scope:spawn(function () shell_loop(generation, cfg_ch) end) + if not ok then + detach_finaliser() + finalise_manager_scope(scope, generation) + scope:cancel(tostring(serr or 'manager shell spawn failed')) + return op.always(false, tostring(serr)) + end + + S.started = true + return op.always(true, nil) + end) end function M.apply_config_op(entries) @@ -368,32 +368,32 @@ function M.apply_config_op(entries) end function M.shutdown_op(timeout) - timeout = timeout or STOP_TIMEOUT + timeout = timeout or STOP_TIMEOUT - return op.guard(function () - if not S.started or not S.scope then - return op.always(true, nil) - end + return op.guard(function () + if not S.started or not S.scope then + return op.always(true, nil) + end - local scope = S.scope + local scope = S.scope local generation = S.generation - scope:cancel() - - return fibers.boolean_choice( - scope:join_op():wrap(function () - finalise_manager_scope(scope, generation) - return true, nil - end), - sleep.sleep_op(timeout):wrap(function () - return false, 'uart manager stop timeout' - end) - ):wrap(function (completed, _a, b) - if completed then - return true, nil - end - return false, b - end) - end) + scope:cancel() + + return fibers.boolean_choice( + scope:join_op():wrap(function () + finalise_manager_scope(scope, generation) + return true, nil + end), + sleep.sleep_op(timeout):wrap(function () + return false, 'uart manager stop timeout' + end) + ):wrap(function (completed, _a, b) + if completed then + return true, nil + end + return false, b + end) + end) end function M.terminate(reason) diff --git a/src/services/hal/managers/usb.lua b/src/services/hal/managers/usb.lua index fad60cae..3f8bd97c 100644 --- a/src/services/hal/managers/usb.lua +++ b/src/services/hal/managers/usb.lua @@ -18,9 +18,9 @@ local cond = require "fibers.cond" local STOP_TIMEOUT = 5.0 local function dlog(logger, level, payload) - if logger and logger[level] then - logger[level](logger, payload) - end + if logger and logger[level] then + logger[level](logger, payload) + end end ---@class UsbManager @@ -28,9 +28,9 @@ end ---@field started boolean ---@field logger Logger? local UsbManager = { - started = false, - scope = nil, - logger = nil, + started = false, + scope = nil, + logger = nil, } ---- manager fiber ---- @@ -39,47 +39,47 @@ local UsbManager = { ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function manager(scope, dev_ev_ch, cap_emit_ch) - dlog(UsbManager.logger, 'debug', { what = 'started' }) - - scope:finally(function() - dlog(UsbManager.logger, 'debug', { what = 'closed' }) - end) - - local driver_logger = nil - if UsbManager.logger and UsbManager.logger.child then - driver_logger = UsbManager.logger:child({ component = 'driver', driver = 'usb', id = 'usb3' }) - end - - local driver, drv_err = usb_driver_any.new('usb3', driver_logger) - if not driver then - error("USB Manager: failed to create USB driver: " .. tostring(drv_err)) - end - - local init_err = driver:init() - if init_err ~= "" then - error("USB Manager: failed to init driver: " .. tostring(init_err)) - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if cap_err ~= "" then - error("USB Manager: failed to bind capabilities: " .. tostring(cap_err)) - end - - local ready_cond = cond.new() - local device_event, ev_err = hal_types.new.DeviceEvent( - "added", "usb", "usb3", {}, capabilities, ready_cond) - if not device_event then - error("USB Manager: failed to create DeviceEvent: " .. tostring(ev_err)) - end - dev_ev_ch:put(device_event) - ready_cond:wait() - - local ok, start_err = driver:start() - if not ok then - error("USB Manager: failed to start driver: " .. tostring(start_err)) - end - - dlog(UsbManager.logger, 'debug', { what = 'device_registered' }) + dlog(UsbManager.logger, 'debug', { what = 'started' }) + + scope:finally(function() + dlog(UsbManager.logger, 'debug', { what = 'closed' }) + end) + + local driver_logger = nil + if UsbManager.logger and UsbManager.logger.child then + driver_logger = UsbManager.logger:child({ component = 'driver', driver = 'usb', id = 'usb3' }) + end + + local driver, drv_err = usb_driver_any.new('usb3', driver_logger) + if not driver then + error("USB Manager: failed to create USB driver: " .. tostring(drv_err)) + end + + local init_err = driver:init() + if init_err ~= "" then + error("USB Manager: failed to init driver: " .. tostring(init_err)) + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err ~= "" then + error("USB Manager: failed to bind capabilities: " .. tostring(cap_err)) + end + + local ready_cond = cond.new() + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", "usb", "usb3", {}, capabilities, ready_cond) + if not device_event then + error("USB Manager: failed to create DeviceEvent: " .. tostring(ev_err)) + end + dev_ev_ch:put(device_event) + ready_cond:wait() + + local ok, start_err = driver:start() + if not ok then + error("USB Manager: failed to start driver: " .. tostring(start_err)) + end + + dlog(UsbManager.logger, 'debug', { what = 'device_registered' }) end ---- public interface ---- @@ -89,59 +89,59 @@ end ---@param cap_emit_ch Channel ---@return string error function UsbManager.start(logger, dev_ev_ch, cap_emit_ch) - if UsbManager.started then - return "Already started" - end - - local scope, err = fibers.current_scope():child() - if not scope then - return "Failed to create child scope: " .. tostring(err) - end - UsbManager.scope = scope - UsbManager.logger = logger - - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - dlog(UsbManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) - end - dlog(UsbManager.logger, 'debug', { what = 'stopped' }) - end) - - scope:spawn(manager, dev_ev_ch, cap_emit_ch) - - UsbManager.started = true - dlog(UsbManager.logger, 'debug', { what = 'start_called' }) - return "" + if UsbManager.started then + return "Already started" + end + + local scope, err = fibers.current_scope():child() + if not scope then + return "Failed to create child scope: " .. tostring(err) + end + UsbManager.scope = scope + UsbManager.logger = logger + + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + dlog(UsbManager.logger, 'error', { what = 'scope_failed', err = tostring(primary), status = st }) + end + dlog(UsbManager.logger, 'debug', { what = 'stopped' }) + end) + + scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + UsbManager.started = true + dlog(UsbManager.logger, 'debug', { what = 'start_called' }) + return "" end ---@param timeout number? ---@return boolean ok ---@return string error function UsbManager.stop(timeout) - if not UsbManager.started then - return false, "Not started" - end - timeout = timeout or STOP_TIMEOUT - UsbManager.scope:cancel('usb manager stopped') - - local source = fibers.perform(op.named_choice { - join = UsbManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - }) - - if source == 'timeout' then - return false, "usb manager stop timeout" - end - UsbManager.started = false - return true, "" + if not UsbManager.started then + return false, "Not started" + end + timeout = timeout or STOP_TIMEOUT + UsbManager.scope:cancel('usb manager stopped') + + local source = fibers.perform(op.named_choice { + join = UsbManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + }) + + if source == 'timeout' then + return false, "usb manager stop timeout" + end + UsbManager.started = false + return true, "" end ---@param namespaces table ---@return boolean ok ---@return string error function UsbManager.apply_config(namespaces) -- luacheck: ignore - return true, "" + return true, "" end return UsbManager diff --git a/src/services/hal/managers/wlan.lua b/src/services/hal/managers/wlan.lua index f3f19cc9..f9c390a4 100644 --- a/src/services/hal/managers/wlan.lua +++ b/src/services/hal/managers/wlan.lua @@ -18,11 +18,11 @@ local log -- set in start() ---@field band BandDriver? ---@field config_ch Channel local WLANManager = { - started = false, - radios = {}, - band = nil, - scope = nil, - config_ch = channel.new(4), + started = false, + radios = {}, + band = nil, + scope = nil, + config_ch = channel.new(4), } ------------------------------------------------------------------------ @@ -36,132 +36,132 @@ local WLANManager = { ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function start_radio(name, radio_cfg, dev_ev_ch, cap_emit_ch) - local driver, drv_err = radio_driver.new(name, log:child({ radio = name })) - if not driver then - log:error({ what = 'create_radio_driver_failed', name = name, err = drv_err }) - return - end - - local init_err = driver:init(radio_cfg.path or '', radio_cfg.type or '') - if init_err ~= '' then - log:error({ what = 'init_radio_driver_failed', name = name, err = init_err }) - return - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if not capabilities then - log:error({ what = 'radio_capabilities_failed', name = name, err = cap_err }) - return - end - - local ok, start_err = driver:start() - if not ok then - log:error({ what = 'start_radio_driver_failed', name = name, err = start_err }) - return - end - - WLANManager.radios[name] = { - driver = driver, - path = driver.staged.path, - type = driver.staged.type, - } - - local device_event, ev_err = hal_types.new.DeviceEvent( - 'added', - 'radio', - name, - { - provider = 'hal', - version = 1, - name = name, - path = driver.staged.path, - type = driver.staged.type, - }, - capabilities - ) - if not device_event then - log:error({ what = 'create_radio_device_event_failed', name = name, err = ev_err }) - return - end - - dev_ev_ch:put(device_event) - log:debug({ what = 'radio_driver_started', name = name }) + local driver, drv_err = radio_driver.new(name, log:child({ radio = name })) + if not driver then + log:error({ what = 'create_radio_driver_failed', name = name, err = drv_err }) + return + end + + local init_err = driver:init(radio_cfg.path or '', radio_cfg.type or '') + if init_err ~= '' then + log:error({ what = 'init_radio_driver_failed', name = name, err = init_err }) + return + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if not capabilities then + log:error({ what = 'radio_capabilities_failed', name = name, err = cap_err }) + return + end + + local ok, start_err = driver:start() + if not ok then + log:error({ what = 'start_radio_driver_failed', name = name, err = start_err }) + return + end + + WLANManager.radios[name] = { + driver = driver, + path = driver.staged.path, + type = driver.staged.type, + } + + local device_event, ev_err = hal_types.new.DeviceEvent( + 'added', + 'radio', + name, + { + provider = 'hal', + version = 1, + name = name, + path = driver.staged.path, + type = driver.staged.type, + }, + capabilities + ) + if not device_event then + log:error({ what = 'create_radio_device_event_failed', name = name, err = ev_err }) + return + end + + dev_ev_ch:put(device_event) + log:debug({ what = 'radio_driver_started', name = name }) end ---Stop a single radio driver and emit device-removed event. ---@param name string ---@param dev_ev_ch Channel local function stop_radio(name, dev_ev_ch) - local entry = WLANManager.radios[name] - if not entry then - log:warn({ what = 'stop_radio_not_found', name = name }) - return - end - - -- Cancel the driver's scope (structured concurrency handles cleanup) - entry.driver.scope:cancel('removed by manager') - WLANManager.radios[name] = nil - - -- HAL unregisters by class+id, so an empty capabilities list is fine here - local device_event, ev_err = hal_types.new.DeviceEvent( - 'removed', - 'radio', - name, - {}, - {} - ) - if not device_event then - log:error({ what = 'create_radio_remove_event_failed', name = name, err = ev_err }) - return - end - dev_ev_ch:put(device_event) - log:debug({ what = 'radio_driver_stopped', name = name }) + local entry = WLANManager.radios[name] + if not entry then + log:warn({ what = 'stop_radio_not_found', name = name }) + return + end + + -- Cancel the driver's scope (structured concurrency handles cleanup) + entry.driver.scope:cancel('removed by manager') + WLANManager.radios[name] = nil + + -- HAL unregisters by class+id, so an empty capabilities list is fine here + local device_event, ev_err = hal_types.new.DeviceEvent( + 'removed', + 'radio', + name, + {}, + {} + ) + if not device_event then + log:error({ what = 'create_radio_remove_event_failed', name = name, err = ev_err }) + return + end + dev_ev_ch:put(device_event) + log:debug({ what = 'radio_driver_stopped', name = name }) end ---Start the band driver and emit device-added event. ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function start_band(dev_ev_ch, cap_emit_ch) - local driver, drv_err = band_driver.new(log:child({ driver = 'band' })) - if not driver then - log:warn({ what = 'create_band_driver_failed', err = drv_err }) - return - end - - local init_err = driver:init() - if init_err ~= '' then - log:warn({ what = 'init_band_driver_failed', err = init_err }) - return - end - - local capabilities, cap_err = driver:capabilities(cap_emit_ch) - if not capabilities then - log:warn({ what = 'band_capabilities_failed', err = cap_err }) - return - end - - local ok, start_err = driver:start() - if not ok then - log:warn({ what = 'start_band_driver_failed', err = start_err }) - return - end - - WLANManager.band = driver - - local device_event, ev_err = hal_types.new.DeviceEvent( - 'added', - 'band', - '1', - { provider = 'hal', version = 1 }, - capabilities - ) - if not device_event then - log:warn({ what = 'create_band_device_event_failed', err = ev_err }) - return - end - - dev_ev_ch:put(device_event) - log:debug({ what = 'band_driver_started' }) + local driver, drv_err = band_driver.new(log:child({ driver = 'band' })) + if not driver then + log:warn({ what = 'create_band_driver_failed', err = drv_err }) + return + end + + local init_err = driver:init() + if init_err ~= '' then + log:warn({ what = 'init_band_driver_failed', err = init_err }) + return + end + + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if not capabilities then + log:warn({ what = 'band_capabilities_failed', err = cap_err }) + return + end + + local ok, start_err = driver:start() + if not ok then + log:warn({ what = 'start_band_driver_failed', err = start_err }) + return + end + + WLANManager.band = driver + + local device_event, ev_err = hal_types.new.DeviceEvent( + 'added', + 'band', + '1', + { provider = 'hal', version = 1 }, + capabilities + ) + if not device_event then + log:warn({ what = 'create_band_device_event_failed', err = ev_err }) + return + end + + dev_ev_ch:put(device_event) + log:debug({ what = 'band_driver_started' }) end ---Reconcile running radio drivers against a new config. @@ -169,36 +169,36 @@ end ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel local function reconcile_radios(config, dev_ev_ch, cap_emit_ch) - local new_radios = {} - if type(config.radios) == 'table' then - for _, r in ipairs(config.radios) do - if type(r.name) == 'string' and r.name ~= '' then - new_radios[r.name] = r - end - end - end - - -- Stop drivers no longer in config or whose path/type changed - local to_stop = {} - for name, entry in pairs(WLANManager.radios) do - local new_r = new_radios[name] - if not new_r then - table.insert(to_stop, { name = name, reason = 'removed' }) - elseif (new_r.path or '') ~= entry.path or (new_r.type or '') ~= entry.type then - table.insert(to_stop, { name = name, reason = 'changed' }) - end - end - for _, s in ipairs(to_stop) do - log:debug({ what = 'stopping_radio', name = s.name, reason = s.reason }) - stop_radio(s.name, dev_ev_ch) - end - - -- Start new or replacement radios - for name, radio_cfg in pairs(new_radios) do - if not WLANManager.radios[name] then - start_radio(name, radio_cfg, dev_ev_ch, cap_emit_ch) - end - end + local new_radios = {} + if type(config.radios) == 'table' then + for _, r in ipairs(config.radios) do + if type(r.name) == 'string' and r.name ~= '' then + new_radios[r.name] = r + end + end + end + + -- Stop drivers no longer in config or whose path/type changed + local to_stop = {} + for name, entry in pairs(WLANManager.radios) do + local new_r = new_radios[name] + if not new_r then + table.insert(to_stop, { name = name, reason = 'removed' }) + elseif (new_r.path or '') ~= entry.path or (new_r.type or '') ~= entry.type then + table.insert(to_stop, { name = name, reason = 'changed' }) + end + end + for _, s in ipairs(to_stop) do + log:debug({ what = 'stopping_radio', name = s.name, reason = s.reason }) + stop_radio(s.name, dev_ev_ch) + end + + -- Start new or replacement radios + for name, radio_cfg in pairs(new_radios) do + if not WLANManager.radios[name] then + start_radio(name, radio_cfg, dev_ev_ch, cap_emit_ch) + end + end end ------------------------------------------------------------------------ @@ -206,54 +206,54 @@ end ------------------------------------------------------------------------ local function manager_fiber(scope, dev_ev_ch, cap_emit_ch) - scope:finally(function() - local st, primary = scope:status() - if st == 'failed' then - log:error({ what = 'wlan_manager_scope_error', err = tostring(primary) }) - end - log:debug({ what = 'wlan_manager_stopped' }) - end) - - -- Start the band driver immediately (always, regardless of radio count). - -- The band driver is persistent; it is not restarted on config updates. - start_band(dev_ev_ch, cap_emit_ch) - - -- Watch for config updates and driver faults - while true do - -- Build fault ops for each running radio driver - local fault_ops = {} - for name, entry in pairs(WLANManager.radios) do - table.insert(fault_ops, entry.driver.scope:fault_op():wrap(function(_, err) return name, err end)) - end - - local fault_op = op.never() - if #fault_ops > 0 then - fault_op = op.choice(unpack(fault_ops)) - end - - local source, val, fault_err = fibers.perform(fibers.named_choice({ - config = WLANManager.config_ch:get_op(), - driver_fault = fault_op, - cancel = scope:cancel_op(), - })) - - if source == 'cancel' then - break - elseif source == 'config' then - log:debug({ what = 'apply_config_received' }) - -- reconcile_radios handles both initial setup and subsequent updates - reconcile_radios(val, dev_ev_ch, cap_emit_ch) - elseif source == 'driver_fault' then - local name = val - log:error({ what = 'radio_driver_fault', name = tostring(name), err = tostring(fault_err) }) - -- Remove from registry (scope already failed); emit device-removed - if WLANManager.radios[name] then - WLANManager.radios[name] = nil - local device_event = hal_types.new.DeviceEvent('removed', 'radio', name, {}, {}) - if device_event then dev_ev_ch:put(device_event) end - end - end - end + scope:finally(function() + local st, primary = scope:status() + if st == 'failed' then + log:error({ what = 'wlan_manager_scope_error', err = tostring(primary) }) + end + log:debug({ what = 'wlan_manager_stopped' }) + end) + + -- Start the band driver immediately (always, regardless of radio count). + -- The band driver is persistent; it is not restarted on config updates. + start_band(dev_ev_ch, cap_emit_ch) + + -- Watch for config updates and driver faults + while true do + -- Build fault ops for each running radio driver + local fault_ops = {} + for name, entry in pairs(WLANManager.radios) do + table.insert(fault_ops, entry.driver.scope:fault_op():wrap(function(_, err) return name, err end)) + end + + local fault_op = op.never() + if #fault_ops > 0 then + fault_op = op.choice(unpack(fault_ops)) + end + + local source, val, fault_err = fibers.perform(fibers.named_choice({ + config = WLANManager.config_ch:get_op(), + driver_fault = fault_op, + cancel = scope:cancel_op(), + })) + + if source == 'cancel' then + break + elseif source == 'config' then + log:debug({ what = 'apply_config_received' }) + -- reconcile_radios handles both initial setup and subsequent updates + reconcile_radios(val, dev_ev_ch, cap_emit_ch) + elseif source == 'driver_fault' then + local name = val + log:error({ what = 'radio_driver_fault', name = tostring(name), err = tostring(fault_err) }) + -- Remove from registry (scope already failed); emit device-removed + if WLANManager.radios[name] then + WLANManager.radios[name] = nil + local device_event = hal_types.new.DeviceEvent('removed', 'radio', name, {}, {}) + if device_event then dev_ev_ch:put(device_event) end + end + end + end end ------------------------------------------------------------------------ @@ -267,23 +267,23 @@ end ---@param cap_emit_ch Channel ---@return string err "" on success function WLANManager.start(logger, dev_ev_ch, cap_emit_ch) - log = logger + log = logger - if WLANManager.started then - return "already started" - end + if WLANManager.started then + return "already started" + end - local scope, scope_err = fibers.current_scope():child() - if not scope then - return "failed to create child scope: " .. tostring(scope_err) - end - WLANManager.scope = scope + local scope, scope_err = fibers.current_scope():child() + if not scope then + return "failed to create child scope: " .. tostring(scope_err) + end + WLANManager.scope = scope - scope:spawn(manager_fiber, dev_ev_ch, cap_emit_ch) + scope:spawn(manager_fiber, dev_ev_ch, cap_emit_ch) - WLANManager.started = true - log:debug({ what = 'wlan_manager_started' }) - return "" + WLANManager.started = true + log:debug({ what = 'wlan_manager_started' }) + return "" end ---Apply a new device config to the WLAN Manager. @@ -292,11 +292,11 @@ end ---@return boolean ok ---@return string err function WLANManager.apply_config(config) - if not WLANManager.started then - return false, "not started" - end - WLANManager.config_ch:put(config) - return true, "" + if not WLANManager.started then + return false, "not started" + end + WLANManager.config_ch:put(config) + return true, "" end ---Stop the WLAN Manager and all its child drivers. @@ -304,22 +304,22 @@ end ---@return boolean ok ---@return string err function WLANManager.stop(timeout) - if not WLANManager.started then - return false, "not started" - end - timeout = timeout or STOP_TIMEOUT - WLANManager.scope:cancel() - - local source = fibers.perform(op.named_choice({ - join = WLANManager.scope:join_op(), - timeout = sleep.sleep_op(timeout), - })) - - if source == 'timeout' then - return false, "wlan manager stop timeout" - end - WLANManager.started = false - return true, "" + if not WLANManager.started then + return false, "not started" + end + timeout = timeout or STOP_TIMEOUT + WLANManager.scope:cancel() + + local source = fibers.perform(op.named_choice({ + join = WLANManager.scope:join_op(), + timeout = sleep.sleep_op(timeout), + })) + + if source == 'timeout' then + return false, "wlan manager stop timeout" + end + WLANManager.started = false + return true, "" end return WLANManager diff --git a/src/services/hal/templates/driver_template.lua b/src/services/hal/templates/driver_template.lua index 512cf92e..0c4d1e6a 100644 --- a/src/services/hal/templates/driver_template.lua +++ b/src/services/hal/templates/driver_template.lua @@ -32,223 +32,223 @@ local CONTROL_Q_LEN = 8 local DEFAULT_SHUTDOWN_TIMEOUT = 5.0 local function return_error(err, code) - return false, err or "unknown error", code + return false, err or "unknown error", code end local function emit_op(emit_ch, class, id, mode, key, data) - return op.guard(function () - local payload, err = hal_types.new.Emit(class, id, mode, key, data) - if not payload then - return op.always(false, err) - end - - return emit_ch:put_op(payload):wrap(function (sent, send_err) - if sent ~= false and sent ~= nil then - return true, nil - end - return false, tostring(send_err or "emit channel closed") - end) - end) + return op.guard(function () + local payload, err = hal_types.new.Emit(class, id, mode, key, data) + if not payload then + return op.always(false, err) + end + + return emit_ch:put_op(payload):wrap(function (sent, send_err) + if sent ~= false and sent ~= nil then + return true, nil + end + return false, tostring(send_err or "emit channel closed") + end) + end) end local function validate_fn(fn) - if type(fn) ~= "function" then - return false, "verb handler is unimplemented" - end - return true, nil + if type(fn) ~= "function" then + return false, "verb handler is unimplemented" + end + return true, nil end function TemplateDriver:init_op() - return op.guard(function () - self.initialised = true - return op.always(true, nil) - end) + return op.guard(function () + self.initialised = true + return op.always(true, nil) + end) end function TemplateDriver:get_status_op(opts) - return op.guard(function () - if opts ~= nil and type(opts) ~= "table" then - return op.always(return_error("invalid options", 1)) - end - - return op.always(true, { - id = self.id, - state = "ready", - }) - end) + return op.guard(function () + if opts ~= nil and type(opts) ~= "table" then + return op.always(return_error("invalid options", 1)) + end + + return op.always(true, { + id = self.id, + state = "ready", + }) + end) end function TemplateDriver:reset_op(opts) - return op.guard(function () - if opts ~= nil and type(opts) ~= "table" then - return op.always(return_error("invalid options", 1)) - end - - if self.cap_emit_ch then - return emit_op(self.cap_emit_ch, "template", self.id, "event", "reset", { - at = os.time(), - }) - end - - return op.always(true, nil) - end) + return op.guard(function () + if opts ~= nil and type(opts) ~= "table" then + return op.always(return_error("invalid options", 1)) + end + + if self.cap_emit_ch then + return emit_op(self.cap_emit_ch, "template", self.id, "event", "reset", { + at = os.time(), + }) + end + + return op.always(true, nil) + end) end local function control_loop(self, scope) - scope:finally(function () - self.log:debug({ what = "template_driver_control_loop_stopped", id = self.id }) - end) - - while true do - local request, b = fibers.perform(self.control_ch:get_op()) - if not request then - self.log:error({ what = "control_channel_read_failed", id = self.id, err = tostring(b) }) - return - end - - local fn = self[request.verb .. "_op"] - local valid, validation_err = validate_fn(fn) - - local ok, reason, code - if not valid then - ok, reason, code = false, validation_err, 1 - else - local call_ok, fn_ok, fn_reason, fn_code = safe.pcall(function () - return fibers.perform(fn(self, request.opts)) - end) - - if not call_ok then - ok, reason, code = false, tostring(fn_ok), 1 - else - ok, reason, code = fn_ok, fn_reason, fn_code - end - end - - local reply, reply_err = hal_types.new.Reply(ok, reason, code) - if not reply then - self.log:error({ what = "reply_create_failed", id = self.id, err = tostring(reply_err) }) - else - local sent, send_err = fibers.perform(request.reply_ch:put_op(reply)) - if sent == false or sent == nil then - self.log:error({ what = "reply_send_failed", id = self.id, err = tostring(send_err) }) - end - end - end + scope:finally(function () + self.log:debug({ what = "template_driver_control_loop_stopped", id = self.id }) + end) + + while true do + local request, b = fibers.perform(self.control_ch:get_op()) + if not request then + self.log:error({ what = "control_channel_read_failed", id = self.id, err = tostring(b) }) + return + end + + local fn = self[request.verb .. "_op"] + local valid, validation_err = validate_fn(fn) + + local ok, reason, code + if not valid then + ok, reason, code = false, validation_err, 1 + else + local call_ok, fn_ok, fn_reason, fn_code = safe.pcall(function () + return fibers.perform(fn(self, request.opts)) + end) + + if not call_ok then + ok, reason, code = false, tostring(fn_ok), 1 + else + ok, reason, code = fn_ok, fn_reason, fn_code + end + end + + local reply, reply_err = hal_types.new.Reply(ok, reason, code) + if not reply then + self.log:error({ what = "reply_create_failed", id = self.id, err = tostring(reply_err) }) + else + local sent, send_err = fibers.perform(request.reply_ch:put_op(reply)) + if sent == false or sent == nil then + self.log:error({ what = "reply_send_failed", id = self.id, err = tostring(send_err) }) + end + end + end end function TemplateDriver:start_op(owner_scope) - return op.guard(function () - if self.started then - return op.always(false, "already started") - end - if not self.initialised then - return op.always(false, "driver not initialised") - end - if not self.caps_applied then - return op.always(false, "capabilities not applied") - end - - local scope, err = owner_scope:child() - if not scope then - return op.always(false, tostring(err)) - end - - self.scope = scope - - local ok, spawn_err = scope:spawn(function () - return control_loop(self, scope) - end) - if not ok then - self.scope = nil - scope:cancel(tostring(spawn_err or "template driver spawn failed")) - return op.always(false, tostring(spawn_err)) - end - - self.started = true - return op.always(true, nil) - end) + return op.guard(function () + if self.started then + return op.always(false, "already started") + end + if not self.initialised then + return op.always(false, "driver not initialised") + end + if not self.caps_applied then + return op.always(false, "capabilities not applied") + end + + local scope, err = owner_scope:child() + if not scope then + return op.always(false, tostring(err)) + end + + self.scope = scope + + local ok, spawn_err = scope:spawn(function () + return control_loop(self, scope) + end) + if not ok then + self.scope = nil + scope:cancel(tostring(spawn_err or "template driver spawn failed")) + return op.always(false, tostring(spawn_err)) + end + + self.started = true + return op.always(true, nil) + end) end function TemplateDriver:shutdown_op(timeout) - timeout = timeout or DEFAULT_SHUTDOWN_TIMEOUT - - return op.guard(function () - local scope = self.scope - if not self.started or not scope then - return op.always(true, nil) - end - - scope:cancel("template driver shutdown") - - return fibers.boolean_choice( - scope:join_op():wrap(function () - self.started = false - self.scope = nil - return true, nil - end), - sleep.sleep_op(timeout):wrap(function () - return false, "template driver shutdown timeout" - end) - ):wrap(function (completed, _a, b) - if completed then return true, nil end - return false, b - end) - end) + timeout = timeout or DEFAULT_SHUTDOWN_TIMEOUT + + return op.guard(function () + local scope = self.scope + if not self.started or not scope then + return op.always(true, nil) + end + + scope:cancel("template driver shutdown") + + return fibers.boolean_choice( + scope:join_op():wrap(function () + self.started = false + self.scope = nil + return true, nil + end), + sleep.sleep_op(timeout):wrap(function () + return false, "template driver shutdown timeout" + end) + ):wrap(function (completed, _a, b) + if completed then return true, nil end + return false, b + end) + end) end function TemplateDriver:terminate(reason) - if self.scope then - self.scope:cancel(reason or "template driver terminated") - end - self.started = false - self.scope = nil - return true, nil + if self.scope then + self.scope:cancel(reason or "template driver terminated") + end + self.started = false + self.scope = nil + return true, nil end function TemplateDriver:capabilities_op(emit_ch) - return op.guard(function () - if not self.initialised then - return op.always(false, "driver not initialised") - end - if self.caps_applied then - return op.always(false, "capabilities already applied") - end - - self.cap_emit_ch = emit_ch - - local cap, cap_err = cap_types.new.Capability( - "template", - self.id, - self.control_ch, - { "get_status", "reset" } - ) - if not cap then - return op.always(false, cap_err) - end - - self.caps_applied = true - return op.always(true, { cap }) - end) + return op.guard(function () + if not self.initialised then + return op.always(false, "driver not initialised") + end + if self.caps_applied then + return op.always(false, "capabilities already applied") + end + + self.cap_emit_ch = emit_ch + + local cap, cap_err = cap_types.new.Capability( + "template", + self.id, + self.control_ch, + { "get_status", "reset" } + ) + if not cap then + return op.always(false, cap_err) + end + + self.caps_applied = true + return op.always(true, { cap }) + end) end local function new(id, logger) - if type(id) ~= "string" or id == "" then - return nil, "invalid id" - end - - return setmetatable({ - id = id, - scope = nil, - control_ch = channel.new(CONTROL_Q_LEN), - cap_emit_ch = nil, - initialised = false, - caps_applied = false, - started = false, - log = logger, - }, TemplateDriver), nil + if type(id) ~= "string" or id == "" then + return nil, "invalid id" + end + + return setmetatable({ + id = id, + scope = nil, + control_ch = channel.new(CONTROL_Q_LEN), + cap_emit_ch = nil, + initialised = false, + caps_applied = false, + started = false, + log = logger, + }, TemplateDriver), nil end return { - new = new, - Driver = TemplateDriver, + new = new, + Driver = TemplateDriver, } diff --git a/src/services/hal/templates/manager_template.lua b/src/services/hal/templates/manager_template.lua index 739fd7d6..78403369 100644 --- a/src/services/hal/templates/manager_template.lua +++ b/src/services/hal/templates/manager_template.lua @@ -25,192 +25,192 @@ local sleep = require "fibers.sleep" ---@field drivers table ---@field log Logger|nil local TemplateManager = { - api_mode = 'op_only', - scope = nil, - started = false, - detect_ch = channel.new(), - remove_ch = channel.new(), - ready_driver_ch = channel.new(), - drivers = {}, - log = nil, + api_mode = 'op_only', + scope = nil, + started = false, + detect_ch = channel.new(), + remove_ch = channel.new(), + ready_driver_ch = channel.new(), + drivers = {}, + log = nil, } local DEFAULT_SHUTDOWN_TIMEOUT = 5.0 local function detector(scope) - scope:finally(function () - TemplateManager.log:debug({ what = "template_detector_stopped" }) - end) - - while true do - local which, payload = fibers.perform(fibers.named_choice{ - detect = TemplateManager.detect_ch:get_op(), - remove = TemplateManager.remove_ch:get_op(), - }) - - if which == "detect" then - local id = payload - local driver, err = driver_template.new(id, TemplateManager.log:child({ template = id })) - if not driver then - TemplateManager.log:error({ what = "template_driver_create_failed", id = id, err = tostring(err) }) - else - local ok, spawn_err = fibers.spawn(function () - local ok_init, init_err = fibers.perform(driver:init_op()) - if not ok_init then - TemplateManager.log:error({ what = "template_driver_init_failed", id = id, err = tostring(init_err) }) - return - end - fibers.perform(TemplateManager.ready_driver_ch:put_op(driver)) - end) - if not ok then - resource.terminate_checked(driver, tostring(spawn_err or "driver init spawn failed"), "template driver init cleanup failed") - end - end - elseif which == "remove" then - local id = payload - local driver = TemplateManager.drivers[id] - if driver then - TemplateManager.drivers[id] = nil - local ok, err = fibers.perform(driver:shutdown_op(DEFAULT_SHUTDOWN_TIMEOUT)) - if not ok then - resource.terminate_checked(driver, tostring(err or "driver shutdown failed"), "template driver shutdown cleanup failed") - end - end - end - end + scope:finally(function () + TemplateManager.log:debug({ what = "template_detector_stopped" }) + end) + + while true do + local which, payload = fibers.perform(fibers.named_choice{ + detect = TemplateManager.detect_ch:get_op(), + remove = TemplateManager.remove_ch:get_op(), + }) + + if which == "detect" then + local id = payload + local driver, err = driver_template.new(id, TemplateManager.log:child({ template = id })) + if not driver then + TemplateManager.log:error({ what = "template_driver_create_failed", id = id, err = tostring(err) }) + else + local ok, spawn_err = fibers.spawn(function () + local ok_init, init_err = fibers.perform(driver:init_op()) + if not ok_init then + TemplateManager.log:error({ what = "template_driver_init_failed", id = id, err = tostring(init_err) }) + return + end + fibers.perform(TemplateManager.ready_driver_ch:put_op(driver)) + end) + if not ok then + resource.terminate_checked(driver, tostring(spawn_err or "driver init spawn failed"), "template driver init cleanup failed") + end + end + elseif which == "remove" then + local id = payload + local driver = TemplateManager.drivers[id] + if driver then + TemplateManager.drivers[id] = nil + local ok, err = fibers.perform(driver:shutdown_op(DEFAULT_SHUTDOWN_TIMEOUT)) + if not ok then + resource.terminate_checked(driver, tostring(err or "driver shutdown failed"), "template driver shutdown cleanup failed") + end + end + end + end end local function manager_loop(scope, dev_ev_ch, cap_emit_ch) - scope:finally(function () - TemplateManager.log:debug({ what = "template_manager_loop_stopped" }) - end) - - while true do - local driver = fibers.perform(TemplateManager.ready_driver_ch:get_op()) - if not driver then - return - end - - repeat - local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(cap_emit_ch)) - if not ok_caps then - TemplateManager.log:error({ what = "template_caps_failed", err = tostring(caps_or_err) }) - resource.terminate_checked(driver, "capabilities failed", "template driver capabilities cleanup failed") - break - end - - local ok_start, start_err = fibers.perform(driver:start_op(scope)) - if not ok_start then - TemplateManager.log:error({ what = "template_start_failed", err = tostring(start_err) }) - resource.terminate_checked(driver, "start failed", "template driver start cleanup failed") - break - end - - TemplateManager.drivers[driver.id] = driver - - local ev, ev_err = hal_types.new.DeviceEvent( - "added", - "template_device", - driver.id, - { source = "template" }, - caps_or_err - ) - if not ev then - TemplateManager.log:error({ what = "template_device_event_failed", err = tostring(ev_err) }) - break - end - - fibers.perform(dev_ev_ch:put_op(ev)) - until true - end + scope:finally(function () + TemplateManager.log:debug({ what = "template_manager_loop_stopped" }) + end) + + while true do + local driver = fibers.perform(TemplateManager.ready_driver_ch:get_op()) + if not driver then + return + end + + repeat + local ok_caps, caps_or_err = fibers.perform(driver:capabilities_op(cap_emit_ch)) + if not ok_caps then + TemplateManager.log:error({ what = "template_caps_failed", err = tostring(caps_or_err) }) + resource.terminate_checked(driver, "capabilities failed", "template driver capabilities cleanup failed") + break + end + + local ok_start, start_err = fibers.perform(driver:start_op(scope)) + if not ok_start then + TemplateManager.log:error({ what = "template_start_failed", err = tostring(start_err) }) + resource.terminate_checked(driver, "start failed", "template driver start cleanup failed") + break + end + + TemplateManager.drivers[driver.id] = driver + + local ev, ev_err = hal_types.new.DeviceEvent( + "added", + "template_device", + driver.id, + { source = "template" }, + caps_or_err + ) + if not ev then + TemplateManager.log:error({ what = "template_device_event_failed", err = tostring(ev_err) }) + break + end + + fibers.perform(dev_ev_ch:put_op(ev)) + until true + end end function TemplateManager.start_op(logger, dev_ev_ch, cap_emit_ch) - return op.guard(function () - if TemplateManager.started then - return op.always(false, "already started") - end - - local owner_scope = fibers.current_scope() - local scope, err = owner_scope:child() - if not scope then - return op.always(false, tostring(err)) - end - - TemplateManager.log = logger - TemplateManager.scope = scope - - scope:finally(function () - TemplateManager.terminate("template manager scope finalised") - end) - - local ok1, err1 = scope:spawn(detector) - if not ok1 then - TemplateManager.terminate(tostring(err1 or "detector spawn failed")) - return op.always(false, tostring(err1)) - end - - local ok2, err2 = scope:spawn(manager_loop, dev_ev_ch, cap_emit_ch) - if not ok2 then - TemplateManager.terminate(tostring(err2 or "manager loop spawn failed")) - return op.always(false, tostring(err2)) - end - - TemplateManager.started = true - return op.always(true, nil) - end) + return op.guard(function () + if TemplateManager.started then + return op.always(false, "already started") + end + + local owner_scope = fibers.current_scope() + local scope, err = owner_scope:child() + if not scope then + return op.always(false, tostring(err)) + end + + TemplateManager.log = logger + TemplateManager.scope = scope + + scope:finally(function () + TemplateManager.terminate("template manager scope finalised") + end) + + local ok1, err1 = scope:spawn(detector) + if not ok1 then + TemplateManager.terminate(tostring(err1 or "detector spawn failed")) + return op.always(false, tostring(err1)) + end + + local ok2, err2 = scope:spawn(manager_loop, dev_ev_ch, cap_emit_ch) + if not ok2 then + TemplateManager.terminate(tostring(err2 or "manager loop spawn failed")) + return op.always(false, tostring(err2)) + end + + TemplateManager.started = true + return op.always(true, nil) + end) end function TemplateManager.shutdown_op(timeout) - timeout = timeout or DEFAULT_SHUTDOWN_TIMEOUT - - return op.guard(function () - local scope = TemplateManager.scope - if not TemplateManager.started or not scope then - return op.always(true, nil) - end - - scope:cancel("template manager shutdown") - - return fibers.boolean_choice( - scope:join_op():wrap(function () - TemplateManager.terminate("template manager joined") - return true, nil - end), - sleep.sleep_op(timeout):wrap(function () - return false, "template manager shutdown timeout" - end) - ):wrap(function (completed, _a, b) - if completed then return true, nil end - return false, b - end) - end) + timeout = timeout or DEFAULT_SHUTDOWN_TIMEOUT + + return op.guard(function () + local scope = TemplateManager.scope + if not TemplateManager.started or not scope then + return op.always(true, nil) + end + + scope:cancel("template manager shutdown") + + return fibers.boolean_choice( + scope:join_op():wrap(function () + TemplateManager.terminate("template manager joined") + return true, nil + end), + sleep.sleep_op(timeout):wrap(function () + return false, "template manager shutdown timeout" + end) + ):wrap(function (completed, _a, b) + if completed then return true, nil end + return false, b + end) + end) end function TemplateManager.terminate(reason) - for id, driver in pairs(TemplateManager.drivers or {}) do - resource.terminate_checked(driver, reason or "template manager terminated", "template manager driver cleanup failed") - TemplateManager.drivers[id] = nil - end - - if TemplateManager.scope then - TemplateManager.scope:cancel(reason or "template manager terminated") - end - - TemplateManager.scope = nil - TemplateManager.started = false - return true, nil + for id, driver in pairs(TemplateManager.drivers or {}) do + resource.terminate_checked(driver, reason or "template manager terminated", "template manager driver cleanup failed") + TemplateManager.drivers[id] = nil + end + + if TemplateManager.scope then + TemplateManager.scope:cancel(reason or "template manager terminated") + end + + TemplateManager.scope = nil + TemplateManager.started = false + return true, nil end function TemplateManager.fault_op() - if TemplateManager.scope and TemplateManager.started then - return TemplateManager.scope:fault_op() - end - return op.never() + if TemplateManager.scope and TemplateManager.started then + return TemplateManager.scope:fault_op() + end + return op.never() end function TemplateManager.apply_config_op(_namespaces) - return op.always(true, nil) + return op.always(true, nil) end return TemplateManager diff --git a/src/services/hal/types/capabilities.lua b/src/services/hal/types/capabilities.lua index 99d6bd52..827d7105 100644 --- a/src/services/hal/types/capabilities.lua +++ b/src/services/hal/types/capabilities.lua @@ -1,34 +1,34 @@ ---@param list string[] ---@return table local function list_to_map(list) - local map = {} - for _, v in ipairs(list) do - map[v] = true - end - return map + local map = {} + for _, v in ipairs(list) do + map[v] = true + end + return map end local channel = require "fibers.channel" local ChannelMT = getmetatable(channel.new()) local function valid_class(class) - return type(class) == 'string' and class ~= '' + return type(class) == 'string' and class ~= '' end local function valid_id(id) - return (type(id) == 'string' and id ~= '') or (type(id) == 'number' and id >= 0) + return (type(id) == 'string' and id ~= '') or (type(id) == 'number' and id >= 0) end local function valid_offerings(offerings) - if type(offerings) ~= 'table' then - return false - end - for _, v in ipairs(offerings) do - if type(v) ~= 'string' or v == '' then - return false - end - end - return true + if type(offerings) ~= 'table' then + return false + end + for _, v in ipairs(offerings) do + if type(v) ~= 'string' or v == '' then + return false + end + end + return true end ---@alias CapabilityClass string @@ -55,32 +55,32 @@ Capability.__index = Capability ---@return Capability? ---@return string error function new.Capability(class, id, control_ch, offerings) - if not valid_class(class) then - return nil, "invalid capability class" - end + if not valid_class(class) then + return nil, "invalid capability class" + end - if not valid_id(id) then - return nil, "invalid capability id" - end + if not valid_id(id) then + return nil, "invalid capability id" + end - if getmetatable(control_ch) ~= ChannelMT then - return nil, "invalid capability control_ch" - end + if getmetatable(control_ch) ~= ChannelMT then + return nil, "invalid capability control_ch" + end - if not valid_offerings(offerings) then - return nil, "invalid capability offerings" - end + if not valid_offerings(offerings) then + return nil, "invalid capability offerings" + end - local offerings_map = list_to_map(offerings) + local offerings_map = list_to_map(offerings) - local capability = setmetatable({ - class = class, - id = id, - offerings = offerings_map, - control_ch = control_ch, - }, Capability) + local capability = setmetatable({ + class = class, + id = id, + offerings = offerings_map, + control_ch = control_ch, + }, Capability) - return capability, "" + return capability, "" end ---@param id CapabilityId @@ -88,17 +88,17 @@ end ---@return Capability? ---@return string error function new.ModemCapability(id, control_ch) - local offerings = { - 'get', - 'enable', - 'disable', - 'restart', - 'connect', - 'disconnect', - 'listen_for_sim', - 'set_signal_update_freq', - } - return new.Capability('modem', id, control_ch, offerings) + local offerings = { + 'get', + 'enable', + 'disable', + 'restart', + 'connect', + 'disconnect', + 'listen_for_sim', + 'set_signal_update_freq', + } + return new.Capability('modem', id, control_ch, offerings) end ---@param id CapabilityId @@ -106,8 +106,8 @@ end ---@return Capability? ---@return string error function new.GeoCapability(id, control_ch) - local offerings = {} - return new.Capability('geo', id, control_ch, offerings) + local offerings = {} + return new.Capability('geo', id, control_ch, offerings) end ---@param id CapabilityId @@ -115,8 +115,8 @@ end ---@return Capability? ---@return string error function new.TimeCapability(id, control_ch) - local offerings = {} - return new.Capability('time', id, control_ch, offerings) + local offerings = {} + return new.Capability('time', id, control_ch, offerings) end ---@param id CapabilityId @@ -124,15 +124,15 @@ end ---@return Capability? ---@return string error function new.NetworkCapability(id, control_ch) - local offerings = { - 'validate', - 'plan', - 'apply', - 'snapshot', - 'probe_link', - 'read_counters', - } - return new.Capability('network', id, control_ch, offerings) + local offerings = { + 'validate', + 'plan', + 'apply', + 'snapshot', + 'probe_link', + 'read_counters', + } + return new.Capability('network', id, control_ch, offerings) end ---@param id CapabilityId @@ -140,13 +140,13 @@ end ---@return Capability? ---@return string error function new.NetworkConfigCapability(id, control_ch) - return new.Capability('network-config', id, control_ch, { - 'validate', - 'plan', - 'apply', - 'apply_live_weights', - 'apply_shaping', - }) + return new.Capability('network-config', id, control_ch, { + 'validate', + 'plan', + 'apply', + 'apply_live_weights', + 'apply_shaping', + }) end ---@param id CapabilityId @@ -154,10 +154,10 @@ end ---@return Capability? ---@return string error function new.NetworkStateCapability(id, control_ch) - return new.Capability('network-state', id, control_ch, { - 'snapshot', - 'watch', - }) + return new.Capability('network-state', id, control_ch, { + 'snapshot', + 'watch', + }) end ---@param id CapabilityId @@ -165,11 +165,11 @@ end ---@return Capability? ---@return string error function new.NetworkDiagnosticsCapability(id, control_ch) - return new.Capability('network-diagnostics', id, control_ch, { - 'probe_link', - 'read_counters', - 'speedtest', - }) + return new.Capability('network-diagnostics', id, control_ch, { + 'probe_link', + 'read_counters', + 'speedtest', + }) end @@ -179,13 +179,13 @@ end ---@return Capability? ---@return string error function new.WiredProviderCapability(id, control_ch) - return new.Capability('wired-provider', id, control_ch, { - 'snapshot', - 'watch', - 'apply_attachments', - 'set_poe', - 'bounce', - }) + return new.Capability('wired-provider', id, control_ch, { + 'snapshot', + 'watch', + 'apply_attachments', + 'set_poe', + 'bounce', + }) end ---@param id CapabilityId @@ -193,19 +193,19 @@ end ---@return Capability? ---@return string error function new.RadioCapability(id, control_ch) - local offerings = { - 'set_channels', - 'set_txpower', - 'set_country', - 'set_enabled', - 'add_interface', - 'delete_interface', - 'clear_radio_config', - 'set_report_period', - 'apply', - 'rollback', - } - return new.Capability('radio', id, control_ch, offerings) + local offerings = { + 'set_channels', + 'set_txpower', + 'set_country', + 'set_enabled', + 'add_interface', + 'delete_interface', + 'clear_radio_config', + 'set_report_period', + 'apply', + 'rollback', + } + return new.Capability('radio', id, control_ch, offerings) end ---@param id CapabilityId @@ -213,25 +213,25 @@ end ---@return Capability? ---@return string error function new.BandCapability(id, control_ch) - local offerings = { - 'set_log_level', - 'set_kicking', - 'set_station_counting', - 'set_rrm_mode', - 'set_neighbour_reports', - 'set_legacy_options', - 'set_band_priority', - 'set_band_kicking', - 'set_support_bonus', - 'set_update_freq', - 'set_client_inactive_kickoff', - 'set_cleanup', - 'set_networking', - 'apply', - 'clear', - 'rollback', - } - return new.Capability('band', id, control_ch, offerings) + local offerings = { + 'set_log_level', + 'set_kicking', + 'set_station_counting', + 'set_rrm_mode', + 'set_neighbour_reports', + 'set_legacy_options', + 'set_band_priority', + 'set_band_kicking', + 'set_support_bonus', + 'set_update_freq', + 'set_client_inactive_kickoff', + 'set_cleanup', + 'set_networking', + 'apply', + 'clear', + 'rollback', + } + return new.Capability('band', id, control_ch, offerings) end ---@param id CapabilityId @@ -239,10 +239,10 @@ end ---@return Capability? ---@return string error function new.SerialCapability(id, control_ch) - local offerings = { - 'open', 'close', 'write' - } - return new.Capability('serial', id, control_ch, offerings) + local offerings = { + 'open', 'close', 'write' + } + return new.Capability('serial', id, control_ch, offerings) end ---@param id CapabilityId @@ -250,11 +250,11 @@ end ---@return Capability? ---@return string error function new.FilesystemCapability(id, control_ch) - local offerings = { - 'read', - 'write' - } - return new.Capability('fs', id, control_ch, offerings) + local offerings = { + 'read', + 'write' + } + return new.Capability('fs', id, control_ch, offerings) end ---@param id CapabilityId @@ -262,11 +262,11 @@ end ---@return Capability? ---@return string error function new.UARTCapability(id, control_ch) - local offerings = { - 'status', - 'open', - } - return new.Capability('uart', id, control_ch, offerings) + local offerings = { + 'status', + 'open', + } + return new.Capability('uart', id, control_ch, offerings) end ---@param id CapabilityId @@ -274,7 +274,7 @@ end ---@return Capability? ---@return string error function new.CpuCapability(id, control_ch) - return new.Capability('cpu', id, control_ch, { 'get' }) + return new.Capability('cpu', id, control_ch, { 'get' }) end ---@param id CapabilityId @@ -282,7 +282,7 @@ end ---@return Capability? ---@return string error function new.MemoryCapability(id, control_ch) - return new.Capability('memory', id, control_ch, { 'get' }) + return new.Capability('memory', id, control_ch, { 'get' }) end ---@param id CapabilityId @@ -290,7 +290,7 @@ end ---@return Capability? ---@return string error function new.ThermalCapability(id, control_ch) - return new.Capability('thermal', id, control_ch, { 'get' }) + return new.Capability('thermal', id, control_ch, { 'get' }) end ---@param id CapabilityId @@ -298,7 +298,7 @@ end ---@return Capability? ---@return string error function new.PlatformCapability(id, control_ch) - return new.Capability('platform', id, control_ch, { 'get' }) + return new.Capability('platform', id, control_ch, { 'get' }) end ---@param id CapabilityId @@ -306,7 +306,7 @@ end ---@return Capability? ---@return string error function new.PowerCapability(id, control_ch) - return new.Capability('power', id, control_ch, { 'shutdown', 'reboot' }) + return new.Capability('power', id, control_ch, { 'shutdown', 'reboot' }) end ---@param id CapabilityId @@ -314,7 +314,7 @@ end ---@return Capability? ---@return string error function new.UsbCapability(id, control_ch) - return new.Capability('usb', id, control_ch, { 'enable', 'disable' }) + return new.Capability('usb', id, control_ch, { 'enable', 'disable' }) end ---@class ControlError @@ -327,19 +327,19 @@ ControlError.__index = ControlError ---@param code integer? ---@return ControlError function new.ControlError(reason, code) - if type(reason) ~= 'string' then - reason = tostring(reason) - end - - if type(code) ~= 'number' or code < 0 then - code = 1 - end - - local control_error = setmetatable({ - reason = reason, - code = code, - }, ControlError) - return control_error + if type(reason) ~= 'string' then + reason = tostring(reason) + end + + if type(code) ~= 'number' or code < 0 then + code = 1 + end + + local control_error = setmetatable({ + reason = reason, + code = code, + }, ControlError) + return control_error end ---@param id CapabilityId @@ -383,7 +383,7 @@ function new.ArtifactStoreCapability(id, control_ch) end return { - Capability = Capability, - ControlError = ControlError, - new = new, + Capability = Capability, + ControlError = ControlError, + new = new, } diff --git a/src/services/hal/types/capability_args.lua b/src/services/hal/types/capability_args.lua index fe503c46..c192bc7c 100644 --- a/src/services/hal/types/capability_args.lua +++ b/src/services/hal/types/capability_args.lua @@ -12,18 +12,18 @@ ModemGetOpts.__index = ModemGetOpts ---@return ModemGetOpts? ---@return string error function new.ModemGetOpts(field, timescale) - if type(field) ~= 'string' or field == '' then - return nil, "invalid field" - end - - if timescale ~= nil and (type(timescale) ~= 'number' or timescale < 0) then - return nil, "invalid timescale" - end - - return setmetatable({ - field = field, - timescale = timescale, - }, ModemGetOpts), "" + if type(field) ~= 'string' or field == '' then + return nil, "invalid field" + end + + if timescale ~= nil and (type(timescale) ~= 'number' or timescale < 0) then + return nil, "invalid timescale" + end + + return setmetatable({ + field = field, + timescale = timescale, + }, ModemGetOpts), "" end ---@class ModemConnectOpts @@ -36,12 +36,12 @@ ModemConnectOpts.__index = ModemConnectOpts ---@return ModemConnectOpts? ---@return string error function new.ModemConnectOpts(connection_string) - if type(connection_string) ~= 'string' or connection_string == '' then - return nil, "invalid connection string" - end - return setmetatable({ - connection_string = connection_string, - }, ModemConnectOpts), "" + if type(connection_string) ~= 'string' or connection_string == '' then + return nil, "invalid connection string" + end + return setmetatable({ + connection_string = connection_string, + }, ModemConnectOpts), "" end ---@class ModemSignalUpdateOpts @@ -54,12 +54,12 @@ ModemSignalUpdateOpts.__index = ModemSignalUpdateOpts ---@return ModemSignalUpdateOpts? ---@return string error function new.ModemSignalUpdateOpts(frequency) - if type(frequency) ~= 'number' or frequency <= 0 then - return nil, "invalid frequency" - end - return setmetatable({ - frequency = frequency, - }, ModemSignalUpdateOpts), "" + if type(frequency) ~= 'number' or frequency <= 0 then + return nil, "invalid frequency" + end + return setmetatable({ + frequency = frequency, + }, ModemSignalUpdateOpts), "" end ---@class FilesystemReadOpts @@ -72,19 +72,19 @@ FilesystemReadOpts.__index = FilesystemReadOpts ---@return boolean valid ---@return string? error local function validate_filename(filename) - if type(filename) ~= 'string' or filename == '' then - return false, "filename must be a non-empty string" - end + if type(filename) ~= 'string' or filename == '' then + return false, "filename must be a non-empty string" + end - if filename:find('/') or filename:find('\\') then - return false, "filename cannot contain path separators" - end + if filename:find('/') or filename:find('\\') then + return false, "filename cannot contain path separators" + end - if filename == '..' or filename:find('^%.%.') or filename:find('%.%.') then - return false, "filename cannot contain .. segments" - end + if filename == '..' or filename:find('^%.%.') or filename:find('%.%.') then + return false, "filename cannot contain .. segments" + end - return true, nil + return true, nil end ---Create a new FilesystemReadOpts @@ -92,13 +92,13 @@ end ---@return FilesystemReadOpts? ---@return string error function new.FilesystemReadOpts(filename) - local valid, err = validate_filename(filename) - if not valid then - return nil, err - end - return setmetatable({ - filename = filename, - }, FilesystemReadOpts), "" + local valid, err = validate_filename(filename) + if not valid then + return nil, err + end + return setmetatable({ + filename = filename, + }, FilesystemReadOpts), "" end ---@class FilesystemWriteOpts @@ -113,17 +113,17 @@ FilesystemWriteOpts.__index = FilesystemWriteOpts ---@return FilesystemWriteOpts? ---@return string error function new.FilesystemWriteOpts(filename, data) - local valid, err = validate_filename(filename) - if not valid then - return nil, err - end - if type(data) ~= 'string' then - return nil, "invalid data" - end - return setmetatable({ - filename = filename, - data = data, - }, FilesystemWriteOpts), "" + local valid, err = validate_filename(filename) + if not valid then + return nil, err + end + if type(data) ~= 'string' then + return nil, "invalid data" + end + return setmetatable({ + filename = filename, + data = data, + }, FilesystemWriteOpts), "" end ---------------------------------------------------------------------- @@ -138,21 +138,21 @@ UARTOpenOpts.__index = UARTOpenOpts ---@return UARTOpenOpts?|nil ---@return string function new.UARTOpenOpts(opts) - if opts ~= nil and type(opts) ~= 'table' then - return nil, 'invalid uart open opts' - end + if opts ~= nil and type(opts) ~= 'table' then + return nil, 'invalid uart open opts' + end - opts = opts or {} + opts = opts or {} - -- Deliberately empty for now. - -- On current OpenWrt targets UART line settings are assumed to come from - -- platform/devicetree configuration. Runtime termios configuration can be - -- added later without changing the capability surface. - for k in pairs(opts) do - return nil, 'unsupported uart open option: ' .. tostring(k) - end + -- Deliberately empty for now. + -- On current OpenWrt targets UART line settings are assumed to come from + -- platform/devicetree configuration. Runtime termios configuration can be + -- added later without changing the capability surface. + for k in pairs(opts) do + return nil, 'unsupported uart open option: ' .. tostring(k) + end - return setmetatable({}, UARTOpenOpts), '' + return setmetatable({}, UARTOpenOpts), '' end ---@class UARTWriteOpts @@ -165,12 +165,12 @@ UARTWriteOpts.__index = UARTWriteOpts ---@return UARTWriteOpts? ---@return string error function new.UARTWriteOpts(data) - if type(data) ~= 'string' or data == '' then - return nil, "data must be a non-empty string" - end - return setmetatable({ - data = data, - }, UARTWriteOpts), "" + if type(data) ~= 'string' or data == '' then + return nil, "data must be a non-empty string" + end + return setmetatable({ + data = data, + }, UARTWriteOpts), "" end ---@class MemoryGetOpts @@ -185,13 +185,13 @@ MemoryGetOpts.__index = MemoryGetOpts ---@return MemoryGetOpts? ---@return string error function new.MemoryGetOpts(field, max_age) - if type(field) ~= 'string' or field == '' then - return nil, "invalid field" - end - if type(max_age) ~= 'number' or max_age < 0 then - return nil, "invalid max_age" - end - return setmetatable({ field = field, max_age = max_age }, MemoryGetOpts), "" + if type(field) ~= 'string' or field == '' then + return nil, "invalid field" + end + if type(max_age) ~= 'number' or max_age < 0 then + return nil, "invalid max_age" + end + return setmetatable({ field = field, max_age = max_age }, MemoryGetOpts), "" end ---@class CpuGetOpts @@ -206,13 +206,13 @@ CpuGetOpts.__index = CpuGetOpts ---@return CpuGetOpts? ---@return string error function new.CpuGetOpts(field, max_age) - if type(field) ~= 'string' or field == '' then - return nil, "invalid field" - end - if type(max_age) ~= 'number' or max_age < 0 then - return nil, "invalid max_age" - end - return setmetatable({ field = field, max_age = max_age }, CpuGetOpts), "" + if type(field) ~= 'string' or field == '' then + return nil, "invalid field" + end + if type(max_age) ~= 'number' or max_age < 0 then + return nil, "invalid max_age" + end + return setmetatable({ field = field, max_age = max_age }, CpuGetOpts), "" end ---@class ThermalGetOpts @@ -225,10 +225,10 @@ ThermalGetOpts.__index = ThermalGetOpts ---@return ThermalGetOpts? ---@return string error function new.ThermalGetOpts(max_age) - if type(max_age) ~= 'number' or max_age < 0 then - return nil, "invalid max_age" - end - return setmetatable({ max_age = max_age }, ThermalGetOpts), "" + if type(max_age) ~= 'number' or max_age < 0 then + return nil, "invalid max_age" + end + return setmetatable({ max_age = max_age }, ThermalGetOpts), "" end ---@class PlatformGetOpts @@ -243,13 +243,13 @@ PlatformGetOpts.__index = PlatformGetOpts ---@return PlatformGetOpts? ---@return string error function new.PlatformGetOpts(field, max_age) - if type(field) ~= 'string' or field == '' then - return nil, "invalid field" - end - if type(max_age) ~= 'number' or max_age < 0 then - return nil, "invalid max_age" - end - return setmetatable({ field = field, max_age = max_age }, PlatformGetOpts), "" + if type(field) ~= 'string' or field == '' then + return nil, "invalid field" + end + if type(max_age) ~= 'number' or max_age < 0 then + return nil, "invalid max_age" + end + return setmetatable({ field = field, max_age = max_age }, PlatformGetOpts), "" end ---@class PowerActionOpts @@ -262,10 +262,10 @@ PowerActionOpts.__index = PowerActionOpts ---@return PowerActionOpts? ---@return string error function new.PowerActionOpts(delay) - if delay ~= nil and (type(delay) ~= 'number' or delay < 0) then - return nil, "invalid delay" - end - return setmetatable({ delay = delay }, PowerActionOpts), "" + if delay ~= nil and (type(delay) ~= 'number' or delay < 0) then + return nil, "invalid delay" + end + return setmetatable({ delay = delay }, PowerActionOpts), "" end ---@class ControlStoreGetOpts @@ -524,21 +524,21 @@ end local RADIO_VALID_BANDS = { '2g', '5g' } local RADIO_VALID_HTMODES = { - 'HE20', 'HE40+', 'HE40-', 'HE80', 'HE160', - 'HT20', 'HT40+', 'HT40-', - 'VHT20', 'VHT40+', 'VHT40-', 'VHT80', 'VHT160', + 'HE20', 'HE40+', 'HE40-', 'HE80', 'HE160', + 'HT20', 'HT40+', 'HT40-', + 'VHT20', 'VHT40+', 'VHT40-', 'VHT80', 'VHT160', } local RADIO_VALID_ENCRYPTIONS = { - 'none', 'wep', 'psk', 'psk2', 'psk-mixed', - 'sae', 'sae-mixed', 'owe', 'wpa', 'wpa2', 'wpa3', + 'none', 'wep', 'psk', 'psk2', 'psk-mixed', + 'sae', 'sae-mixed', 'owe', 'wpa', 'wpa2', 'wpa3', } local RADIO_VALID_IFACE_MODES = { 'ap', 'sta', 'adhoc', 'mesh', 'monitor' } local function is_in(value, list) - for _, v in ipairs(list) do - if v == value then return true end - end - return false + for _, v in ipairs(list) do + if v == value then return true end + end + return false end ---@alias RadioBand '2g'|'5g' @@ -561,22 +561,22 @@ RadioSetChannelsOpts.__index = RadioSetChannelsOpts ---@return RadioSetChannelsOpts? ---@return string error function new.RadioSetChannelsOpts(band, channel, htmode, channels) - if not is_in(band, RADIO_VALID_BANDS) then - return nil, 'band must be one of: ' .. table.concat(RADIO_VALID_BANDS, ', ') - end - if not is_in(htmode, RADIO_VALID_HTMODES) then - return nil, 'htmode must be one of: ' .. table.concat(RADIO_VALID_HTMODES, ', ') - end - if channel == 'auto' then - if type(channels) ~= 'table' or #channels == 0 then - return nil, 'channels must be a non-empty list when channel is "auto"' - end - elseif type(channel) ~= 'number' and type(channel) ~= 'string' then - return nil, 'channel must be a number, string, or "auto"' - end - return setmetatable({ - band = band, channel = channel, htmode = htmode, channels = channels, - }, RadioSetChannelsOpts), "" + if not is_in(band, RADIO_VALID_BANDS) then + return nil, 'band must be one of: ' .. table.concat(RADIO_VALID_BANDS, ', ') + end + if not is_in(htmode, RADIO_VALID_HTMODES) then + return nil, 'htmode must be one of: ' .. table.concat(RADIO_VALID_HTMODES, ', ') + end + if channel == 'auto' then + if type(channels) ~= 'table' or #channels == 0 then + return nil, 'channels must be a non-empty list when channel is "auto"' + end + elseif type(channel) ~= 'number' and type(channel) ~= 'string' then + return nil, 'channel must be a number, string, or "auto"' + end + return setmetatable({ + band = band, channel = channel, htmode = htmode, channels = channels, + }, RadioSetChannelsOpts), "" end ---@class RadioSetTxpowerOpts @@ -588,10 +588,10 @@ RadioSetTxpowerOpts.__index = RadioSetTxpowerOpts ---@return RadioSetTxpowerOpts? ---@return string error function new.RadioSetTxpowerOpts(txpower) - if type(txpower) ~= 'number' and type(txpower) ~= 'string' then - return nil, 'txpower must be a number or string' - end - return setmetatable({ txpower = txpower }, RadioSetTxpowerOpts), "" + if type(txpower) ~= 'number' and type(txpower) ~= 'string' then + return nil, 'txpower must be a number or string' + end + return setmetatable({ txpower = txpower }, RadioSetTxpowerOpts), "" end ---@class RadioSetCountryOpts @@ -603,10 +603,10 @@ RadioSetCountryOpts.__index = RadioSetCountryOpts ---@return RadioSetCountryOpts? ---@return string error function new.RadioSetCountryOpts(country) - if type(country) ~= 'string' or #country ~= 2 then - return nil, 'country must be a 2-character string' - end - return setmetatable({ country = country:upper() }, RadioSetCountryOpts), "" + if type(country) ~= 'string' or #country ~= 2 then + return nil, 'country must be a 2-character string' + end + return setmetatable({ country = country:upper() }, RadioSetCountryOpts), "" end ---@class RadioSetEnabledOpts @@ -618,10 +618,10 @@ RadioSetEnabledOpts.__index = RadioSetEnabledOpts ---@return RadioSetEnabledOpts? ---@return string error function new.RadioSetEnabledOpts(enabled) - if type(enabled) ~= 'boolean' then - return nil, 'enabled must be a boolean' - end - return setmetatable({ enabled = enabled }, RadioSetEnabledOpts), "" + if type(enabled) ~= 'boolean' then + return nil, 'enabled must be a boolean' + end + return setmetatable({ enabled = enabled }, RadioSetEnabledOpts), "" end ---@class RadioAddInterfaceOpts @@ -643,28 +643,28 @@ RadioAddInterfaceOpts.__index = RadioAddInterfaceOpts ---@return RadioAddInterfaceOpts? ---@return string error function new.RadioAddInterfaceOpts(ssid, encryption, password, network, mode, enable_steering) - if type(ssid) ~= 'string' or ssid == '' then - return nil, 'ssid must be a non-empty string' - end - if not is_in(encryption, RADIO_VALID_ENCRYPTIONS) then - return nil, 'encryption must be one of: ' .. table.concat(RADIO_VALID_ENCRYPTIONS, ', ') - end - if type(password) ~= 'string' then - return nil, 'password must be a string' - end - if type(network) ~= 'string' or network == '' then - return nil, 'network must be a non-empty string' - end - if not is_in(mode, RADIO_VALID_IFACE_MODES) then - return nil, 'mode must be one of: ' .. table.concat(RADIO_VALID_IFACE_MODES, ', ') - end - if type(enable_steering) ~= 'boolean' then - return nil, 'enable_steering must be a boolean' - end - return setmetatable({ - ssid = ssid, encryption = encryption, password = password, - network = network, mode = mode, enable_steering = enable_steering, - }, RadioAddInterfaceOpts), "" + if type(ssid) ~= 'string' or ssid == '' then + return nil, 'ssid must be a non-empty string' + end + if not is_in(encryption, RADIO_VALID_ENCRYPTIONS) then + return nil, 'encryption must be one of: ' .. table.concat(RADIO_VALID_ENCRYPTIONS, ', ') + end + if type(password) ~= 'string' then + return nil, 'password must be a string' + end + if type(network) ~= 'string' or network == '' then + return nil, 'network must be a non-empty string' + end + if not is_in(mode, RADIO_VALID_IFACE_MODES) then + return nil, 'mode must be one of: ' .. table.concat(RADIO_VALID_IFACE_MODES, ', ') + end + if type(enable_steering) ~= 'boolean' then + return nil, 'enable_steering must be a boolean' + end + return setmetatable({ + ssid = ssid, encryption = encryption, password = password, + network = network, mode = mode, enable_steering = enable_steering, + }, RadioAddInterfaceOpts), "" end ---@class RadioDeleteInterfaceOpts @@ -676,10 +676,10 @@ RadioDeleteInterfaceOpts.__index = RadioDeleteInterfaceOpts ---@return RadioDeleteInterfaceOpts? ---@return string error function new.RadioDeleteInterfaceOpts(interface) - if type(interface) ~= 'string' or interface == '' then - return nil, 'interface must be a non-empty string' - end - return setmetatable({ interface = interface }, RadioDeleteInterfaceOpts), "" + if type(interface) ~= 'string' or interface == '' then + return nil, 'interface must be a non-empty string' + end + return setmetatable({ interface = interface }, RadioDeleteInterfaceOpts), "" end ---@class RadioSetReportPeriodOpts @@ -691,10 +691,10 @@ RadioSetReportPeriodOpts.__index = RadioSetReportPeriodOpts ---@return RadioSetReportPeriodOpts? ---@return string error function new.RadioSetReportPeriodOpts(period) - if type(period) ~= 'number' or period <= 0 then - return nil, 'period must be a positive number' - end - return setmetatable({ period = period }, RadioSetReportPeriodOpts), "" + if type(period) ~= 'number' or period <= 0 then + return nil, 'period must be a positive number' + end + return setmetatable({ period = period }, RadioSetReportPeriodOpts), "" end ---@class RadioCapabilityReply @@ -708,14 +708,14 @@ end local BAND_VALID_KICK_MODES = { 'none', 'compare', 'absolute', 'both' } local BAND_VALID_RRM_MODES = { 'PAT' } local BAND_VALID_LEGACY_KEYS = { - 'eval_probe_req', 'eval_assoc_req', 'eval_auth_req', - 'min_probe_count', 'deny_assoc_reason', 'deny_auth_reason', + 'eval_probe_req', 'eval_assoc_req', 'eval_auth_req', + 'min_probe_count', 'deny_assoc_reason', 'deny_auth_reason', } local BAND_VALID_KICKING_OPTS = { - 'rssi_center', 'rssi_reward_threshold', 'rssi_reward', - 'rssi_penalty_threshold', 'rssi_penalty', 'rssi_weight', - 'channel_util_reward_threshold', 'channel_util_reward', - 'channel_util_penalty_threshold', 'channel_util_penalty', + 'rssi_center', 'rssi_reward_threshold', 'rssi_reward', + 'rssi_penalty_threshold', 'rssi_penalty', 'rssi_weight', + 'channel_util_reward_threshold', 'channel_util_reward', + 'channel_util_penalty_threshold', 'channel_util_penalty', } local BAND_VALID_UPDATE_KEYS = { 'client', 'chan_util', 'hostapd', 'beacon_reports', 'tcp_con' } local BAND_VALID_CLEANUP_KEYS = { 'probe', 'client', 'ap' } @@ -739,10 +739,10 @@ BandSetLogLevelOpts.__index = BandSetLogLevelOpts ---@return BandSetLogLevelOpts? ---@return string error function new.BandSetLogLevelOpts(level) - if type(level) ~= 'number' or level < 0 then - return nil, 'level must be a non-negative number' - end - return setmetatable({ level = level }, BandSetLogLevelOpts), "" + if type(level) ~= 'number' or level < 0 then + return nil, 'level must be a non-negative number' + end + return setmetatable({ level = level }, BandSetLogLevelOpts), "" end ---@class BandSetKickingOpts @@ -760,24 +760,24 @@ BandSetKickingOpts.__index = BandSetKickingOpts ---@return BandSetKickingOpts? ---@return string error function new.BandSetKickingOpts(mode, bandwidth_threshold, kicking_threshold, evals_before_kick) - if not is_in(mode, BAND_VALID_KICK_MODES) then - return nil, 'mode must be one of: ' .. table.concat(BAND_VALID_KICK_MODES, ', ') - end - if type(bandwidth_threshold) ~= 'number' or bandwidth_threshold < 0 then - return nil, 'bandwidth_threshold must be a non-negative number' - end - if type(kicking_threshold) ~= 'number' or kicking_threshold < 0 then - return nil, 'kicking_threshold must be a non-negative number' - end - if type(evals_before_kick) ~= 'number' or evals_before_kick < 0 then - return nil, 'evals_before_kick must be a non-negative integer' - end - return setmetatable({ - mode = mode, - bandwidth_threshold = bandwidth_threshold, - kicking_threshold = kicking_threshold, - evals_before_kick = evals_before_kick, - }, BandSetKickingOpts), "" + if not is_in(mode, BAND_VALID_KICK_MODES) then + return nil, 'mode must be one of: ' .. table.concat(BAND_VALID_KICK_MODES, ', ') + end + if type(bandwidth_threshold) ~= 'number' or bandwidth_threshold < 0 then + return nil, 'bandwidth_threshold must be a non-negative number' + end + if type(kicking_threshold) ~= 'number' or kicking_threshold < 0 then + return nil, 'kicking_threshold must be a non-negative number' + end + if type(evals_before_kick) ~= 'number' or evals_before_kick < 0 then + return nil, 'evals_before_kick must be a non-negative integer' + end + return setmetatable({ + mode = mode, + bandwidth_threshold = bandwidth_threshold, + kicking_threshold = kicking_threshold, + evals_before_kick = evals_before_kick, + }, BandSetKickingOpts), "" end ---@class BandSetStationCountingOpts @@ -791,16 +791,16 @@ BandSetStationCountingOpts.__index = BandSetStationCountingOpts ---@return BandSetStationCountingOpts? ---@return string error function new.BandSetStationCountingOpts(use_station_count, max_station_diff) - if type(use_station_count) ~= 'boolean' then - return nil, 'use_station_count must be a boolean' - end - if type(max_station_diff) ~= 'number' or max_station_diff < 0 then - return nil, 'max_station_diff must be a non-negative integer' - end - return setmetatable({ - use_station_count = use_station_count, - max_station_diff = max_station_diff, - }, BandSetStationCountingOpts), "" + if type(use_station_count) ~= 'boolean' then + return nil, 'use_station_count must be a boolean' + end + if type(max_station_diff) ~= 'number' or max_station_diff < 0 then + return nil, 'max_station_diff must be a non-negative integer' + end + return setmetatable({ + use_station_count = use_station_count, + max_station_diff = max_station_diff, + }, BandSetStationCountingOpts), "" end ---@class BandSetRrmModeOpts @@ -812,10 +812,10 @@ BandSetRrmModeOpts.__index = BandSetRrmModeOpts ---@return BandSetRrmModeOpts? ---@return string error function new.BandSetRrmModeOpts(mode) - if not is_in(mode, BAND_VALID_RRM_MODES) then - return nil, 'mode must be one of: ' .. table.concat(BAND_VALID_RRM_MODES, ', ') - end - return setmetatable({ mode = mode }, BandSetRrmModeOpts), "" + if not is_in(mode, BAND_VALID_RRM_MODES) then + return nil, 'mode must be one of: ' .. table.concat(BAND_VALID_RRM_MODES, ', ') + end + return setmetatable({ mode = mode }, BandSetRrmModeOpts), "" end ---@class BandSetNeighbourReportsOpts @@ -829,18 +829,18 @@ BandSetNeighbourReportsOpts.__index = BandSetNeighbourReportsOpts ---@return BandSetNeighbourReportsOpts? ---@return string error function new.BandSetNeighbourReportsOpts(dyn_report_num, disassoc_report_len) - local dyn = tonumber(dyn_report_num) - local dis = tonumber(disassoc_report_len) - if not dyn or dyn < 0 then - return nil, 'dyn_report_num must be a non-negative integer' - end - if not dis or dis < 0 then - return nil, 'disassoc_report_len must be a non-negative integer' - end - return setmetatable({ - dyn_report_num = dyn, - disassoc_report_len = dis, - }, BandSetNeighbourReportsOpts), "" + local dyn = tonumber(dyn_report_num) + local dis = tonumber(disassoc_report_len) + if not dyn or dyn < 0 then + return nil, 'dyn_report_num must be a non-negative integer' + end + if not dis or dis < 0 then + return nil, 'disassoc_report_len must be a non-negative integer' + end + return setmetatable({ + dyn_report_num = dyn, + disassoc_report_len = dis, + }, BandSetNeighbourReportsOpts), "" end ---@class BandLegacyOpts @@ -860,15 +860,15 @@ BandSetLegacyOptionsOpts.__index = BandSetLegacyOptionsOpts ---@return BandSetLegacyOptionsOpts? ---@return string error function new.BandSetLegacyOptionsOpts(opts) - if type(opts) ~= 'table' then - return nil, 'opts must be a table' - end - for key in pairs(opts) do - if not is_in(key, BAND_VALID_LEGACY_KEYS) then - return nil, 'unknown legacy option key: ' .. tostring(key) - end - end - return setmetatable({ opts = opts }, BandSetLegacyOptionsOpts), "" + if type(opts) ~= 'table' then + return nil, 'opts must be a table' + end + for key in pairs(opts) do + if not is_in(key, BAND_VALID_LEGACY_KEYS) then + return nil, 'unknown legacy option key: ' .. tostring(key) + end + end + return setmetatable({ opts = opts }, BandSetLegacyOptionsOpts), "" end ---@class BandSetBandPriorityOpts @@ -882,14 +882,14 @@ BandSetBandPriorityOpts.__index = BandSetBandPriorityOpts ---@return BandSetBandPriorityOpts? ---@return string error function new.BandSetBandPriorityOpts(band, priority) - local b = type(band) == 'string' and band:upper() or '' - if not is_in(b, BAND_VALID_BANDS) then - return nil, 'band must be "2G" or "5G"' - end - if type(priority) ~= 'number' or priority < 0 then - return nil, 'priority must be a non-negative number' - end - return setmetatable({ band = b, priority = priority }, BandSetBandPriorityOpts), "" + local b = type(band) == 'string' and band:upper() or '' + if not is_in(b, BAND_VALID_BANDS) then + return nil, 'band must be "2G" or "5G"' + end + if type(priority) ~= 'number' or priority < 0 then + return nil, 'priority must be a non-negative number' + end + return setmetatable({ band = b, priority = priority }, BandSetBandPriorityOpts), "" end ---@class BandKickingOptions @@ -915,22 +915,22 @@ BandSetBandKickingOpts.__index = BandSetBandKickingOpts ---@return BandSetBandKickingOpts? ---@return string error function new.BandSetBandKickingOpts(band, options) - local b = type(band) == 'string' and band:upper() or '' - if not is_in(b, BAND_VALID_BANDS) then - return nil, 'band must be "2G" or "5G"' - end - if type(options) ~= 'table' then - return nil, 'options must be a table' - end - for key, value in pairs(options) do - if not is_in(key, BAND_VALID_KICKING_OPTS) then - return nil, 'unknown band kicking option: ' .. tostring(key) - end - if tonumber(value) == nil then - return nil, 'value for ' .. key .. ' must be a number' - end - end - return setmetatable({ band = b, options = options }, BandSetBandKickingOpts), "" + local b = type(band) == 'string' and band:upper() or '' + if not is_in(b, BAND_VALID_BANDS) then + return nil, 'band must be "2G" or "5G"' + end + if type(options) ~= 'table' then + return nil, 'options must be a table' + end + for key, value in pairs(options) do + if not is_in(key, BAND_VALID_KICKING_OPTS) then + return nil, 'unknown band kicking option: ' .. tostring(key) + end + if tonumber(value) == nil then + return nil, 'value for ' .. key .. ' must be a number' + end + end + return setmetatable({ band = b, options = options }, BandSetBandKickingOpts), "" end ---@class BandSetSupportBonusOpts @@ -946,17 +946,17 @@ BandSetSupportBonusOpts.__index = BandSetSupportBonusOpts ---@return BandSetSupportBonusOpts? ---@return string error function new.BandSetSupportBonusOpts(band, support, reward) - local b = type(band) == 'string' and band:upper() or '' - if not is_in(b, BAND_VALID_BANDS) then - return nil, 'band must be "2G" or "5G"' - end - if not is_in(support, BAND_VALID_SUPPORTS) then - return nil, 'support must be "ht" or "vht"' - end - if type(reward) ~= 'number' then - return nil, 'reward must be a number' - end - return setmetatable({ band = b, support = support, reward = reward }, BandSetSupportBonusOpts), "" + local b = type(band) == 'string' and band:upper() or '' + if not is_in(b, BAND_VALID_BANDS) then + return nil, 'band must be "2G" or "5G"' + end + if not is_in(support, BAND_VALID_SUPPORTS) then + return nil, 'support must be "ht" or "vht"' + end + if type(reward) ~= 'number' then + return nil, 'reward must be a number' + end + return setmetatable({ band = b, support = support, reward = reward }, BandSetSupportBonusOpts), "" end ---@class BandUpdateFreqOptions @@ -975,18 +975,18 @@ BandSetUpdateFreqOpts.__index = BandSetUpdateFreqOpts ---@return BandSetUpdateFreqOpts? ---@return string error function new.BandSetUpdateFreqOpts(updates) - if type(updates) ~= 'table' then - return nil, 'updates must be a table' - end - for key, value in pairs(updates) do - if not is_in(key, BAND_VALID_UPDATE_KEYS) then - return nil, 'unknown update key: ' .. tostring(key) - end - if type(value) ~= 'number' or value < 0 then - return nil, 'value for ' .. key .. ' must be a non-negative number' - end - end - return setmetatable({ updates = updates }, BandSetUpdateFreqOpts), "" + if type(updates) ~= 'table' then + return nil, 'updates must be a table' + end + for key, value in pairs(updates) do + if not is_in(key, BAND_VALID_UPDATE_KEYS) then + return nil, 'unknown update key: ' .. tostring(key) + end + if type(value) ~= 'number' or value < 0 then + return nil, 'value for ' .. key .. ' must be a non-negative number' + end + end + return setmetatable({ updates = updates }, BandSetUpdateFreqOpts), "" end ---@class BandSetClientInactiveKickoffOpts @@ -998,11 +998,11 @@ BandSetClientInactiveKickoffOpts.__index = BandSetClientInactiveKickoffOpts ---@return BandSetClientInactiveKickoffOpts? ---@return string error function new.BandSetClientInactiveKickoffOpts(timeout) - local t = tonumber(timeout) - if not t or t < 0 then - return nil, 'timeout must be a non-negative integer' - end - return setmetatable({ timeout = t }, BandSetClientInactiveKickoffOpts), "" + local t = tonumber(timeout) + if not t or t < 0 then + return nil, 'timeout must be a non-negative integer' + end + return setmetatable({ timeout = t }, BandSetClientInactiveKickoffOpts), "" end ---@class BandCleanupTimeouts @@ -1019,18 +1019,18 @@ BandSetCleanupOpts.__index = BandSetCleanupOpts ---@return BandSetCleanupOpts? ---@return string error function new.BandSetCleanupOpts(timeouts) - if type(timeouts) ~= 'table' then - return nil, 'timeouts must be a table' - end - for key, value in pairs(timeouts) do - if not is_in(key, BAND_VALID_CLEANUP_KEYS) then - return nil, 'unknown cleanup key: ' .. tostring(key) - end - if type(value) ~= 'number' or value < 0 then - return nil, 'value for cleanup.' .. key .. ' must be a non-negative number' - end - end - return setmetatable({ timeouts = timeouts }, BandSetCleanupOpts), "" + if type(timeouts) ~= 'table' then + return nil, 'timeouts must be a table' + end + for key, value in pairs(timeouts) do + if not is_in(key, BAND_VALID_CLEANUP_KEYS) then + return nil, 'unknown cleanup key: ' .. tostring(key) + end + if type(value) ~= 'number' or value < 0 then + return nil, 'value for cleanup.' .. key .. ' must be a non-negative number' + end + end + return setmetatable({ timeouts = timeouts }, BandSetCleanupOpts), "" end ---@class BandNetworkingOptions @@ -1050,27 +1050,27 @@ BandSetNetworkingOpts.__index = BandSetNetworkingOpts ---@return BandSetNetworkingOpts? ---@return string error function new.BandSetNetworkingOpts(method, options) - if not is_in(method, BAND_VALID_NET_METHODS) then - return nil, 'method must be one of: ' .. table.concat(BAND_VALID_NET_METHODS, ', ') - end - if type(options) ~= 'table' then - return nil, 'options must be a table' - end - for key, value in pairs(options) do - if not is_in(key, BAND_VALID_NET_OPTS) then - return nil, 'unknown networking option: ' .. tostring(key) - end - if key == 'ip' and type(value) ~= 'string' then - return nil, 'networking.ip must be a string' - end - if (key == 'port' or key == 'broadcast_port') and type(value) ~= 'number' then - return nil, 'networking.' .. key .. ' must be a number' - end - if key == 'enable_encryption' and type(value) ~= 'boolean' then - return nil, 'networking.enable_encryption must be a boolean' - end - end - return setmetatable({ method = method, options = options }, BandSetNetworkingOpts), "" + if not is_in(method, BAND_VALID_NET_METHODS) then + return nil, 'method must be one of: ' .. table.concat(BAND_VALID_NET_METHODS, ', ') + end + if type(options) ~= 'table' then + return nil, 'options must be a table' + end + for key, value in pairs(options) do + if not is_in(key, BAND_VALID_NET_OPTS) then + return nil, 'unknown networking option: ' .. tostring(key) + end + if key == 'ip' and type(value) ~= 'string' then + return nil, 'networking.ip must be a string' + end + if (key == 'port' or key == 'broadcast_port') and type(value) ~= 'number' then + return nil, 'networking.' .. key .. ' must be a number' + end + if key == 'enable_encryption' and type(value) ~= 'boolean' then + return nil, 'networking.enable_encryption must be a boolean' + end + end + return setmetatable({ method = method, options = options }, BandSetNetworkingOpts), "" end ---@class BandCapabilityReply @@ -1078,48 +1078,48 @@ end ---@field reason? string -- error message on failure return { - ModemGetOpts = ModemGetOpts, - ModemConnectOpts = ModemConnectOpts, - ModemSignalUpdateOpts = ModemSignalUpdateOpts, - FilesystemReadOpts = FilesystemReadOpts, - FilesystemWriteOpts = FilesystemWriteOpts, - UARTOpenOpts = UARTOpenOpts, - UARTWriteOpts = UARTWriteOpts, - MemoryGetOpts = MemoryGetOpts, - CpuGetOpts = CpuGetOpts, - ThermalGetOpts = ThermalGetOpts, - PlatformGetOpts = PlatformGetOpts, - PowerActionOpts = PowerActionOpts, - ControlStoreGetOpts = ControlStoreGetOpts, - ControlStorePutOpts = ControlStorePutOpts, - ControlStoreDeleteOpts = ControlStoreDeleteOpts, - ControlStoreListOpts = ControlStoreListOpts, - SignatureVerifyEd25519Opts = SignatureVerifyEd25519Opts, - ArtifactStoreCreateSinkOpts = ArtifactStoreCreateSinkOpts, - ArtifactStoreImportPathOpts = ArtifactStoreImportPathOpts, - ArtifactStoreImportSourceOpts = ArtifactStoreImportSourceOpts, - ArtifactStoreOpenOpts = ArtifactStoreOpenOpts, - ArtifactStoreDeleteOpts = ArtifactStoreDeleteOpts, - ArtifactStoreStatusOpts = ArtifactStoreStatusOpts, - RadioSetChannelsOpts = RadioSetChannelsOpts, - RadioSetTxpowerOpts = RadioSetTxpowerOpts, - RadioSetCountryOpts = RadioSetCountryOpts, - RadioSetEnabledOpts = RadioSetEnabledOpts, - RadioAddInterfaceOpts = RadioAddInterfaceOpts, - RadioDeleteInterfaceOpts = RadioDeleteInterfaceOpts, - RadioSetReportPeriodOpts = RadioSetReportPeriodOpts, - BandSetLogLevelOpts = BandSetLogLevelOpts, - BandSetKickingOpts = BandSetKickingOpts, - BandSetStationCountingOpts = BandSetStationCountingOpts, - BandSetRrmModeOpts = BandSetRrmModeOpts, - BandSetNeighbourReportsOpts = BandSetNeighbourReportsOpts, - BandSetLegacyOptionsOpts = BandSetLegacyOptionsOpts, - BandSetBandPriorityOpts = BandSetBandPriorityOpts, - BandSetBandKickingOpts = BandSetBandKickingOpts, - BandSetSupportBonusOpts = BandSetSupportBonusOpts, - BandSetUpdateFreqOpts = BandSetUpdateFreqOpts, - BandSetClientInactiveKickoffOpts = BandSetClientInactiveKickoffOpts, - BandSetCleanupOpts = BandSetCleanupOpts, - BandSetNetworkingOpts = BandSetNetworkingOpts, - new = new, + ModemGetOpts = ModemGetOpts, + ModemConnectOpts = ModemConnectOpts, + ModemSignalUpdateOpts = ModemSignalUpdateOpts, + FilesystemReadOpts = FilesystemReadOpts, + FilesystemWriteOpts = FilesystemWriteOpts, + UARTOpenOpts = UARTOpenOpts, + UARTWriteOpts = UARTWriteOpts, + MemoryGetOpts = MemoryGetOpts, + CpuGetOpts = CpuGetOpts, + ThermalGetOpts = ThermalGetOpts, + PlatformGetOpts = PlatformGetOpts, + PowerActionOpts = PowerActionOpts, + ControlStoreGetOpts = ControlStoreGetOpts, + ControlStorePutOpts = ControlStorePutOpts, + ControlStoreDeleteOpts = ControlStoreDeleteOpts, + ControlStoreListOpts = ControlStoreListOpts, + SignatureVerifyEd25519Opts = SignatureVerifyEd25519Opts, + ArtifactStoreCreateSinkOpts = ArtifactStoreCreateSinkOpts, + ArtifactStoreImportPathOpts = ArtifactStoreImportPathOpts, + ArtifactStoreImportSourceOpts = ArtifactStoreImportSourceOpts, + ArtifactStoreOpenOpts = ArtifactStoreOpenOpts, + ArtifactStoreDeleteOpts = ArtifactStoreDeleteOpts, + ArtifactStoreStatusOpts = ArtifactStoreStatusOpts, + RadioSetChannelsOpts = RadioSetChannelsOpts, + RadioSetTxpowerOpts = RadioSetTxpowerOpts, + RadioSetCountryOpts = RadioSetCountryOpts, + RadioSetEnabledOpts = RadioSetEnabledOpts, + RadioAddInterfaceOpts = RadioAddInterfaceOpts, + RadioDeleteInterfaceOpts = RadioDeleteInterfaceOpts, + RadioSetReportPeriodOpts = RadioSetReportPeriodOpts, + BandSetLogLevelOpts = BandSetLogLevelOpts, + BandSetKickingOpts = BandSetKickingOpts, + BandSetStationCountingOpts = BandSetStationCountingOpts, + BandSetRrmModeOpts = BandSetRrmModeOpts, + BandSetNeighbourReportsOpts = BandSetNeighbourReportsOpts, + BandSetLegacyOptionsOpts = BandSetLegacyOptionsOpts, + BandSetBandPriorityOpts = BandSetBandPriorityOpts, + BandSetBandKickingOpts = BandSetBandKickingOpts, + BandSetSupportBonusOpts = BandSetSupportBonusOpts, + BandSetUpdateFreqOpts = BandSetUpdateFreqOpts, + BandSetClientInactiveKickoffOpts = BandSetClientInactiveKickoffOpts, + BandSetCleanupOpts = BandSetCleanupOpts, + BandSetNetworkingOpts = BandSetNetworkingOpts, + new = new, } diff --git a/src/services/hal/types/core.lua b/src/services/hal/types/core.lua index f5296040..7476f59a 100644 --- a/src/services/hal/types/core.lua +++ b/src/services/hal/types/core.lua @@ -29,24 +29,24 @@ ControlRequest.__index = ControlRequest ---@return ControlRequest? ---@return string error function new.ControlRequest(verb, opts, reply_ch, cancel_op) - if type(verb) ~= 'string' or verb == '' then - return nil, "invalid verb" - end - - if type(opts) ~= 'table' then - return nil, "opts must be a table" - end - - if getmetatable(reply_ch) ~= ChannelMT then - return nil, "invalid reply_ch" - end - - return setmetatable({ - verb = verb, - opts = opts, - reply_ch = reply_ch, - cancel_op = cancel_op, - }, ControlRequest), "" + if type(verb) ~= 'string' or verb == '' then + return nil, "invalid verb" + end + + if type(opts) ~= 'table' then + return nil, "opts must be a table" + end + + if getmetatable(reply_ch) ~= ChannelMT then + return nil, "invalid reply_ch" + end + + return setmetatable({ + verb = verb, + opts = opts, + reply_ch = reply_ch, + cancel_op = cancel_op, + }, ControlRequest), "" end ---@class Reply @@ -63,15 +63,15 @@ Reply.__index = Reply ---@return Reply? ---@return string error function new.Reply(ok, reason, code) - if type(ok) ~= 'boolean' then - return nil, "invalid ok" - end - - return setmetatable({ - ok = ok, - reason = reason, - code = code, - }, Reply), "" + if type(ok) ~= 'boolean' then + return nil, "invalid ok" + end + + return setmetatable({ + ok = ok, + reason = reason, + code = code, + }, Reply), "" end ---@alias EmitMode 'event'|'state'|'meta'|'log' @@ -94,33 +94,33 @@ Emit.__index = Emit ---@return Emit? ---@return string error function new.Emit(class, id, mode, key, data) - if type(class) ~= 'string' or class == '' then - return nil, "invalid class" - end - - if type(id) ~= 'string' and type(id) ~= 'number' then - return nil, "invalid id" - end - - if mode ~= 'event' and mode ~= 'state' and mode ~= 'meta' and mode ~= 'log' then - return nil, "invalid mode" - end - - if type(key) ~= 'string' or key == '' then - return nil, "invalid key" - end - - if type(data) == 'nil' then - return nil, "data cannot be nil" - end - - return setmetatable({ - class = class, - id = id, - mode = mode, - key = key, - data = data, - }, Emit), "" + if type(class) ~= 'string' or class == '' then + return nil, "invalid class" + end + + if type(id) ~= 'string' and type(id) ~= 'number' then + return nil, "invalid id" + end + + if mode ~= 'event' and mode ~= 'state' and mode ~= 'meta' and mode ~= 'log' then + return nil, "invalid mode" + end + + if type(key) ~= 'string' or key == '' then + return nil, "invalid key" + end + + if type(data) == 'nil' then + return nil, "data cannot be nil" + end + + return setmetatable({ + class = class, + id = id, + mode = mode, + key = key, + data = data, + }, Emit), "" end ---@alias EventType 'added'|'removed' @@ -145,48 +145,48 @@ DeviceEvent.__index = DeviceEvent ---@return DeviceEvent? ---@return string error function new.DeviceEvent(event_type, class, id, meta, capabilities, ready_cond) - meta = meta or {} - capabilities = capabilities or {} - - if event_type ~= 'added' and event_type ~= 'removed' then - return nil, "invalid event_type" - end - - if type(class) ~= 'string' or class == '' then - return nil, "invalid class" - end - - if type(id) ~= 'string' and type(id) ~= 'number' then - return nil, "invalid id" - end - - if type(meta) ~= 'table' then - return nil, "invalid meta" - end - - if type(capabilities) ~= 'table' then - return nil, "invalid capabilities" - end - for _, cap in ipairs(capabilities) do - if getmetatable(cap) ~= cap_types.Capability then - return nil, "invalid capability in capabilities" - end - end - - if ready_cond ~= nil and type(ready_cond.signal) ~= 'function' then - return nil, "invalid ready_cond" - end - - local ev = setmetatable({ - event_type = event_type, - class = class, - id = id, - meta = meta, - capabilities = capabilities, - ready_cond = ready_cond, - }, DeviceEvent) - - return ev, "" + meta = meta or {} + capabilities = capabilities or {} + + if event_type ~= 'added' and event_type ~= 'removed' then + return nil, "invalid event_type" + end + + if type(class) ~= 'string' or class == '' then + return nil, "invalid class" + end + + if type(id) ~= 'string' and type(id) ~= 'number' then + return nil, "invalid id" + end + + if type(meta) ~= 'table' then + return nil, "invalid meta" + end + + if type(capabilities) ~= 'table' then + return nil, "invalid capabilities" + end + for _, cap in ipairs(capabilities) do + if getmetatable(cap) ~= cap_types.Capability then + return nil, "invalid capability in capabilities" + end + end + + if ready_cond ~= nil and type(ready_cond.signal) ~= 'function' then + return nil, "invalid ready_cond" + end + + local ev = setmetatable({ + event_type = event_type, + class = class, + id = id, + meta = meta, + capabilities = capabilities, + ready_cond = ready_cond, + }, DeviceEvent) + + return ev, "" end ---@class Device @@ -205,37 +205,37 @@ Device.__index = Device ---@return Device? ---@return string error function new.Device(class, id, meta, capabilities) - meta = meta or {} - capabilities = capabilities or {} - - if type(class) ~= 'string' or class == '' then - return nil, "invalid class" - end - - if type(id) ~= 'string' and type(id) ~= 'number' then - return nil, "invalid id" - end - - if type(meta) ~= 'table' then - return nil, "invalid meta" - end - - if type(capabilities) ~= 'table' then - return nil, "invalid capabilities" - end - for _, cap in ipairs(capabilities) do - if getmetatable(cap) ~= cap_types.Capability then - return nil, "invalid capability in capabilities" - end - end - - local dev = setmetatable({ - class = class, - id = id, - meta = meta, - capabilities = capabilities, - }, Device) - return dev, "" + meta = meta or {} + capabilities = capabilities or {} + + if type(class) ~= 'string' or class == '' then + return nil, "invalid class" + end + + if type(id) ~= 'string' and type(id) ~= 'number' then + return nil, "invalid id" + end + + if type(meta) ~= 'table' then + return nil, "invalid meta" + end + + if type(capabilities) ~= 'table' then + return nil, "invalid capabilities" + end + for _, cap in ipairs(capabilities) do + if getmetatable(cap) ~= cap_types.Capability then + return nil, "invalid capability in capabilities" + end + end + + local dev = setmetatable({ + class = class, + id = id, + meta = meta, + capabilities = capabilities, + }, Device) + return dev, "" end -- Todo types: @@ -266,37 +266,37 @@ UARTOpenReply.__index = UARTOpenReply ---@return UARTOpenReply?|nil ---@return string function new.UARTOpenReply(lease_id, session, path, baud, mode) - if type(lease_id) ~= 'string' or lease_id == '' then - return nil, 'invalid lease_id' - end - if session == nil then - return nil, 'missing session' - end - if type(path) ~= 'string' or path == '' then - return nil, 'invalid path' - end - if baud ~= nil and (type(baud) ~= 'number' or baud <= 0 or baud % 1 ~= 0) then - return nil, 'invalid baud' - end - if mode ~= nil and type(mode) ~= 'string' then - return nil, 'invalid mode' - end - - return setmetatable({ - lease_id = lease_id, - session = session, - path = path, - baud = baud, - mode = mode, - }, UARTOpenReply), '' + if type(lease_id) ~= 'string' or lease_id == '' then + return nil, 'invalid lease_id' + end + if session == nil then + return nil, 'missing session' + end + if type(path) ~= 'string' or path == '' then + return nil, 'invalid path' + end + if baud ~= nil and (type(baud) ~= 'number' or baud <= 0 or baud % 1 ~= 0) then + return nil, 'invalid baud' + end + if mode ~= nil and type(mode) ~= 'string' then + return nil, 'invalid mode' + end + + return setmetatable({ + lease_id = lease_id, + session = session, + path = path, + baud = baud, + mode = mode, + }, UARTOpenReply), '' end return { - ControlRequest = ControlRequest, - Reply = Reply, - Emit = Emit, - DeviceEvent = DeviceEvent, - Device = Device, - UARTOpenReply = UARTOpenReply, - new = new, + ControlRequest = ControlRequest, + Reply = Reply, + Emit = Emit, + DeviceEvent = DeviceEvent, + Device = Device, + UARTOpenReply = UARTOpenReply, + new = new, } diff --git a/src/services/hal/types/time.lua b/src/services/hal/types/time.lua index 715c7f4f..077f4497 100644 --- a/src/services/hal/types/time.lua +++ b/src/services/hal/types/time.lua @@ -21,29 +21,29 @@ local new = {} ---@return NTPEvent? ---@return string error function new.NTPEvent(stratum, action, offset, freq_drift_ppm) - if type(stratum) ~= 'number' then - return nil, "invalid stratum" - end - - if type(action) ~= 'string' or action == '' then - return nil, "invalid action" - end - - if type(offset) ~= 'number' then - return nil, "invalid offset" - end - - if type(freq_drift_ppm) ~= 'number' then - return nil, "invalid freq_drift_ppm" - end - - local event = setmetatable({ - stratum = stratum, - action = action, - offset = offset, - freq_drift_ppm = freq_drift_ppm, - }, NTPEvent) - return event, "" + if type(stratum) ~= 'number' then + return nil, "invalid stratum" + end + + if type(action) ~= 'string' or action == '' then + return nil, "invalid action" + end + + if type(offset) ~= 'number' then + return nil, "invalid offset" + end + + if type(freq_drift_ppm) ~= 'number' then + return nil, "invalid freq_drift_ppm" + end + + local event = setmetatable({ + stratum = stratum, + action = action, + offset = offset, + freq_drift_ppm = freq_drift_ppm, + }, NTPEvent) + return event, "" end ---@class TimeBackend @@ -54,7 +54,7 @@ local TimeBackend = {} TimeBackend.__index = TimeBackend return { - NTPEvent = NTPEvent, - TimeBackend = TimeBackend, - new = new, + NTPEvent = NTPEvent, + TimeBackend = TimeBackend, + new = new, } diff --git a/src/services/metrics/config.lua b/src/services/metrics/config.lua index 761ba024..a31c6f1b 100644 --- a/src/services/metrics/config.lua +++ b/src/services/metrics/config.lua @@ -23,13 +23,13 @@ local VALID_PROTOCOLS = { http = true, log = true, bus = true } local VALID_PROCESS_TYPES = { DiffTrigger = true, TimeTrigger = true, DeltaValue = true } local VALID_TEMPLATE_FIELDS = { - protocol = true, - process = true, + protocol = true, + process = true, } local VALID_METRIC_FIELDS = { - protocol = true, - process = true, - template = true, + protocol = true, + process = true, + template = true, } ------------------------------------------------------------------------------- @@ -39,36 +39,36 @@ local VALID_METRIC_FIELDS = { ---@param t any ---@return boolean local function is_array(t) - if type(t) ~= 'table' then return false end - local count = 0 - for k in pairs(t) do - if type(k) ~= 'number' or math.floor(k) ~= k or k < 1 then - return false - end - count = count + 1 - end - for i = 1, count do - if t[i] == nil then return false end - end - return true + if type(t) ~= 'table' then return false end + local count = 0 + for k in pairs(t) do + if type(k) ~= 'number' or math.floor(k) ~= k or k < 1 then + return false + end + count = count + 1 + end + for i = 1, count do + if t[i] == nil then return false end + end + return true end ---@param base table? ---@param override table? ---@return table local function merge_config(base, override) - if not base then return override or {} end - if not override then return base end - local result = {} - for k, v in pairs(base) do result[k] = v end - for k, v in pairs(override) do - if type(v) == 'table' and type(result[k]) == 'table' then - result[k] = merge_config(result[k], v) - else - result[k] = v - end - end - return result + if not base then return override or {} end + if not override then return base end + local result = {} + for k, v in pairs(base) do result[k] = v end + for k, v in pairs(override) do + if type(v) == 'table' and type(result[k]) == 'table' then + result[k] = merge_config(result[k], v) + else + result[k] = v + end + end + return result end --- Normalise a raw mainflux config table to a consistent field set. @@ -76,22 +76,22 @@ end ---@param config table ---@return table local function standardise_config(config) - local out = {} - out.thing_key = config.mainflux_key or config.thing_key - out.channels = config.mainflux_channels or config.channels - for _, channel in ipairs(out.channels or {}) do - channel.metadata = channel.metadata or {} - if type(channel.metadata) == 'userdata' then channel.metadata = {} end - if type(channel.name) == 'string' then - if string.find(channel.name, 'data') then - channel.metadata.channel_type = 'data' - elseif string.find(channel.name, 'control') then - channel.metadata.channel_type = 'events' - end - end - end - out.content = config.content - return out + local out = {} + out.thing_key = config.mainflux_key or config.thing_key + out.channels = config.mainflux_channels or config.channels + for _, channel in ipairs(out.channels or {}) do + channel.metadata = channel.metadata or {} + if type(channel.metadata) == 'userdata' then channel.metadata = {} end + if type(channel.name) == 'string' then + if string.find(channel.name, 'data') then + channel.metadata.channel_type = 'data' + elseif string.find(channel.name, 'control') then + channel.metadata.channel_type = 'events' + end + end + end + out.content = config.content + return out end --- Basic sanity-check for the cloud (Mainflux) config used for HTTP publish. @@ -99,19 +99,19 @@ end ---@return boolean ok ---@return string? error local function validate_http_config(config) - if not config then - return false, 'No cloud config set' - end - if not config.url then - return false, 'No cloud url set' - end - if type(config.url) ~= 'string' then - return false, 'Cloud url is not a string' - end - if not config.thing_key or not config.channels then - return false, 'Cloud thing_key / channels missing' - end - return true, nil + if not config then + return false, 'No cloud config set' + end + if not config.url then + return false, 'No cloud url set' + end + if type(config.url) ~= 'string' then + return false, 'Cloud url is not a string' + end + if not config.thing_key or not config.channels then + return false, 'Cloud thing_key / channels missing' + end + return true, nil end ------------------------------------------------------------------------------- @@ -123,153 +123,153 @@ end ---@param index number ---@return string? error local function validate_process_block(process_block, endpoint, index) - if type(process_block) ~= 'table' then - return string.format('Metric config [%s] process block %d is not a table', - tostring(endpoint), index) - end - if process_block.type == nil then - return string.format('Metric config [%s] process block %d has no type field', - tostring(endpoint), index) - end - if not VALID_PROCESS_TYPES[process_block.type] then - return string.format( - "Metric config [%s] process block %d has invalid type '%s' (valid: %s)", - tostring(endpoint), index, tostring(process_block.type), - table.concat({ 'DiffTrigger', 'TimeTrigger', 'DeltaValue' }, ', ')) - end - return nil + if type(process_block) ~= 'table' then + return string.format('Metric config [%s] process block %d is not a table', + tostring(endpoint), index) + end + if process_block.type == nil then + return string.format('Metric config [%s] process block %d has no type field', + tostring(endpoint), index) + end + if not VALID_PROCESS_TYPES[process_block.type] then + return string.format( + "Metric config [%s] process block %d has invalid type '%s' (valid: %s)", + tostring(endpoint), index, tostring(process_block.type), + table.concat({ 'DiffTrigger', 'TimeTrigger', 'DeltaValue' }, ', ')) + end + return nil end ---@param name any ---@param template_config any ---@return table warnings local function validate_template(name, template_config) - local warnings = {} - - if type(name) ~= 'string' then - table.insert(warnings, { - msg = 'Template name is not a string', - endpoint = name, - type = 'template', - }) - end - if type(template_config) ~= 'table' then - table.insert(warnings, { - msg = string.format('Template config [%s] is not a table', tostring(name)), - endpoint = name, - type = 'template', - }) - return warnings - end - - for field in pairs(template_config) do - if not VALID_TEMPLATE_FIELDS[field] then - table.insert(warnings, { - msg = string.format("Template config [%s] has invalid field '%s'", - tostring(name), tostring(field)), - endpoint = name, - type = 'template', - }) - end - end - - if template_config.protocol and not VALID_PROTOCOLS[template_config.protocol] then - table.insert(warnings, { - msg = string.format( - "Template config [%s] has invalid protocol '%s' (valid: http, log, bus)", - tostring(name), tostring(template_config.protocol)), - endpoint = name, - type = 'template', - }) - end - - if template_config.process ~= nil then - if not is_array(template_config.process) then - table.insert(warnings, { - msg = string.format('Template config [%s] process must be an array', tostring(name)), - endpoint = name, - type = 'template', - }) - else - for i, blk in ipairs(template_config.process) do - local err = validate_process_block(blk, name, i) - if err then - table.insert(warnings, { msg = err, endpoint = name, type = 'template' }) - end - end - end - end - - return warnings + local warnings = {} + + if type(name) ~= 'string' then + table.insert(warnings, { + msg = 'Template name is not a string', + endpoint = name, + type = 'template', + }) + end + if type(template_config) ~= 'table' then + table.insert(warnings, { + msg = string.format('Template config [%s] is not a table', tostring(name)), + endpoint = name, + type = 'template', + }) + return warnings + end + + for field in pairs(template_config) do + if not VALID_TEMPLATE_FIELDS[field] then + table.insert(warnings, { + msg = string.format("Template config [%s] has invalid field '%s'", + tostring(name), tostring(field)), + endpoint = name, + type = 'template', + }) + end + end + + if template_config.protocol and not VALID_PROTOCOLS[template_config.protocol] then + table.insert(warnings, { + msg = string.format( + "Template config [%s] has invalid protocol '%s' (valid: http, log, bus)", + tostring(name), tostring(template_config.protocol)), + endpoint = name, + type = 'template', + }) + end + + if template_config.process ~= nil then + if not is_array(template_config.process) then + table.insert(warnings, { + msg = string.format('Template config [%s] process must be an array', tostring(name)), + endpoint = name, + type = 'template', + }) + else + for i, blk in ipairs(template_config.process) do + local err = validate_process_block(blk, name, i) + if err then + table.insert(warnings, { msg = err, endpoint = name, type = 'template' }) + end + end + end + end + + return warnings end ---@param endpoint any ---@param metric_config any ---@return table warnings local function validate_metric(endpoint, metric_config) - local warnings = {} - - if type(endpoint) ~= 'string' then - table.insert(warnings, { - msg = 'Metric endpoint is not a string', - endpoint = endpoint, - type = 'metric', - }) - end - if type(metric_config) ~= 'table' then - table.insert(warnings, { - msg = string.format('Metric config [%s] is not a table', tostring(endpoint)), - endpoint = endpoint, - type = 'metric', - }) - return warnings - end - - for field in pairs(metric_config) do - if not VALID_METRIC_FIELDS[field] then - table.insert(warnings, { - msg = string.format("Metric config [%s] has invalid field '%s'", - tostring(endpoint), tostring(field)), - endpoint = endpoint, - type = 'metric', - }) - end - end - - if metric_config.protocol == nil then - table.insert(warnings, { - msg = string.format('Metric config [%s] has no defined protocol', tostring(endpoint)), - endpoint = endpoint, - type = 'metric', - }) - elseif not VALID_PROTOCOLS[metric_config.protocol] then - table.insert(warnings, { - msg = string.format( - "Metric config [%s] has invalid protocol '%s' (valid: http, log, bus)", - tostring(endpoint), tostring(metric_config.protocol)), - endpoint = endpoint, - type = 'metric', - }) - end - - if metric_config.process ~= nil then - if not is_array(metric_config.process) then - table.insert(warnings, { - msg = string.format('Metric config [%s] process must be an array', tostring(endpoint)), - endpoint = endpoint, - type = 'metric', - }) - else - for i, blk in ipairs(metric_config.process) do - local err = validate_process_block(blk, endpoint, i) - if err then - table.insert(warnings, { msg = err, endpoint = endpoint, type = 'metric' }) - end - end - end - end - - return warnings + local warnings = {} + + if type(endpoint) ~= 'string' then + table.insert(warnings, { + msg = 'Metric endpoint is not a string', + endpoint = endpoint, + type = 'metric', + }) + end + if type(metric_config) ~= 'table' then + table.insert(warnings, { + msg = string.format('Metric config [%s] is not a table', tostring(endpoint)), + endpoint = endpoint, + type = 'metric', + }) + return warnings + end + + for field in pairs(metric_config) do + if not VALID_METRIC_FIELDS[field] then + table.insert(warnings, { + msg = string.format("Metric config [%s] has invalid field '%s'", + tostring(endpoint), tostring(field)), + endpoint = endpoint, + type = 'metric', + }) + end + end + + if metric_config.protocol == nil then + table.insert(warnings, { + msg = string.format('Metric config [%s] has no defined protocol', tostring(endpoint)), + endpoint = endpoint, + type = 'metric', + }) + elseif not VALID_PROTOCOLS[metric_config.protocol] then + table.insert(warnings, { + msg = string.format( + "Metric config [%s] has invalid protocol '%s' (valid: http, log, bus)", + tostring(endpoint), tostring(metric_config.protocol)), + endpoint = endpoint, + type = 'metric', + }) + end + + if metric_config.process ~= nil then + if not is_array(metric_config.process) then + table.insert(warnings, { + msg = string.format('Metric config [%s] process must be an array', tostring(endpoint)), + endpoint = endpoint, + type = 'metric', + }) + else + for i, blk in ipairs(metric_config.process) do + local err = validate_process_block(blk, endpoint, i) + if err then + table.insert(warnings, { msg = err, endpoint = endpoint, type = 'metric' }) + end + end + end + end + + return warnings end ------------------------------------------------------------------------------- @@ -282,42 +282,42 @@ end ---@return ProcessPipeline? ---@return string? error local function build_metric_pipeline(endpoint, process_config) - local pipeline, pipeline_err = processing.new_process_pipeline() - if not pipeline then - return nil, string.format('Metric config [%s] failed to create pipeline: %s', endpoint, tostring(pipeline_err)) - end - - if process_config == nil then - -- An empty pipeline (pass-through) is valid. - return pipeline, nil - end - - for _, blk_cfg in ipairs(process_config) do - local ptype = blk_cfg.type - if ptype == nil then - return nil, string.format('Metric config [%s] has process block with no type', endpoint) - end - - local proc_class = processing[ptype] - if proc_class == nil then - return nil, string.format('Metric config [%s] has invalid process block type [%s]', - endpoint, tostring(ptype)) - end - - local proc, proc_err = proc_class.new(blk_cfg) - if not proc or proc_err then - return nil, string.format( - 'Metric config [%s] failed to create process block [%s]: %s', - endpoint, tostring(ptype), tostring(proc_err)) - end - - local add_err = pipeline:add(proc) - if add_err then - return nil, add_err - end - end - - return pipeline, nil + local pipeline, pipeline_err = processing.new_process_pipeline() + if not pipeline then + return nil, string.format('Metric config [%s] failed to create pipeline: %s', endpoint, tostring(pipeline_err)) + end + + if process_config == nil then + -- An empty pipeline (pass-through) is valid. + return pipeline, nil + end + + for _, blk_cfg in ipairs(process_config) do + local ptype = blk_cfg.type + if ptype == nil then + return nil, string.format('Metric config [%s] has process block with no type', endpoint) + end + + local proc_class = processing[ptype] + if proc_class == nil then + return nil, string.format('Metric config [%s] has invalid process block type [%s]', + endpoint, tostring(ptype)) + end + + local proc, proc_err = proc_class.new(blk_cfg) + if not proc or proc_err then + return nil, string.format( + 'Metric config [%s] failed to create process block [%s]: %s', + endpoint, tostring(ptype), tostring(proc_err)) + end + + local add_err = pipeline:add(proc) + if add_err then + return nil, add_err + end + end + + return pipeline, nil end ------------------------------------------------------------------------------- @@ -330,81 +330,81 @@ end ---@return table warnings ---@return string? error local function validate_config(config) - if type(config) ~= 'table' then - return false, {}, 'Config is not a table' - end - - local data = config.data or {} - local warnings = {} - - if type(data) ~= 'table' then - return false, warnings, 'Invalid configuration message' - end - - if data.schema ~= TARGET_SCHEMA then - return false, {}, string.format( - 'Unsupported config schema [%s], expected [%s]', tostring(data.schema), TARGET_SCHEMA) - end - - if type(data.publish_period) ~= 'number' then - return false, warnings, - 'Publish period must be of number type, found ' .. type(data.publish_period) - end - if data.publish_period <= 0 then - return false, warnings, 'Publish period must be greater than 0' - end - - if type(data.pipelines) ~= 'table' then - return false, warnings, 'No metric pipelines defined in config' - end - - local dropped_templates = {} - for name, tmpl in pairs(data.templates or {}) do - local tmpl_warns = validate_template(name, tmpl) - if #tmpl_warns > 0 then - for _, w in ipairs(tmpl_warns) do - table.insert(warnings, w) - end - dropped_templates[name] = true - end - end - - for endpoint, metric_config in pairs(data.pipelines) do - -- Check template existence - if metric_config.template then - if (not data.templates) or (not data.templates[metric_config.template]) then - table.insert(warnings, { - msg = string.format( - 'Metric config [%s] uses template [%s] that does not exist', - tostring(endpoint), tostring(metric_config.template)), - endpoint = endpoint, - type = 'metric', - }) - end - if dropped_templates[metric_config.template] then - table.insert(warnings, { - msg = string.format( - 'Metric config [%s] uses invalid template [%s]', - tostring(endpoint), tostring(metric_config.template)), - endpoint = endpoint, - type = 'metric', - }) - end - end - - -- Merge template then validate the resulting config - local full_cfg = merge_config( - (data.templates and metric_config.template - and data.templates[metric_config.template]) or {}, - metric_config - ) - local metric_warns = validate_metric(endpoint, full_cfg) - for _, w in ipairs(metric_warns) do - table.insert(warnings, w) - end - end - - return true, warnings, nil + if type(config) ~= 'table' then + return false, {}, 'Config is not a table' + end + + local data = config.data or {} + local warnings = {} + + if type(data) ~= 'table' then + return false, warnings, 'Invalid configuration message' + end + + if data.schema ~= TARGET_SCHEMA then + return false, {}, string.format( + 'Unsupported config schema [%s], expected [%s]', tostring(data.schema), TARGET_SCHEMA) + end + + if type(data.publish_period) ~= 'number' then + return false, warnings, + 'Publish period must be of number type, found ' .. type(data.publish_period) + end + if data.publish_period <= 0 then + return false, warnings, 'Publish period must be greater than 0' + end + + if type(data.pipelines) ~= 'table' then + return false, warnings, 'No metric pipelines defined in config' + end + + local dropped_templates = {} + for name, tmpl in pairs(data.templates or {}) do + local tmpl_warns = validate_template(name, tmpl) + if #tmpl_warns > 0 then + for _, w in ipairs(tmpl_warns) do + table.insert(warnings, w) + end + dropped_templates[name] = true + end + end + + for endpoint, metric_config in pairs(data.pipelines) do + -- Check template existence + if metric_config.template then + if (not data.templates) or (not data.templates[metric_config.template]) then + table.insert(warnings, { + msg = string.format( + 'Metric config [%s] uses template [%s] that does not exist', + tostring(endpoint), tostring(metric_config.template)), + endpoint = endpoint, + type = 'metric', + }) + end + if dropped_templates[metric_config.template] then + table.insert(warnings, { + msg = string.format( + 'Metric config [%s] uses invalid template [%s]', + tostring(endpoint), tostring(metric_config.template)), + endpoint = endpoint, + type = 'metric', + }) + end + end + + -- Merge template then validate the resulting config + local full_cfg = merge_config( + (data.templates and metric_config.template + and data.templates[metric_config.template]) or {}, + metric_config + ) + local metric_warns = validate_metric(endpoint, full_cfg) + for _, w in ipairs(metric_warns) do + table.insert(warnings, w) + end + end + + return true, warnings, nil end ------------------------------------------------------------------------------- @@ -419,51 +419,51 @@ end ---@return PipelineMap pipelines_map keyed by metric_name ---@return number publish_period local function apply_config(config, log_fn) - local data = config.data - log_fn = log_fn or function() end - - local publish_period = data.publish_period - local pipelines_map = {} - - for metric_name, metric_config in pairs(data.pipelines) do - local resolved = metric_config - if resolved.template and data.templates and data.templates[resolved.template] then - resolved = merge_config(data.templates[resolved.template], resolved) - end - - local protocol = resolved.protocol - if not protocol or not VALID_PROTOCOLS[protocol] then - log_fn('warn', { - what = 'pipeline_skipped', - pipeline = tostring(metric_name), - reason = 'invalid or missing protocol', - }) - else - local pipeline, pipeline_err = build_metric_pipeline( - metric_name, resolved.process or {}) - if pipeline_err then - log_fn('error', { - what = 'pipeline_skipped', - pipeline = tostring(metric_name), - err = pipeline_err, - }) - else - pipelines_map[metric_name] = { - pipeline = pipeline, - protocol = protocol, - } - end - end - end - - return pipelines_map, publish_period + local data = config.data + log_fn = log_fn or function() end + + local publish_period = data.publish_period + local pipelines_map = {} + + for metric_name, metric_config in pairs(data.pipelines) do + local resolved = metric_config + if resolved.template and data.templates and data.templates[resolved.template] then + resolved = merge_config(data.templates[resolved.template], resolved) + end + + local protocol = resolved.protocol + if not protocol or not VALID_PROTOCOLS[protocol] then + log_fn('warn', { + what = 'pipeline_skipped', + pipeline = tostring(metric_name), + reason = 'invalid or missing protocol', + }) + else + local pipeline, pipeline_err = build_metric_pipeline( + metric_name, resolved.process or {}) + if pipeline_err then + log_fn('error', { + what = 'pipeline_skipped', + pipeline = tostring(metric_name), + err = pipeline_err, + }) + else + pipelines_map[metric_name] = { + pipeline = pipeline, + protocol = protocol, + } + end + end + end + + return pipelines_map, publish_period end return { - merge_config = merge_config, - standardise_config = standardise_config, - validate_http_config = validate_http_config, - validate_config = validate_config, - apply_config = apply_config, - build_metric_pipeline = build_metric_pipeline, + merge_config = merge_config, + standardise_config = standardise_config, + validate_http_config = validate_http_config, + validate_config = validate_config, + apply_config = apply_config, + build_metric_pipeline = build_metric_pipeline, } diff --git a/src/services/metrics/processing.lua b/src/services/metrics/processing.lua index 27656f64..0bcf1bbc 100644 --- a/src/services/metrics/processing.lua +++ b/src/services/metrics/processing.lua @@ -48,51 +48,51 @@ local DiffTrigger = {} DiffTrigger.__index = DiffTrigger local function check_diff_args_valid(config) - if config.initial_val ~= nil and type(config.initial_val) ~= 'number' then - return 'Initial value must be a number' - end - if config.diff_method ~= 'any-change' and type(config.threshold) ~= 'number' then - return 'Threshold must be a number' - end + if config.initial_val ~= nil and type(config.initial_val) ~= 'number' then + return 'Initial value must be a number' + end + if config.diff_method ~= 'any-change' and type(config.threshold) ~= 'number' then + return 'Threshold must be a number' + end end ---@param config table ---@return DiffTrigger? ---@return string? error function DiffTrigger.new(config) - local valid_err = check_diff_args_valid(config) - if valid_err then return nil, valid_err end - - local self = setmetatable({}, DiffTrigger) - self.config = config - self.threshold = config.threshold - - local dm = config.diff_method - if dm == 'absolute' then - self.diff_fn = function(curr, last, threshold) - return math.abs(curr - last) >= threshold - end - elseif dm == 'percent' then - self.diff_fn = function(curr, last, threshold) - return (math.abs((curr - last) / last) * 100) >= threshold - end - elseif dm == 'any-change' then - self.diff_fn = function(curr, last) - return curr ~= last - end - else - return nil, "Diff method must be 'absolute', 'percent' or 'any-change'" - end - return self, nil + local valid_err = check_diff_args_valid(config) + if valid_err then return nil, valid_err end + + local self = setmetatable({}, DiffTrigger) + self.config = config + self.threshold = config.threshold + + local dm = config.diff_method + if dm == 'absolute' then + self.diff_fn = function(curr, last, threshold) + return math.abs(curr - last) >= threshold + end + elseif dm == 'percent' then + self.diff_fn = function(curr, last, threshold) + return (math.abs((curr - last) / last) * 100) >= threshold + end + elseif dm == 'any-change' then + self.diff_fn = function(curr, last) + return curr ~= last + end + else + return nil, "Diff method must be 'absolute', 'percent' or 'any-change'" + end + return self, nil end ---@return table function DiffTrigger:new_state() - return { - empty = (self.config.initial_val == nil), - last_val = self.config.initial_val or 0, - curr_val = nil, - } + return { + empty = (self.config.initial_val == nil), + last_val = self.config.initial_val or 0, + curr_val = nil, + } end ---@param value any @@ -101,13 +101,13 @@ end ---@return boolean short_circuit ---@return string? error function DiffTrigger:run(value, state) - state.curr_val = value - if state.empty or self.diff_fn(state.curr_val, state.last_val, self.threshold) then - state.last_val = value - state.empty = false - return value, false, nil - end - return nil, true, nil + state.curr_val = value + if state.empty or self.diff_fn(state.curr_val, state.last_val, self.threshold) then + state.last_val = value + state.empty = false + return value, false, nil + end + return nil, true, nil end --- No-op: DiffTrigger does not reset last_val on publish. @@ -131,18 +131,18 @@ TimeTrigger.__index = TimeTrigger ---@return TimeTrigger? ---@return string? error function TimeTrigger.new(config) - if type(config.duration) ~= 'number' then - return nil, 'Duration must be a number' - end - local self = setmetatable({}, TimeTrigger) - self.duration = config.duration - self.config = config - return self, nil + if type(config.duration) ~= 'number' then + return nil, 'Duration must be a number' + end + local self = setmetatable({}, TimeTrigger) + self.duration = config.duration + self.config = config + return self, nil end ---@return table function TimeTrigger:new_state() - return { timeout = runtime.now() + self.duration } + return { timeout = runtime.now() + self.duration } end ---@param value any @@ -151,11 +151,11 @@ end ---@return boolean short_circuit ---@return string? error function TimeTrigger:run(value, state) - if runtime.now() >= state.timeout then - state.timeout = runtime.now() + self.duration - return value, false, nil - end - return nil, true, nil + if runtime.now() >= state.timeout then + state.timeout = runtime.now() + self.duration + return value, false, nil + end + return nil, true, nil end ---@param state table @@ -177,20 +177,20 @@ DeltaValue.__index = DeltaValue ---@return DeltaValue? ---@return string? error function DeltaValue.new(config) - if config.initial_val ~= nil and type(config.initial_val) ~= 'number' then - return nil, 'Initial value must be a number' - end - local self = setmetatable({}, DeltaValue) - self.config = config - return self, nil + if config.initial_val ~= nil and type(config.initial_val) ~= 'number' then + return nil, 'Initial value must be a number' + end + local self = setmetatable({}, DeltaValue) + self.config = config + return self, nil end ---@return table function DeltaValue:new_state() - return { - last_val = self.config.initial_val or 0, - curr_val = nil, - } + return { + last_val = self.config.initial_val or 0, + curr_val = nil, + } end ---@param value any @@ -199,19 +199,19 @@ end ---@return boolean short_circuit ---@return string? error function DeltaValue:run(value, state) - if type(value) ~= 'number' then - return nil, false, 'Value must be a number' - end - local difference = value - state.last_val - state.curr_val = value - return difference, false, nil + if type(value) ~= 'number' then + return nil, false, 'Value must be a number' + end + local difference = value - state.last_val + state.curr_val = value + return difference, false, nil end --- On reset, advance last_val to curr_val so the next delta is computed from --- the most-recently-published sample. ---@param state table function DeltaValue:reset(state) - state.last_val = state.curr_val or 0 + state.last_val = state.curr_val or 0 end ------------------------------------------------------------------------------- @@ -225,25 +225,25 @@ ProcessPipeline.__index = ProcessPipeline ---@return ProcessPipeline local function new_process_pipeline() - return setmetatable({ process_blocks = {} }, ProcessPipeline) + return setmetatable({ process_blocks = {} }, ProcessPipeline) end --- Append a processing block to the pipeline. ---@param block any ---@return string? error function ProcessPipeline:add(block) - if block == nil then return 'processing block cannot be nil' end - table.insert(self.process_blocks, block) + if block == nil then return 'processing block cannot be nil' end + table.insert(self.process_blocks, block) end --- Create a fresh state table for this pipeline (and all its blocks). ---@return table function ProcessPipeline:new_state() - local state = { full_run = false, blocks = {} } - for i, block in ipairs(self.process_blocks) do - state.blocks[i] = block:new_state() - end - return state + local state = { full_run = false, blocks = {} } + for i, block in ipairs(self.process_blocks) do + state.blocks[i] = block:new_state() + end + return state end --- Run the pipeline, passing value through each block sequentially. @@ -254,44 +254,44 @@ end ---@return boolean short_circuit ---@return string? error function ProcessPipeline:run(value, state) - local val = value - local short = false - local err = nil + local val = value + local short = false + local err = nil - for i, block in ipairs(self.process_blocks) do - val, short, err = block:run(val, state.blocks[i]) - if err or short then break end - end + for i, block in ipairs(self.process_blocks) do + val, short, err = block:run(val, state.blocks[i]) + if err or short then break end + end - if not short and not err then - state.full_run = true - end + if not short and not err then + state.full_run = true + end - return val, short, err + return val, short, err end --- Reset block states, but only when the pipeline produced a published value --- (i.e. ran to completion without short-circuiting). ---@param state table function ProcessPipeline:reset(state) - if state.full_run then - for i, block in ipairs(self.process_blocks) do - block:reset(state.blocks[i]) - end - state.full_run = false - end + if state.full_run then + for i, block in ipairs(self.process_blocks) do + block:reset(state.blocks[i]) + end + state.full_run = false + end end --- Reset regardless of whether the pipeline produced a published value. ---@param state table function ProcessPipeline:force_reset(state) - state.full_run = true - self:reset(state) + state.full_run = true + self:reset(state) end return { - DiffTrigger = DiffTrigger, - TimeTrigger = TimeTrigger, - DeltaValue = DeltaValue, - new_process_pipeline = new_process_pipeline, + DiffTrigger = DiffTrigger, + TimeTrigger = TimeTrigger, + DeltaValue = DeltaValue, + new_process_pipeline = new_process_pipeline, } diff --git a/src/services/metrics/senml.lua b/src/services/metrics/senml.lua index f493c86c..0586bcf6 100644 --- a/src/services/metrics/senml.lua +++ b/src/services/metrics/senml.lua @@ -11,29 +11,29 @@ ---@return SenMLRecord? senml_obj ---@return string? error local function encode(topic, value, time) - if type(topic) ~= 'string' or topic == '' then - return nil, 'topic must be a non-empty string' - end - local vtype = type(value) - if vtype ~= 'number' and vtype ~= 'string' and vtype ~= 'boolean' then - return nil, 'value must be number, string or boolean, found ' .. vtype - end + if type(topic) ~= 'string' or topic == '' then + return nil, 'topic must be a non-empty string' + end + local vtype = type(value) + if vtype ~= 'number' and vtype ~= 'string' and vtype ~= 'boolean' then + return nil, 'value must be number, string or boolean, found ' .. vtype + end - local obj = { n = topic } + local obj = { n = topic } - if vtype == 'number' then - obj.v = value - elseif vtype == 'string' then - obj.vs = value - elseif vtype == 'boolean' then - obj.vb = value - end + if vtype == 'number' then + obj.v = value + elseif vtype == 'string' then + obj.vs = value + elseif vtype == 'boolean' then + obj.vb = value + end - if time and type(time) == 'number' then - obj.t = time - end + if time and type(time) == 'number' then + obj.t = time + end - return obj, nil + return obj, nil end --- Recursively encode a nested values table into a flat SenML array. @@ -52,33 +52,33 @@ end ---@return table? output ---@return string? error local function encode_r(base_topic, values, output) - for k, v in pairs(values) do - local topic = base_topic - if k ~= '__value' then - if base_topic == '' then - topic = k - else - topic = topic .. '.' .. k - end - end + for k, v in pairs(values) do + local topic = base_topic + if k ~= '__value' then + if base_topic == '' then + topic = k + else + topic = topic .. '.' .. k + end + end - if type(v) == 'table' and (v.value == nil or v.time == nil) then - local _, err = encode_r(topic, v, output) - if err then return nil, err end - else - local clean_metric = v - if type(v) ~= 'table' then - clean_metric = { value = v } - end - local obj, err = encode(topic, clean_metric.value, clean_metric.time) - if err then return nil, err end - table.insert(output, obj) - end - end - return output, nil + if type(v) == 'table' and (v.value == nil or v.time == nil) then + local _, err = encode_r(topic, v, output) + if err then return nil, err end + else + local clean_metric = v + if type(v) ~= 'table' then + clean_metric = { value = v } + end + local obj, err = encode(topic, clean_metric.value, clean_metric.time) + if err then return nil, err end + table.insert(output, obj) + end + end + return output, nil end return { - encode = encode, - encode_r = function(base_topic, values) return encode_r(base_topic, values, {}) end, + encode = encode, + encode_r = function(base_topic, values) return encode_r(base_topic, values, {}) end, } diff --git a/src/services/metrics/state_projection.lua b/src/services/metrics/state_projection.lua index 273ad4ce..f94f33f4 100644 --- a/src/services/metrics/state_projection.lua +++ b/src/services/metrics/state_projection.lua @@ -7,24 +7,24 @@ -- service to actively publish UI-critical operational facts as metrics. return { - { - topic = { 'state', 'system', 'stats' }, - metrics = { - { path = { 'cpu', 'utilisation' }, name = 'system.cpu_util' }, - { path = { 'memory', 'utilisation' }, name = 'system.mem_util' }, - { path = { 'thermal', 'zone0', 'temp_c' }, name = 'system.temp' }, - }, - }, - { - topic = { 'state', 'net', 'backhaul' }, - metrics = { - -- Future projection: per-uplink availability, usable state and uptime. - }, - }, - { - topic = { 'state', 'gsm', 'uplink', '+' }, - metrics = { - -- Future projection: cellular connectivity and SIM state. - }, - }, + { + topic = { 'state', 'system', 'stats' }, + metrics = { + { path = { 'cpu', 'utilisation' }, name = 'system.cpu_util' }, + { path = { 'memory', 'utilisation' }, name = 'system.mem_util' }, + { path = { 'thermal', 'zone0', 'temp_c' }, name = 'system.temp' }, + }, + }, + { + topic = { 'state', 'net', 'backhaul' }, + metrics = { + -- Future projection: per-uplink availability, usable state and uptime. + }, + }, + { + topic = { 'state', 'gsm', 'uplink', '+' }, + metrics = { + -- Future projection: cellular connectivity and SIM state. + }, + }, } diff --git a/src/services/metrics/types.lua b/src/services/metrics/types.lua index 372d8a95..e06bebcf 100644 --- a/src/services/metrics/types.lua +++ b/src/services/metrics/types.lua @@ -23,17 +23,17 @@ BaseTime.__index = BaseTime ---@return BaseTime? ---@return string error function new.BaseTime(real, mono) - if type(real) ~= 'number' then - return nil, "real must be a number" - end - if type(mono) ~= 'number' then - return nil, "mono must be a number" - end - return setmetatable({ - synced = false, - real = real, - mono = mono, - }, BaseTime), "" + if type(real) ~= 'number' then + return nil, "real must be a number" + end + if type(mono) ~= 'number' then + return nil, "mono must be a number" + end + return setmetatable({ + synced = false, + real = real, + mono = mono, + }, BaseTime), "" end ------------------------------------------------------------------------------- @@ -59,20 +59,20 @@ CloudConfig.__index = CloudConfig ---@return CloudConfig? ---@return string error function new.CloudConfig(url, thing_key, channels) - if type(url) ~= 'string' or url == '' then - return nil, "url must be a non-empty string" - end - if type(thing_key) ~= 'string' or thing_key == '' then - return nil, "thing_key must be a non-empty string" - end - if type(channels) ~= 'table' then - return nil, "channels must be a table" - end - return setmetatable({ - url = url, - thing_key = thing_key, - channels = channels, - }, CloudConfig), "" + if type(url) ~= 'string' or url == '' then + return nil, "url must be a non-empty string" + end + if type(thing_key) ~= 'string' or thing_key == '' then + return nil, "thing_key must be a non-empty string" + end + if type(channels) ~= 'table' then + return nil, "channels must be a table" + end + return setmetatable({ + url = url, + thing_key = thing_key, + channels = channels, + }, CloudConfig), "" end ------------------------------------------------------------------------------- @@ -91,17 +91,17 @@ MetricSample.__index = MetricSample ---@return MetricSample? ---@return string error function new.MetricSample(value, time) - local vt = type(value) - if vt ~= 'number' and vt ~= 'string' and vt ~= 'boolean' then - return nil, "value must be number, string or boolean" - end - if type(time) ~= 'number' then - return nil, "time must be a number" - end - return setmetatable({ - value = value, - time = time, - }, MetricSample), "" + local vt = type(value) + if vt ~= 'number' and vt ~= 'string' and vt ~= 'boolean' then + return nil, "value must be number, string or boolean" + end + if type(time) ~= 'number' then + return nil, "time must be a number" + end + return setmetatable({ + value = value, + time = time, + }, MetricSample), "" end ------------------------------------------------------------------------------- @@ -124,23 +124,23 @@ SenMLRecord.__index = SenMLRecord ---@return SenMLRecord? ---@return string? error function new.SenMLRecord(name, value, time) - if type(name) ~= 'string' or name == '' then - return nil, "name must be a non-empty string" - end - local vt = type(value) - if vt ~= 'number' and vt ~= 'string' and vt ~= 'boolean' then - return nil, "value must be number, string or boolean, found " .. vt - end - if time ~= nil and type(time) ~= 'number' then - return nil, "time must be a number" - end - - local obj = setmetatable({ n = name }, SenMLRecord) - if vt == 'number' then obj.v = value end - if vt == 'string' then obj.vs = value end - if vt == 'boolean' then obj.vb = value end - if time then obj.t = time end - return obj, nil + if type(name) ~= 'string' or name == '' then + return nil, "name must be a non-empty string" + end + local vt = type(value) + if vt ~= 'number' and vt ~= 'string' and vt ~= 'boolean' then + return nil, "value must be number, string or boolean, found " .. vt + end + if time ~= nil and type(time) ~= 'number' then + return nil, "time must be a number" + end + + local obj = setmetatable({ n = name }, SenMLRecord) + if vt == 'number' then obj.v = value end + if vt == 'string' then obj.vs = value end + if vt == 'boolean' then obj.vb = value end + if time then obj.t = time end + return obj, nil end ------------------------------------------------------------------------------- @@ -170,9 +170,9 @@ end ---@field fs_cap CapabilityReference? nil before filesystem cap is resolved return { - new = new, - BaseTime = BaseTime, - CloudConfig = CloudConfig, - MetricSample = MetricSample, - SenMLRecord = SenMLRecord, + new = new, + BaseTime = BaseTime, + CloudConfig = CloudConfig, + MetricSample = MetricSample, + SenMLRecord = SenMLRecord, } diff --git a/src/services/system.lua b/src/services/system.lua index 5beaecf4..b66d0ac8 100644 --- a/src/services/system.lua +++ b/src/services/system.lua @@ -33,22 +33,22 @@ local function t_cfg(name) return { 'cfg', name } end ---@return Topic local function t_state_time_synced() - return { 'state', 'time', 'synced' } + return { 'state', 'time', 'synced' } end ---@return Topic local function t_state_system_shutdown() - return { 'state', 'system', 'shutdown' } + return { 'state', 'system', 'shutdown' } end ---@return Topic local function t_state_system_identity() - return { 'state', 'system', 'identity' } + return { 'state', 'system', 'identity' } end ---@return Topic local function t_state_system_stats() - return { 'state', 'system', 'stats' } + return { 'state', 'system', 'stats' } end -- ── config validation ────────────────────────────── @@ -57,23 +57,23 @@ end ---@return { report_period: number, usb3_enabled: boolean, alarms?: table }? ---@return string error local function validate_config(cfg) - if type(cfg) ~= 'table' then - return nil, "config must be a table" - end - if cfg.schema ~= SCHEMA_TARGET then - return nil, "config.schema is not currently supported" - end - if type(cfg.report_period) ~= 'number' or cfg.report_period <= 0 then - return nil, "config.report_period must be a positive number" - end - if type(cfg.usb3_enabled) ~= 'boolean' then - return nil, "config.usb3_enabled must be a boolean" - end - -- alarms is optional - if cfg.alarms ~= nil and type(cfg.alarms) ~= 'table' then - return nil, "config.alarms must be nil or a table" - end - return cfg, "" + if type(cfg) ~= 'table' then + return nil, "config must be a table" + end + if cfg.schema ~= SCHEMA_TARGET then + return nil, "config.schema is not currently supported" + end + if type(cfg.report_period) ~= 'number' or cfg.report_period <= 0 then + return nil, "config.report_period must be a positive number" + end + if type(cfg.usb3_enabled) ~= 'boolean' then + return nil, "config.usb3_enabled must be a boolean" + end + -- alarms is optional + if cfg.alarms ~= nil and type(cfg.alarms) ~= 'table' then + return nil, "config.alarms must be nil or a table" + end + return cfg, "" end -- ── RPC helper ───────────────────────────────── @@ -88,18 +88,18 @@ local CAP_RETRY_TIMEOUT = 0.1 ---@return any value ---@return string error local function cap_rpc(cap_ref, method, opts, timeout) - timeout = timeout or REQUEST_TIMEOUT - local reply, err = perform(cap_ref:call_control_op(method, opts, { timeout = timeout })) - if not reply then return nil, err or "rpc failed" end - if reply.ok ~= true then return nil, reply.reason or "rpc returned not ok" end - return reply.reason, "" + timeout = timeout or REQUEST_TIMEOUT + local reply, err = perform(cap_ref:call_control_op(method, opts, { timeout = timeout })) + if not reply then return nil, err or "rpc failed" end + if reply.ok ~= true then return nil, reply.reason or "rpc returned not ok" end + return reply.reason, "" end ---@param class CapabilityClass ---@param id CapabilityId ---@return string local function cap_ref_key(class, id) - return tostring(class) .. ':' .. tostring(id) + return tostring(class) .. ':' .. tostring(id) end ---@param conn Connection @@ -109,28 +109,28 @@ end ---@return CapabilityReference? ---@return string error local function wait_for_cap(conn, svc, class, id, timeout) - local cap_ref, cap_err = cap_sdk.new_cap_listener(conn, class, id):wait_for_cap({ - timeout = timeout or REQUEST_TIMEOUT, - }) - if not cap_ref then - local key = cap_ref_key(class, id) - local first = not cap_unavailable_logged[key] - cap_unavailable_logged[key] = true - svc:obs_log(first and 'warn' or 'debug', { - what = tostring(class) .. '_unavailable', - summary = string.format( - '%s capability %s unavailable: %s', - tostring(class), - tostring(id), - tostring(cap_err or 'unknown') - ), - class = class, - id = id, - err = cap_err, - }) - return nil, cap_err - end - return cap_ref, "" + local cap_ref, cap_err = cap_sdk.new_cap_listener(conn, class, id):wait_for_cap({ + timeout = timeout or REQUEST_TIMEOUT, + }) + if not cap_ref then + local key = cap_ref_key(class, id) + local first = not cap_unavailable_logged[key] + cap_unavailable_logged[key] = true + svc:obs_log(first and 'warn' or 'debug', { + what = tostring(class) .. '_unavailable', + summary = string.format( + '%s capability %s unavailable: %s', + tostring(class), + tostring(id), + tostring(cap_err or 'unknown') + ), + class = class, + id = id, + err = cap_err, + }) + return nil, cap_err + end + return cap_ref, "" end ---@param conn Connection @@ -138,33 +138,33 @@ end ---@param specs { class: CapabilityClass, id: CapabilityId }[] ---@return table local function discover_caps(conn, svc, specs) - local refs = {} - local seen = {} - - for _, spec in ipairs(specs) do - local key = cap_ref_key(spec.class, spec.id) - if not seen[key] then - local cap_ref = wait_for_cap(conn, svc, spec.class, spec.id) - if cap_ref then - refs[key] = cap_ref - end - seen[key] = true - end - end - - return refs + local refs = {} + local seen = {} + + for _, spec in ipairs(specs) do + local key = cap_ref_key(spec.class, spec.id) + if not seen[key] then + local cap_ref = wait_for_cap(conn, svc, spec.class, spec.id) + if cap_ref then + refs[key] = cap_ref + end + seen[key] = true + end + end + + return refs end local function discover_missing_caps(conn, svc, refs, specs, timeout) - refs = refs or {} - for _, spec in ipairs(specs or {}) do - local key = cap_ref_key(spec.class, spec.id) - if refs[key] == nil then - local cap_ref = wait_for_cap(conn, svc, spec.class, spec.id, timeout or CAP_RETRY_TIMEOUT) - if cap_ref then refs[key] = cap_ref end - end - end - return refs + refs = refs or {} + for _, spec in ipairs(specs or {}) do + local key = cap_ref_key(spec.class, spec.id) + if refs[key] == nil then + local cap_ref = wait_for_cap(conn, svc, spec.class, spec.id, timeout or CAP_RETRY_TIMEOUT) + if cap_ref then refs[key] = cap_ref end + end + end + return refs end ---@param refs table @@ -172,7 +172,7 @@ end ---@param id CapabilityId ---@return CapabilityReference? local function get_cap_ref(refs, class, id) - return refs[cap_ref_key(class, id)] + return refs[cap_ref_key(class, id)] end -- ── sysinfo fiber ──────────────────────────────── @@ -181,332 +181,332 @@ end local THERMAL_ZONE0_ID = 'zone0' local PLATFORM_IDENTITY_METRICS = { - { field = 'hw_revision', metric_key = 'hw_id' }, - { field = 'fw_version', metric_key = 'fw_id' }, - { field = 'serial', metric_key = 'serial' }, - { field = 'board_revision', metric_key = 'board_revision' }, + { field = 'hw_revision', metric_key = 'hw_id' }, + { field = 'fw_version', metric_key = 'fw_id' }, + { field = 'serial', metric_key = 'serial' }, + { field = 'board_revision', metric_key = 'board_revision' }, } local SYSINFO_METRICS = { - { - class = 'cpu', - id = '1', - method = 'get', - field = 'utilisation', - metric_key = 'cpu_util', - mk_opts = cap_sdk.args.new.CpuGetOpts - }, - { - class = 'memory', - id = '1', - method = 'get', - field = 'util', - metric_key = 'mem_util', - mk_opts = cap_sdk.args.new.MemoryGetOpts - }, - { - class = 'thermal', - id = THERMAL_ZONE0_ID, - method = 'get', - field = '', - metric_key = 'temp', - mk_opts = function(_, max_age) return cap_sdk.args.new.ThermalGetOpts(max_age) end - } + { + class = 'cpu', + id = '1', + method = 'get', + field = 'utilisation', + metric_key = 'cpu_util', + mk_opts = cap_sdk.args.new.CpuGetOpts + }, + { + class = 'memory', + id = '1', + method = 'get', + field = 'util', + metric_key = 'mem_util', + mk_opts = cap_sdk.args.new.MemoryGetOpts + }, + { + class = 'thermal', + id = THERMAL_ZONE0_ID, + method = 'get', + field = '', + metric_key = 'temp', + mk_opts = function(_, max_age) return cap_sdk.args.new.ThermalGetOpts(max_age) end + } } local function publish_system_metric(svc, metric_key, value) - if value == nil then return false end - svc:obs_metric(metric_key, { - namespace = { 'system', metric_key }, - value = value - }) - return true + if value == nil then return false end + svc:obs_metric(metric_key, { + namespace = { 'system', metric_key }, + value = value + }) + return true end local function publish_platform_identity_state(svc, identity) - if type(identity) ~= 'table' then return false end - svc:_retain(t_state_system_identity(), { - schema = 'devicecode.system.identity/1', - state = 'ok', - observed_at = fibers.now(), - at = svc:wall(), - hw_revision = identity.hw_revision, - fw_version = identity.fw_version, - serial = identity.serial, - board_revision = identity.board_revision, - }) - return true + if type(identity) ~= 'table' then return false end + svc:_retain(t_state_system_identity(), { + schema = 'devicecode.system.identity/1', + state = 'ok', + observed_at = fibers.now(), + at = svc:wall(), + hw_revision = identity.hw_revision, + fw_version = identity.fw_version, + serial = identity.serial, + board_revision = identity.board_revision, + }) + return true end local function collect_sysinfo_state(svc, cap_refs, max_age) - local out = { - schema = 'devicecode.system.stats/1', - state = 'ok', - observed_at = fibers.now(), - at = svc:wall(), - missing = {}, - } - - local function missing(key, err) - out.missing[#out.missing + 1] = { key = key, err = tostring(err or 'unavailable') } - out.state = 'partial' - end - - for _, m in ipairs(SYSINFO_METRICS) do - local cap_ref = get_cap_ref(cap_refs, m.class, m.id) - if not cap_ref then - missing(m.class .. ':' .. m.id, 'capability_unavailable') - else - local opts, opts_err = m.mk_opts(m.field, max_age) - if opts_err ~= "" then - missing(m.class .. ':' .. m.id, opts_err) - else - local value, err = cap_rpc(cap_ref, m.method, opts) - if err ~= "" then - missing(m.class .. ':' .. m.id, err) - elseif m.metric_key == 'cpu_util' then - out.cpu = { utilisation = tonumber(value) or value } - elseif m.metric_key == 'mem_util' then - out.memory = { utilisation = tonumber(value) or value } - elseif m.metric_key == 'temp' then - out.thermal = out.thermal or {} - out.thermal[THERMAL_ZONE0_ID] = { temp_c = tonumber(value) or value } - end - end - end - end - - if #out.missing == 0 then out.missing = nil end - if out.cpu == nil and out.memory == nil and out.thermal == nil then out.state = 'unavailable' end - return out + local out = { + schema = 'devicecode.system.stats/1', + state = 'ok', + observed_at = fibers.now(), + at = svc:wall(), + missing = {}, + } + + local function missing(key, err) + out.missing[#out.missing + 1] = { key = key, err = tostring(err or 'unavailable') } + out.state = 'partial' + end + + for _, m in ipairs(SYSINFO_METRICS) do + local cap_ref = get_cap_ref(cap_refs, m.class, m.id) + if not cap_ref then + missing(m.class .. ':' .. m.id, 'capability_unavailable') + else + local opts, opts_err = m.mk_opts(m.field, max_age) + if opts_err ~= "" then + missing(m.class .. ':' .. m.id, opts_err) + else + local value, err = cap_rpc(cap_ref, m.method, opts) + if err ~= "" then + missing(m.class .. ':' .. m.id, err) + elseif m.metric_key == 'cpu_util' then + out.cpu = { utilisation = tonumber(value) or value } + elseif m.metric_key == 'mem_util' then + out.memory = { utilisation = tonumber(value) or value } + elseif m.metric_key == 'temp' then + out.thermal = out.thermal or {} + out.thermal[THERMAL_ZONE0_ID] = { temp_c = tonumber(value) or value } + end + end + end + end + + if #out.missing == 0 then out.missing = nil end + if out.cpu == nil and out.memory == nil and out.thermal == nil then out.state = 'unavailable' end + return out end local function publish_sysinfo_state(svc, cap_refs, stats_period) - local state = collect_sysinfo_state(svc, cap_refs, stats_period) - svc:_retain(t_state_system_stats(), state) - return state + local state = collect_sysinfo_state(svc, cap_refs, stats_period) + svc:_retain(t_state_system_stats(), state) + return state end local function unsubscribe(sub) - if sub and type(sub.unsubscribe) == 'function' then sub:unsubscribe() end + if sub and type(sub.unsubscribe) == 'function' then sub:unsubscribe() end end local function read_platform_identity(platform_cap, timeout) - if not platform_cap then return nil, 'platform_unavailable' end - timeout = timeout or REQUEST_TIMEOUT - - local identity_opts, identity_opts_err = cap_sdk.args.new.PlatformGetOpts('identity', 0) - if identity_opts then - local identity, err = cap_rpc(platform_cap, 'get', identity_opts, timeout) - if err == "" and type(identity) == 'table' then - return identity, "" - end - elseif identity_opts_err ~= "" then - return nil, identity_opts_err - end - - local identity_sub = platform_cap:get_state_sub('identity', { - queue_len = 1, - full = 'drop_oldest', - }) - local identity_msg = perform(op.choice( - identity_sub:recv_op(), - sleep.sleep_op(timeout) - )) - unsubscribe(identity_sub) - if identity_msg and type(identity_msg.payload) == 'table' then - return identity_msg.payload, "" - end - - local out = {} - local got = false - local last_err = nil - for _, metric in ipairs(PLATFORM_IDENTITY_METRICS) do - local opts, opts_err = cap_sdk.args.new.PlatformGetOpts(metric.field, 0) - if opts then - local value, err = cap_rpc(platform_cap, 'get', opts, timeout) - if err == "" then - out[metric.field] = value - got = true - else - last_err = err - end - else - last_err = opts_err - end - end - if got then return out, "" end - return nil, last_err or 'platform_identity_unavailable' + if not platform_cap then return nil, 'platform_unavailable' end + timeout = timeout or REQUEST_TIMEOUT + + local identity_opts, identity_opts_err = cap_sdk.args.new.PlatformGetOpts('identity', 0) + if identity_opts then + local identity, err = cap_rpc(platform_cap, 'get', identity_opts, timeout) + if err == "" and type(identity) == 'table' then + return identity, "" + end + elseif identity_opts_err ~= "" then + return nil, identity_opts_err + end + + local identity_sub = platform_cap:get_state_sub('identity', { + queue_len = 1, + full = 'drop_oldest', + }) + local identity_msg = perform(op.choice( + identity_sub:recv_op(), + sleep.sleep_op(timeout) + )) + unsubscribe(identity_sub) + if identity_msg and type(identity_msg.payload) == 'table' then + return identity_msg.payload, "" + end + + local out = {} + local got = false + local last_err = nil + for _, metric in ipairs(PLATFORM_IDENTITY_METRICS) do + local opts, opts_err = cap_sdk.args.new.PlatformGetOpts(metric.field, 0) + if opts then + local value, err = cap_rpc(platform_cap, 'get', opts, timeout) + if err == "" then + out[metric.field] = value + got = true + else + last_err = err + end + else + last_err = opts_err + end + end + if got then return out, "" end + return nil, last_err or 'platform_identity_unavailable' end local function publish_platform_identity_metrics(svc, platform_cap, timeout) - local identity, err = read_platform_identity(platform_cap, timeout) - if type(identity) ~= 'table' then - svc:obs_log('warn', { what = 'platform_identity_unavailable', err = tostring(err or 'unknown') }) - return false - end - - publish_platform_identity_state(svc, identity) - - local published = false - for _, metric in ipairs(PLATFORM_IDENTITY_METRICS) do - if identity[metric.field] ~= nil then - published = publish_system_metric(svc, metric.metric_key, identity[metric.field]) or published - end - end - - if published then - svc:obs_log('debug', 'sysinfo: published platform identity metrics') - else - svc:obs_log('warn', 'sysinfo: platform identity had no publishable fields') - end - return published + local identity, err = read_platform_identity(platform_cap, timeout) + if type(identity) ~= 'table' then + svc:obs_log('warn', { what = 'platform_identity_unavailable', err = tostring(err or 'unknown') }) + return false + end + + publish_platform_identity_state(svc, identity) + + local published = false + for _, metric in ipairs(PLATFORM_IDENTITY_METRICS) do + if identity[metric.field] ~= nil then + published = publish_system_metric(svc, metric.metric_key, identity[metric.field]) or published + end + end + + if published then + svc:obs_log('debug', 'sysinfo: published platform identity metrics') + else + svc:obs_log('warn', 'sysinfo: platform identity had no publishable fields') + end + return published end local function publish_sysinfo_metrics(svc, cap_refs, report_period) - for _, m in ipairs(SYSINFO_METRICS) do - local cap_ref = get_cap_ref(cap_refs, m.class, m.id) - if cap_ref then - local opts, opts_err = m.mk_opts(m.field, report_period) - if opts_err ~= "" then - svc:obs_log('warn', { - what = 'metric_opts_invalid', - class = m.class, - id = m.id, - field = m.field, - err = opts_err - }) - else - local value, err = cap_rpc(cap_ref, m.method, opts) - if err ~= "" then - svc:obs_log('warn', { - what = 'metric_get_failed', - class = m.class, - id = m.id, - field = m.field, - err = err - }) - else - publish_system_metric(svc, m.metric_key, value) - end - end - end - end + for _, m in ipairs(SYSINFO_METRICS) do + local cap_ref = get_cap_ref(cap_refs, m.class, m.id) + if cap_ref then + local opts, opts_err = m.mk_opts(m.field, report_period) + if opts_err ~= "" then + svc:obs_log('warn', { + what = 'metric_opts_invalid', + class = m.class, + id = m.id, + field = m.field, + err = opts_err + }) + else + local value, err = cap_rpc(cap_ref, m.method, opts) + if err ~= "" then + svc:obs_log('warn', { + what = 'metric_get_failed', + class = m.class, + id = m.id, + field = m.field, + err = err + }) + else + publish_system_metric(svc, m.metric_key, value) + end + end + end + end end local function publish_boot_time_metric(svc, platform_cap, report_period) - if not platform_cap then return end - local uptime_opts, uptime_opts_err = cap_sdk.args.new.PlatformGetOpts('uptime', report_period) - if uptime_opts_err ~= "" then - svc:obs_log('warn', { what = 'uptime_opts_invalid', err = uptime_opts_err }) - else - local uptime, uptime_err = cap_rpc(platform_cap, 'get', uptime_opts) - if uptime == nil or uptime_err ~= "" then - svc:obs_log('warn', { what = 'uptime_get_failed', err = uptime_err }) - else - publish_system_metric(svc, 'boot_time', os.time() - math.floor(uptime)) - end - end + if not platform_cap then return end + local uptime_opts, uptime_opts_err = cap_sdk.args.new.PlatformGetOpts('uptime', report_period) + if uptime_opts_err ~= "" then + svc:obs_log('warn', { what = 'uptime_opts_invalid', err = uptime_opts_err }) + else + local uptime, uptime_err = cap_rpc(platform_cap, 'get', uptime_opts) + if uptime == nil or uptime_err ~= "" then + svc:obs_log('warn', { what = 'uptime_get_failed', err = uptime_err }) + else + publish_system_metric(svc, 'boot_time', os.time() - math.floor(uptime)) + end + end end ---@param _ Scope ---@param svc ServiceBase ---@param report_period_ch Channel local function sysinfo_fiber(_, svc, report_period_ch) - local conn = svc.conn - svc:obs_log('debug', 'sysinfo started') - - fibers.current_scope():finally(function() - local _, primary = fibers.current_scope():status() - bus_cleanup.unretain(svc.conn, t_state_system_stats()) - bus_cleanup.unretain(svc.conn, t_state_system_identity()) - svc:obs_log('debug', { what = 'sysinfo_stopped', reason = tostring(primary or 'ok') }) - end) - - local cap_specs = { - { class = 'platform', id = '1' }, - } - for _, metric in ipairs(SYSINFO_METRICS) do - cap_specs[#cap_specs + 1] = { class = metric.class, id = metric.id } - end - - local cap_refs = discover_caps(conn, svc, cap_specs) - local platform_cap = get_cap_ref(cap_refs, 'platform', '1') - local identity_published = false - - -- Block until System Main sends us the initial report_period. - local report_period = report_period_ch:get() - if not report_period then - svc:obs_log('warn', 'sysinfo: report_period channel closed before config received') - return - end - svc:obs_log('debug', { what = 'report_period_set', value = report_period }) - - local function refresh_caps(timeout) - discover_missing_caps(conn, svc, cap_refs, cap_specs, timeout) - platform_cap = get_cap_ref(cap_refs, 'platform', '1') - if not identity_published and platform_cap then - identity_published = publish_platform_identity_metrics(svc, platform_cap, timeout or REQUEST_TIMEOUT) - end - end - - -- Subscribe to time sync. - local time_sub = conn:subscribe(t_state_time_synced()) - - local time_synced = false - local stats_period = math.max(1, math.min(tonumber(report_period) or 5, 5)) - local next_metrics_at = fibers.now() - - refresh_caps(CAP_RETRY_TIMEOUT) - publish_sysinfo_state(svc, cap_refs, stats_period) - publish_sysinfo_metrics(svc, cap_refs, report_period) - next_metrics_at = fibers.now() + report_period - - while true do - local choices = { - stats = sleep.sleep_op(stats_period), - period = report_period_ch:get_op(), - time = time_sub:recv_op(), - -- time = time_synced and op.never() or op.always( { payload = true } ) - } - - local which, msg = perform(op.named_choice(choices)) - - if which == 'period' then - if msg then - report_period = msg - stats_period = math.max(1, math.min(tonumber(report_period) or 5, 5)) - next_metrics_at = fibers.now() - svc:obs_log('debug', { what = 'report_period_updated', value = report_period, stats_period = stats_period }) - refresh_caps(CAP_RETRY_TIMEOUT) - publish_sysinfo_state(svc, cap_refs, stats_period) - publish_sysinfo_metrics(svc, cap_refs, report_period) - next_metrics_at = fibers.now() + report_period - else - svc:obs_log('debug', 'sysinfo: report_period channel closed') - return - end - elseif which == 'time' then - if not msg then - svc:obs_log('debug', 'sysinfo: time subscription closed') - return - end - time_synced = (msg.payload == true) - svc:obs_log('debug', { what = 'time_synced_updated', value = time_synced }) - elseif which == 'stats' then - refresh_caps(CAP_RETRY_TIMEOUT) - publish_sysinfo_state(svc, cap_refs, stats_period) - - if fibers.now() >= next_metrics_at then - publish_sysinfo_metrics(svc, cap_refs, report_period) - if time_synced and platform_cap then - publish_boot_time_metric(svc, platform_cap, report_period) - end - next_metrics_at = fibers.now() + report_period - end - end - end + local conn = svc.conn + svc:obs_log('debug', 'sysinfo started') + + fibers.current_scope():finally(function() + local _, primary = fibers.current_scope():status() + bus_cleanup.unretain(svc.conn, t_state_system_stats()) + bus_cleanup.unretain(svc.conn, t_state_system_identity()) + svc:obs_log('debug', { what = 'sysinfo_stopped', reason = tostring(primary or 'ok') }) + end) + + local cap_specs = { + { class = 'platform', id = '1' }, + } + for _, metric in ipairs(SYSINFO_METRICS) do + cap_specs[#cap_specs + 1] = { class = metric.class, id = metric.id } + end + + local cap_refs = discover_caps(conn, svc, cap_specs) + local platform_cap = get_cap_ref(cap_refs, 'platform', '1') + local identity_published = false + + -- Block until System Main sends us the initial report_period. + local report_period = report_period_ch:get() + if not report_period then + svc:obs_log('warn', 'sysinfo: report_period channel closed before config received') + return + end + svc:obs_log('debug', { what = 'report_period_set', value = report_period }) + + local function refresh_caps(timeout) + discover_missing_caps(conn, svc, cap_refs, cap_specs, timeout) + platform_cap = get_cap_ref(cap_refs, 'platform', '1') + if not identity_published and platform_cap then + identity_published = publish_platform_identity_metrics(svc, platform_cap, timeout or REQUEST_TIMEOUT) + end + end + + -- Subscribe to time sync. + local time_sub = conn:subscribe(t_state_time_synced()) + + local time_synced = false + local stats_period = math.max(1, math.min(tonumber(report_period) or 5, 5)) + local next_metrics_at = fibers.now() + + refresh_caps(CAP_RETRY_TIMEOUT) + publish_sysinfo_state(svc, cap_refs, stats_period) + publish_sysinfo_metrics(svc, cap_refs, report_period) + next_metrics_at = fibers.now() + report_period + + while true do + local choices = { + stats = sleep.sleep_op(stats_period), + period = report_period_ch:get_op(), + time = time_sub:recv_op(), + -- time = time_synced and op.never() or op.always( { payload = true } ) + } + + local which, msg = perform(op.named_choice(choices)) + + if which == 'period' then + if msg then + report_period = msg + stats_period = math.max(1, math.min(tonumber(report_period) or 5, 5)) + next_metrics_at = fibers.now() + svc:obs_log('debug', { what = 'report_period_updated', value = report_period, stats_period = stats_period }) + refresh_caps(CAP_RETRY_TIMEOUT) + publish_sysinfo_state(svc, cap_refs, stats_period) + publish_sysinfo_metrics(svc, cap_refs, report_period) + next_metrics_at = fibers.now() + report_period + else + svc:obs_log('debug', 'sysinfo: report_period channel closed') + return + end + elseif which == 'time' then + if not msg then + svc:obs_log('debug', 'sysinfo: time subscription closed') + return + end + time_synced = (msg.payload == true) + svc:obs_log('debug', { what = 'time_synced_updated', value = time_synced }) + elseif which == 'stats' then + refresh_caps(CAP_RETRY_TIMEOUT) + publish_sysinfo_state(svc, cap_refs, stats_period) + + if fibers.now() >= next_metrics_at then + publish_sysinfo_metrics(svc, cap_refs, report_period) + if time_synced and platform_cap then + publish_boot_time_metric(svc, platform_cap, report_period) + end + next_metrics_at = fibers.now() + report_period + end + end + end end -- ── shutdown orchestration ───────────────────────────── @@ -515,46 +515,46 @@ local SHUTDOWN_GRACE = 10 -- seconds services have to shut down local USB3_MODEL = "bigbox-ss" -- only model with controllable USB3 hardware local function usb_verb_for_config(hw_revision, cfg) - if hw_revision ~= USB3_MODEL then return nil end - return cfg.usb3_enabled and 'enable' or 'disable' + if hw_revision ~= USB3_MODEL then return nil end + return cfg.usb3_enabled and 'enable' or 'disable' end ---@param svc ServiceBase ---@param power_cap CapabilityReference? ---@param alarm SystemAlarm local function handle_alarm(svc, power_cap, alarm) - local conn = svc.conn - local payload = alarm.payload or {} - local alarm_name = payload.name or "scheduled alarm" - local alarm_type = payload.type or "reboot" - - svc:obs_log('info', { what = 'alarm_fired', name = alarm_name, type = alarm_type }) - svc:obs_event('alarm_fired', { name = alarm_name, type = alarm_type, ts = svc:now() }) - - if not power_cap then - svc:obs_log('error', { what = 'alarm_aborted', reason = 'power cap unavailable', alarm = alarm_name }) - return - end - - -- Broadcast shutdown signal so all services can clean up within the deadline. - conn:retain(t_state_system_shutdown(), { - reason = alarm_name, - deadline = fibers.now() + SHUTDOWN_GRACE, - }) - - -- After the grace period, issue the power command via the capability. - local ok, spawn_err = fibers.current_scope():spawn(function() - perform(sleep.sleep_op(SHUTDOWN_GRACE)) - svc:obs_log('info', { what = 'power_command', type = alarm_type }) - local power_opts = cap_sdk.args.new.PowerActionOpts() - local _, err = cap_rpc(power_cap, alarm_type, power_opts) - if err ~= "" then - svc:obs_log('error', { what = 'power_command_failed', type = alarm_type, err = err }) - end - end) - if not ok then - svc:obs_log('error', { what = 'power_fiber_spawn_failed', err = tostring(spawn_err) }) - end + local conn = svc.conn + local payload = alarm.payload or {} + local alarm_name = payload.name or "scheduled alarm" + local alarm_type = payload.type or "reboot" + + svc:obs_log('info', { what = 'alarm_fired', name = alarm_name, type = alarm_type }) + svc:obs_event('alarm_fired', { name = alarm_name, type = alarm_type, ts = svc:now() }) + + if not power_cap then + svc:obs_log('error', { what = 'alarm_aborted', reason = 'power cap unavailable', alarm = alarm_name }) + return + end + + -- Broadcast shutdown signal so all services can clean up within the deadline. + conn:retain(t_state_system_shutdown(), { + reason = alarm_name, + deadline = fibers.now() + SHUTDOWN_GRACE, + }) + + -- After the grace period, issue the power command via the capability. + local ok, spawn_err = fibers.current_scope():spawn(function() + perform(sleep.sleep_op(SHUTDOWN_GRACE)) + svc:obs_log('info', { what = 'power_command', type = alarm_type }) + local power_opts = cap_sdk.args.new.PowerActionOpts() + local _, err = cap_rpc(power_cap, alarm_type, power_opts) + if err ~= "" then + svc:obs_log('error', { what = 'power_command_failed', type = alarm_type, err = err }) + end + end) + if not ok then + svc:obs_log('error', { what = 'power_fiber_spawn_failed', err = tostring(spawn_err) }) + end end -- ── system main fiber ────────────────────────────── @@ -562,112 +562,112 @@ end ---@param svc ServiceBase ---@param report_period_ch Channel local function system_main(svc, report_period_ch) - local conn = svc.conn - svc:obs_log('debug', 'main started') - - local parent_scope = fibers.current_scope() - parent_scope:finally(function() - local _, primary = parent_scope:status() - svc:obs_log('debug', { what = 'main_stopped', reason = tostring(primary or 'ok') }) - end) - - -- Acquire platform cap to read hw_revision from identity state. - local platform_cap = wait_for_cap(conn, svc, 'platform', '1') - - -- Read hw_revision to gate USB3 control to bigbox-ss hardware only. - local hw_revision = nil - if platform_cap then - local identity, identity_err = read_platform_identity(platform_cap, REQUEST_TIMEOUT) - if type(identity) == 'table' and identity.hw_revision then - hw_revision = identity.hw_revision:match('(%S+)') - svc:obs_log('info', { what = 'hw_revision_detected', value = hw_revision }) - svc:obs_log('info', { - what = 'system_summary', - summary = string.format('system summary hw=%s', tostring(hw_revision)), - hw_revision = hw_revision, - }) - else - svc:obs_log('warn', { - what = 'platform_identity_unavailable', - summary = 'platform identity not available at startup; USB3 control disabled', - err = tostring(identity_err or 'unknown'), - }) - end - end - - -- Acquire USB cap only if this is bigbox-ss hardware. - local usb_cap = nil - if hw_revision == USB3_MODEL then - usb_cap = wait_for_cap(conn, svc, 'usb', 'usb3') - end - - -- Acquire power cap upfront — needed when alarms fire. - local power_cap = wait_for_cap(conn, svc, 'power', '1') - - local alarm_mgr = alarms.AlarmManager.new() - local cfg_sub = conn:subscribe(t_cfg(svc.name)) - local time_sub = conn:subscribe(t_state_time_synced()) - - while true do - local choices = { - cfg = cfg_sub:recv_op(), - time = time_sub:recv_op(), - alarm = alarm_mgr:next_alarm_op(), - } - - local which, msg = perform(op.named_choice(choices)) - - if not msg and (which == 'cfg' or which == 'time') then - svc:obs_log('debug', { what = 'subscription_closed', source = which }) - return - end - - if which == 'cfg' then - local cfg, err = validate_config(msg.payload and msg.payload.data) - if cfg == nil or err ~= "" then - svc:obs_log('warn', { what = 'config_invalid', err = err }) - else - svc:obs_event('config_applied', { ts = svc:now() }) - - -- Forward report_period to sysinfo fiber. - -- Drain any stale unconsumed value first (non-blocking via or_else so - -- the channel is tried first), then put the latest value. - perform(report_period_ch:get_op():or_else(function() return nil end)) - report_period_ch:put(cfg.report_period) - - -- Handle USB3 control (bigbox-ss hardware only). - if usb_cap then - local usb_verb = usb_verb_for_config(hw_revision, cfg) - local _, usb_err = cap_rpc(usb_cap, usb_verb, {}) - if usb_err ~= "" then - svc:obs_log('warn', { what = 'usb3_control_failed', verb = usb_verb, err = usb_err }) - end - end - - -- Reload alarms. - alarm_mgr:delete_all() - if type(cfg.alarms) == 'table' then - for _, alarm_cfg in ipairs(cfg.alarms) do - local add_err = alarm_mgr:add(alarm_cfg) - if add_err ~= "" then - svc:obs_log('warn', { what = 'alarm_add_failed', err = add_err }) - end - end - end - end - elseif which == 'time' then - local is_synced = (msg.payload == true) - if is_synced then - alarm_mgr:sync() - svc:obs_log('debug', 'alarm manager synced') - else - alarm_mgr:desync() - svc:obs_log('debug', 'alarm manager desynced') - end - elseif which == 'alarm' then - handle_alarm(svc, power_cap, msg) - end - end + local conn = svc.conn + svc:obs_log('debug', 'main started') + + local parent_scope = fibers.current_scope() + parent_scope:finally(function() + local _, primary = parent_scope:status() + svc:obs_log('debug', { what = 'main_stopped', reason = tostring(primary or 'ok') }) + end) + + -- Acquire platform cap to read hw_revision from identity state. + local platform_cap = wait_for_cap(conn, svc, 'platform', '1') + + -- Read hw_revision to gate USB3 control to bigbox-ss hardware only. + local hw_revision = nil + if platform_cap then + local identity, identity_err = read_platform_identity(platform_cap, REQUEST_TIMEOUT) + if type(identity) == 'table' and identity.hw_revision then + hw_revision = identity.hw_revision:match('(%S+)') + svc:obs_log('info', { what = 'hw_revision_detected', value = hw_revision }) + svc:obs_log('info', { + what = 'system_summary', + summary = string.format('system summary hw=%s', tostring(hw_revision)), + hw_revision = hw_revision, + }) + else + svc:obs_log('warn', { + what = 'platform_identity_unavailable', + summary = 'platform identity not available at startup; USB3 control disabled', + err = tostring(identity_err or 'unknown'), + }) + end + end + + -- Acquire USB cap only if this is bigbox-ss hardware. + local usb_cap = nil + if hw_revision == USB3_MODEL then + usb_cap = wait_for_cap(conn, svc, 'usb', 'usb3') + end + + -- Acquire power cap upfront — needed when alarms fire. + local power_cap = wait_for_cap(conn, svc, 'power', '1') + + local alarm_mgr = alarms.AlarmManager.new() + local cfg_sub = conn:subscribe(t_cfg(svc.name)) + local time_sub = conn:subscribe(t_state_time_synced()) + + while true do + local choices = { + cfg = cfg_sub:recv_op(), + time = time_sub:recv_op(), + alarm = alarm_mgr:next_alarm_op(), + } + + local which, msg = perform(op.named_choice(choices)) + + if not msg and (which == 'cfg' or which == 'time') then + svc:obs_log('debug', { what = 'subscription_closed', source = which }) + return + end + + if which == 'cfg' then + local cfg, err = validate_config(msg.payload and msg.payload.data) + if cfg == nil or err ~= "" then + svc:obs_log('warn', { what = 'config_invalid', err = err }) + else + svc:obs_event('config_applied', { ts = svc:now() }) + + -- Forward report_period to sysinfo fiber. + -- Drain any stale unconsumed value first (non-blocking via or_else so + -- the channel is tried first), then put the latest value. + perform(report_period_ch:get_op():or_else(function() return nil end)) + report_period_ch:put(cfg.report_period) + + -- Handle USB3 control (bigbox-ss hardware only). + if usb_cap then + local usb_verb = usb_verb_for_config(hw_revision, cfg) + local _, usb_err = cap_rpc(usb_cap, usb_verb, {}) + if usb_err ~= "" then + svc:obs_log('warn', { what = 'usb3_control_failed', verb = usb_verb, err = usb_err }) + end + end + + -- Reload alarms. + alarm_mgr:delete_all() + if type(cfg.alarms) == 'table' then + for _, alarm_cfg in ipairs(cfg.alarms) do + local add_err = alarm_mgr:add(alarm_cfg) + if add_err ~= "" then + svc:obs_log('warn', { what = 'alarm_add_failed', err = add_err }) + end + end + end + end + elseif which == 'time' then + local is_synced = (msg.payload == true) + if is_synced then + alarm_mgr:sync() + svc:obs_log('debug', 'alarm manager synced') + else + alarm_mgr:desync() + svc:obs_log('debug', 'alarm manager desynced') + end + elseif which == 'alarm' then + handle_alarm(svc, power_cap, msg) + end + end end -- ── service entry point ────────────────────────────── @@ -678,44 +678,44 @@ local SystemService = {} ---@param conn Connection ---@param opts? { name?: string, env?: string, heartbeat_s?: number } function SystemService.start(conn, opts) - opts = opts or {} + opts = opts or {} - local svc = base.new(conn, { name = opts.name or 'system', env = opts.env }) - local heartbeat_s = (type(opts.heartbeat_s) == 'number') and opts.heartbeat_s or 30.0 + local svc = base.new(conn, { name = opts.name or 'system', env = opts.env }) + local heartbeat_s = (type(opts.heartbeat_s) == 'number') and opts.heartbeat_s or 30.0 - svc:obs_state('boot', { at = svc:wall(), ts = svc:now(), state = 'entered' }) - svc:obs_log('debug', 'service start() entered') - svc:announce({}) - svc:starting() - svc:spawn_heartbeat(heartbeat_s, 'tick') + svc:obs_state('boot', { at = svc:wall(), ts = svc:now(), state = 'entered' }) + svc:obs_log('debug', 'service start() entered') + svc:announce({}) + svc:starting() + svc:spawn_heartbeat(heartbeat_s, 'tick') - -- Channel carries report_period from System Main → System Sysinfo. - -- Buffer of 1 so a rapid double-update does not block Main. - local report_period_ch = channel.new(1) + -- Channel carries report_period from System Main → System Sysinfo. + -- Buffer of 1 so a rapid double-update does not block Main. + local report_period_ch = channel.new(1) - fibers.current_scope():finally(function() - local _, primary = fibers.current_scope():status() - svc:lifecycle('stopped', { ready = false, reason = tostring(primary or 'ok') }) - svc:obs_log('debug', 'service stopped') - end) + fibers.current_scope():finally(function() + local _, primary = fibers.current_scope():status() + svc:lifecycle('stopped', { ready = false, reason = tostring(primary or 'ok') }) + svc:obs_log('debug', 'service stopped') + end) - -- Spawn Sysinfo first so it is ready to receive from report_period_ch. - fibers.current_scope():spawn(sysinfo_fiber, svc, report_period_ch) + -- Spawn Sysinfo first so it is ready to receive from report_period_ch. + fibers.current_scope():spawn(sysinfo_fiber, svc, report_period_ch) - svc:running() - svc:obs_log('debug', 'service running') + svc:running() + svc:obs_log('debug', 'service running') - -- Run System Main in the calling fiber. - system_main(svc, report_period_ch) + -- Run System Main in the calling fiber. + system_main(svc, report_period_ch) end SystemService._test = { - publish_platform_identity_metrics = publish_platform_identity_metrics, - read_platform_identity = read_platform_identity, - publish_sysinfo_metrics = publish_sysinfo_metrics, - collect_sysinfo_state = collect_sysinfo_state, - publish_sysinfo_state = publish_sysinfo_state, - usb_verb_for_config = usb_verb_for_config, + publish_platform_identity_metrics = publish_platform_identity_metrics, + read_platform_identity = read_platform_identity, + publish_sysinfo_metrics = publish_sysinfo_metrics, + collect_sysinfo_state = collect_sysinfo_state, + publish_sysinfo_state = publish_sysinfo_state, + usb_verb_for_config = usb_verb_for_config, } return SystemService diff --git a/src/services/system/alarms.lua b/src/services/system/alarms.lua index 138a35e6..384d280d 100644 --- a/src/services/system/alarms.lua +++ b/src/services/system/alarms.lua @@ -7,8 +7,8 @@ local op = require 'fibers.op' local falarm = require "fibers.alarm" local REPEAT_TYPES = { - NONE = "none", - DAILY = "daily", + NONE = "none", + DAILY = "daily", } ---@class AlarmTriggerTime @@ -21,39 +21,39 @@ local REPEAT_TYPES = { ---@param now number ---@return number? local function next_local_trigger(trigger_time, repeat_type, last, now) - local t - - if last ~= nil then - if repeat_type == REPEAT_TYPES.NONE then - return nil - end - - t = os.date('*t', last) - t.day = t.day + 1 - else - t = os.date('*t', now) - local past = t.hour > trigger_time.hour - or (t.hour == trigger_time.hour and t.min >= trigger_time.min) - if past then - t.day = t.day + 1 - end - end - - ---@cast t osdate - t.hour, t.min, t.sec = trigger_time.hour, trigger_time.min, 0 - return os.time(t) + local t + + if last ~= nil then + if repeat_type == REPEAT_TYPES.NONE then + return nil + end + + t = os.date('*t', last) + t.day = t.day + 1 + else + t = os.date('*t', now) + local past = t.hour > trigger_time.hour + or (t.hour == trigger_time.hour and t.min >= trigger_time.min) + if past then + t.day = t.day + 1 + end + end + + ---@cast t osdate + t.hour, t.min, t.sec = trigger_time.hour, trigger_time.min, 0 + return os.time(t) end ---@param trigger_time AlarmTriggerTime ---@param repeat_type string ---@return Alarm local function build_wait_alarm(trigger_time, repeat_type) - return falarm.new { - next_time = function(last, now) - return next_local_trigger(trigger_time, repeat_type, last, now) - end, - label = string.format('system_%s_%02d:%02d', repeat_type, trigger_time.hour, trigger_time.min), - } + return falarm.new { + next_time = function(last, now) + return next_local_trigger(trigger_time, repeat_type, last, now) + end, + label = string.format('system_%s_%02d:%02d', repeat_type, trigger_time.hour, trigger_time.min), + } end ---@class AlarmConfig @@ -75,63 +75,63 @@ Alarm.__index = Alarm ---@return SystemAlarm? alarm ---@return string error function Alarm.new(config) - if type(config) ~= 'table' then - return nil, "Alarm config must be a table" - end - - if type(config.time) ~= 'string' or config.time == '' then - return nil, "Alarm needs a time" - end - - local hour, minute = config.time:match("^(%d+):(%d+)$") - if not hour or not minute then - return nil, "Invalid datetime format, expected 'HH:MM'" - end - hour, minute = tonumber(hour), tonumber(minute) - if not hour or not minute or - hour < 0 or hour > 23 or minute < 0 or minute > 59 then - return nil, "Invalid datetime format, expected 'HH:MM', 0 <= HH <= 23, 0 <= MM <= 59" - end - - local repeat_type = REPEAT_TYPES.NONE - if config.repeats ~= nil then - if type(config.repeats) ~= 'string' then - return nil, "Invalid repeat type" - end - local repeat_upper = string.upper(config.repeats) - if REPEAT_TYPES[repeat_upper] then - repeat_type = REPEAT_TYPES[repeat_upper] - else - return nil, "Invalid repeat type: " .. config.repeats - end - end - - local trigger_time = { hour = hour, min = minute } - - local next_trigger = next_local_trigger(trigger_time, repeat_type, nil, os.time()) - if not next_trigger then - return nil, "Failed to calculate next trigger" - end - - return setmetatable({ - payload = config.payload, - trigger_time = trigger_time, - repeat_type = repeat_type, - next_trigger = next_trigger, - wait_alarm = build_wait_alarm(trigger_time, repeat_type), - }, Alarm), "" + if type(config) ~= 'table' then + return nil, "Alarm config must be a table" + end + + if type(config.time) ~= 'string' or config.time == '' then + return nil, "Alarm needs a time" + end + + local hour, minute = config.time:match("^(%d+):(%d+)$") + if not hour or not minute then + return nil, "Invalid datetime format, expected 'HH:MM'" + end + hour, minute = tonumber(hour), tonumber(minute) + if not hour or not minute or + hour < 0 or hour > 23 or minute < 0 or minute > 59 then + return nil, "Invalid datetime format, expected 'HH:MM', 0 <= HH <= 23, 0 <= MM <= 59" + end + + local repeat_type = REPEAT_TYPES.NONE + if config.repeats ~= nil then + if type(config.repeats) ~= 'string' then + return nil, "Invalid repeat type" + end + local repeat_upper = string.upper(config.repeats) + if REPEAT_TYPES[repeat_upper] then + repeat_type = REPEAT_TYPES[repeat_upper] + else + return nil, "Invalid repeat type: " .. config.repeats + end + end + + local trigger_time = { hour = hour, min = minute } + + local next_trigger = next_local_trigger(trigger_time, repeat_type, nil, os.time()) + if not next_trigger then + return nil, "Failed to calculate next trigger" + end + + return setmetatable({ + payload = config.payload, + trigger_time = trigger_time, + repeat_type = repeat_type, + next_trigger = next_trigger, + wait_alarm = build_wait_alarm(trigger_time, repeat_type), + }, Alarm), "" end ---@return string error function Alarm:calc_next_trigger() - local next_trigger = next_local_trigger(self.trigger_time, self.repeat_type, nil, os.time()) - if not next_trigger then - return "Failed to calculate next trigger" - end - - self.next_trigger = next_trigger - self.wait_alarm = build_wait_alarm(self.trigger_time, self.repeat_type) - return "" + local next_trigger = next_local_trigger(self.trigger_time, self.repeat_type, nil, os.time()) + if not next_trigger then + return "Failed to calculate next trigger" + end + + self.next_trigger = next_trigger + self.wait_alarm = build_wait_alarm(self.trigger_time, self.repeat_type) + return "" end ---@class SystemAlarmManager @@ -142,57 +142,57 @@ AlarmManager.__index = AlarmManager ---@return SystemAlarmManager function AlarmManager.new() - return setmetatable({ alarms = {}, is_synced = false }, AlarmManager) + return setmetatable({ alarms = {}, is_synced = false }, AlarmManager) end --- Add an alarm (or a config table that describes one) in sorted order. ---@param alarm SystemAlarm|AlarmConfig ---@return string error function AlarmManager:add(alarm) - if type(alarm) == "table" and getmetatable(alarm) ~= Alarm then - ---@cast alarm AlarmConfig - local new_alarm, err = Alarm.new(alarm) - if not new_alarm then return err end - alarm = new_alarm - end - - if not alarm or getmetatable(alarm) ~= Alarm then - return "Invalid alarm object" - end - - local calc_err = alarm:calc_next_trigger() - if calc_err ~= "" then - return calc_err - end - - local i = 1 - -- Insert the alarm in the sorted position based on next_trigger - while i <= #self.alarms and self.alarms[i].next_trigger < alarm.next_trigger do - i = i + 1 - end - table.insert(self.alarms, i, alarm) - return "" + if type(alarm) == "table" and getmetatable(alarm) ~= Alarm then + ---@cast alarm AlarmConfig + local new_alarm, err = Alarm.new(alarm) + if not new_alarm then return err end + alarm = new_alarm + end + + if not alarm or getmetatable(alarm) ~= Alarm then + return "Invalid alarm object" + end + + local calc_err = alarm:calc_next_trigger() + if calc_err ~= "" then + return calc_err + end + + local i = 1 + -- Insert the alarm in the sorted position based on next_trigger + while i <= #self.alarms and self.alarms[i].next_trigger < alarm.next_trigger do + i = i + 1 + end + table.insert(self.alarms, i, alarm) + return "" end --- Remove all alarms. function AlarmManager:delete_all() - self.alarms = {} + self.alarms = {} end --- Mark the manager as synced, recalculating all trigger times from now. function AlarmManager:sync() - if self.is_synced then return end - self.is_synced = true - local old = self.alarms - self:delete_all() - for _, alarm in ipairs(old) do - self:add(alarm) - end + if self.is_synced then return end + self.is_synced = true + local old = self.alarms + self:delete_all() + for _, alarm in ipairs(old) do + self:add(alarm) + end end --- Mark the manager as desynced (alarms will not fire until re-synced). function AlarmManager:desync() - self.is_synced = false + self.is_synced = false end --- Return an op that resolves when the next alarm fires, yielding the alarm. @@ -200,25 +200,25 @@ end --- never resolves. ---@return Op operation function AlarmManager:next_alarm_op() - if #self.alarms == 0 or not self.is_synced then - return op.never() - end - - local head = self.alarms[1] - return head.wait_alarm:wait_op():wrap(function() - local alarm = table.remove(self.alarms, 1) - -- Re-queue repeating alarms. - if alarm.repeat_type ~= REPEAT_TYPES.NONE then - local add_err = self:add(alarm) - if add_err ~= "" then - error("Failed to re-add repeating alarm: " .. add_err) - end - end - return alarm - end) + if #self.alarms == 0 or not self.is_synced then + return op.never() + end + + local head = self.alarms[1] + return head.wait_alarm:wait_op():wrap(function() + local alarm = table.remove(self.alarms, 1) + -- Re-queue repeating alarms. + if alarm.repeat_type ~= REPEAT_TYPES.NONE then + local add_err = self:add(alarm) + if add_err ~= "" then + error("Failed to re-add repeating alarm: " .. add_err) + end + end + return alarm + end) end return { - Alarm = Alarm, - AlarmManager = AlarmManager, + Alarm = Alarm, + AlarmManager = AlarmManager, } diff --git a/src/services/wifi.lua b/src/services/wifi.lua index 375b2a08..d8e6214d 100644 --- a/src/services/wifi.lua +++ b/src/services/wifi.lua @@ -65,31 +65,31 @@ local utils = require 'services.wifi.utils' -- Mode names the config uses vs what the radio driver expects local MODE_MAP = { - access_point = 'ap', - client = 'sta', + access_point = 'ap', + client = 'sta', } local function map_mode(mode) - return MODE_MAP[mode] or mode + return MODE_MAP[mode] or mode end local function is_table(v) return type(v) == 'table' end local function valid_user_salt(v) return type(v) == 'string' and v ~= '' end local function count_array(t) - return is_table(t) and #t or 0 + return is_table(t) and #t or 0 end local function ssids_for_radio(ssid_cfgs, radio_id) - local count = 0 - for _, ssid_cfg in ipairs(ssid_cfgs or {}) do - if is_table(ssid_cfg.radios) then - for _, r in ipairs(ssid_cfg.radios) do - if r == radio_id then count = count + 1; break end - end - end - end - return count + local count = 0 + for _, ssid_cfg in ipairs(ssid_cfgs or {}) do + if is_table(ssid_cfg.radios) then + for _, r in ipairs(ssid_cfg.radios) do + if r == radio_id then count = count + 1; break end + end + end + end + return count end ------------------------------------------------------------------------ @@ -98,31 +98,31 @@ end -- Remap tables for config-level scoring keys → band driver option keys local RSSI_SCORING_REMAP = { - center = 'rssi_center', - weight = 'rssi_weight', - good_threshold = 'rssi_reward_threshold', - good_reward = 'rssi_reward', - bad_threshold = 'rssi_penalty_threshold', - bad_penalty = 'rssi_penalty', + center = 'rssi_center', + weight = 'rssi_weight', + good_threshold = 'rssi_reward_threshold', + good_reward = 'rssi_reward', + bad_threshold = 'rssi_penalty_threshold', + bad_penalty = 'rssi_penalty', } local CHAN_UTIL_SCORING_REMAP = { - good_threshold = 'channel_util_reward_threshold', - good_reward = 'channel_util_reward', - bad_threshold = 'channel_util_penalty_threshold', - bad_penalty = 'channel_util_penalty', + good_threshold = 'channel_util_reward_threshold', + good_reward = 'channel_util_reward', + bad_threshold = 'channel_util_penalty_threshold', + bad_penalty = 'channel_util_penalty', } local function remap_keys(src, mapping) - local out = {} - for src_key, dst_key in pairs(mapping) do - if src[src_key] ~= nil then out[dst_key] = src[src_key] end - end - return out + local out = {} + for src_key, dst_key in pairs(mapping) do + if src[src_key] ~= nil then out[dst_key] = src[src_key] end + end + return out end -- Parse interface index from generated name suffix (e.g. "radio0_i2" → "2") local function iface_idx(name) - return (name and name:match('_i(%d+)$')) or '0' + return (name and name:match('_i(%d+)$')) or '0' end ---@param key string @@ -138,171 +138,171 @@ local function t_obs_event(key) return { 'obs', 'v1', 'wifi', 'event', key } end ------------------------------------------------------------------------ local function band_rpc(band_cap, svc, method, args, opts) - local reply, err = perform(band_cap:call_control_op(method, args, opts)) - if err and err ~= "" then - svc:obs_log('warn', { what = 'band_rpc_failed', method = method, err = tostring(err) }) - return false - end - if not reply or reply.ok ~= true then - svc:obs_log('warn', { - what = 'band_rpc_error', - method = method, - reason = reply and reply.reason or 'nil', - }) - return false - end - return true + local reply, err = perform(band_cap:call_control_op(method, args, opts)) + if err and err ~= "" then + svc:obs_log('warn', { what = 'band_rpc_failed', method = method, err = tostring(err) }) + return false + end + if not reply or reply.ok ~= true then + svc:obs_log('warn', { + what = 'band_rpc_error', + method = method, + reason = reply and reply.reason or 'nil', + }) + return false + end + return true end ---@param band_cap CapabilityReference ---@param band_cfg WifiBandSteeringConfig ---@param svc ServiceBase local function apply_band_steering(band_cap, band_cfg, svc) - if not is_table(band_cfg) then return end - local globals = band_cfg.globals or {} - local timings = band_cfg.timings or {} - local bands = band_cfg.bands or {} - - local slow = { timeout = 10.0 } - - svc:obs_log('debug', { what = 'band_config_start' }) - band_rpc(band_cap, svc, 'clear', {}, slow) - - -- kicking - local kicking = globals.kicking - if is_table(kicking) then - local args, err = cap_sdk.args.new.BandSetKickingOpts( - kicking.kick_mode or 'none', - kicking.bandwidth_threshold or 0, - kicking.kicking_threshold or 0, - kicking.evals_before_kick or 0) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_kicking', err = err }) - else - band_rpc(band_cap, svc, 'set_kicking', args) - end - end - - -- station counting - if is_table(globals.stations) then - local st = globals.stations - local args, err = cap_sdk.args.new.BandSetStationCountingOpts( - st.use_station_count or false, - st.max_station_diff or 0) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_station_counting', err = err }) - else - band_rpc(band_cap, svc, 'set_station_counting', args) - end - end - - -- rrm_mode - if globals.rrm_mode then - local args, err = cap_sdk.args.new.BandSetRrmModeOpts(globals.rrm_mode) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_rrm_mode', err = err }) - else - band_rpc(band_cap, svc, 'set_rrm_mode', args) - end - end - - -- neighbour reports - if is_table(globals.neighbor_reports) then - local nr = globals.neighbor_reports - local args, err = cap_sdk.args.new.BandSetNeighbourReportsOpts( - nr.dyn_report_num or 0, - nr.disassoc_report_len or 0) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_neighbour_reports', err = err }) - else - band_rpc(band_cap, svc, 'set_neighbour_reports', args) - end - end - - -- legacy options - if is_table(globals.legacy) then - local args, err = cap_sdk.args.new.BandSetLegacyOptionsOpts(globals.legacy) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_legacy_options', err = err }) - else - band_rpc(band_cap, svc, 'set_legacy_options', args) - end - end - - -- update frequencies - if is_table(timings.updates) then - local args, err = cap_sdk.args.new.BandSetUpdateFreqOpts(timings.updates) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_update_freq', err = err }) - else - band_rpc(band_cap, svc, 'set_update_freq', args) - end - end - - -- inactive client kickoff - if timings.inactive_client_kickoff ~= nil then - local args, err = cap_sdk.args.new.BandSetClientInactiveKickoffOpts(timings.inactive_client_kickoff) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_client_inactive_kickoff', err = err }) - else - band_rpc(band_cap, svc, 'set_client_inactive_kickoff', args) - end - end - - -- cleanup timeouts - if is_table(timings.cleanup) then - local args, err = cap_sdk.args.new.BandSetCleanupOpts(timings.cleanup) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_cleanup', err = err }) - else - band_rpc(band_cap, svc, 'set_cleanup', args) - end - end - - -- per-band settings - for band_key, band_data in pairs(bands) do - if is_table(band_data) then - if band_data.initial_score ~= nil then - local args, err = cap_sdk.args.new.BandSetBandPriorityOpts(band_key, band_data.initial_score) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_priority', err = err }) - else - band_rpc(band_cap, svc, 'set_band_priority', args) - end - end - if is_table(band_data.rssi_scoring) then - local args, err = cap_sdk.args.new.BandSetBandKickingOpts( - band_key, remap_keys(band_data.rssi_scoring, RSSI_SCORING_REMAP)) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_kicking', err = err }) - else - band_rpc(band_cap, svc, 'set_band_kicking', args) - end - end - if is_table(band_data.chan_util_scoring) then - local args, err = cap_sdk.args.new.BandSetBandKickingOpts( - band_key, remap_keys(band_data.chan_util_scoring, CHAN_UTIL_SCORING_REMAP)) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_kicking', err = err }) - else - band_rpc(band_cap, svc, 'set_band_kicking', args) - end - end - if is_table(band_data.support_bonuses) then - for support_key, reward in pairs(band_data.support_bonuses) do - local args, err = cap_sdk.args.new.BandSetSupportBonusOpts(band_key, support_key, reward) - if not args then - svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_support_bonus', err = err }) - else - band_rpc(band_cap, svc, 'set_support_bonus', args) - end - end - end - end - end - - band_rpc(band_cap, svc, 'apply', {}, slow) - svc:obs_log('debug', { what = 'band_config_applied' }) + if not is_table(band_cfg) then return end + local globals = band_cfg.globals or {} + local timings = band_cfg.timings or {} + local bands = band_cfg.bands or {} + + local slow = { timeout = 10.0 } + + svc:obs_log('debug', { what = 'band_config_start' }) + band_rpc(band_cap, svc, 'clear', {}, slow) + + -- kicking + local kicking = globals.kicking + if is_table(kicking) then + local args, err = cap_sdk.args.new.BandSetKickingOpts( + kicking.kick_mode or 'none', + kicking.bandwidth_threshold or 0, + kicking.kicking_threshold or 0, + kicking.evals_before_kick or 0) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_kicking', err = err }) + else + band_rpc(band_cap, svc, 'set_kicking', args) + end + end + + -- station counting + if is_table(globals.stations) then + local st = globals.stations + local args, err = cap_sdk.args.new.BandSetStationCountingOpts( + st.use_station_count or false, + st.max_station_diff or 0) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_station_counting', err = err }) + else + band_rpc(band_cap, svc, 'set_station_counting', args) + end + end + + -- rrm_mode + if globals.rrm_mode then + local args, err = cap_sdk.args.new.BandSetRrmModeOpts(globals.rrm_mode) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_rrm_mode', err = err }) + else + band_rpc(band_cap, svc, 'set_rrm_mode', args) + end + end + + -- neighbour reports + if is_table(globals.neighbor_reports) then + local nr = globals.neighbor_reports + local args, err = cap_sdk.args.new.BandSetNeighbourReportsOpts( + nr.dyn_report_num or 0, + nr.disassoc_report_len or 0) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_neighbour_reports', err = err }) + else + band_rpc(band_cap, svc, 'set_neighbour_reports', args) + end + end + + -- legacy options + if is_table(globals.legacy) then + local args, err = cap_sdk.args.new.BandSetLegacyOptionsOpts(globals.legacy) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_legacy_options', err = err }) + else + band_rpc(band_cap, svc, 'set_legacy_options', args) + end + end + + -- update frequencies + if is_table(timings.updates) then + local args, err = cap_sdk.args.new.BandSetUpdateFreqOpts(timings.updates) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_update_freq', err = err }) + else + band_rpc(band_cap, svc, 'set_update_freq', args) + end + end + + -- inactive client kickoff + if timings.inactive_client_kickoff ~= nil then + local args, err = cap_sdk.args.new.BandSetClientInactiveKickoffOpts(timings.inactive_client_kickoff) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_client_inactive_kickoff', err = err }) + else + band_rpc(band_cap, svc, 'set_client_inactive_kickoff', args) + end + end + + -- cleanup timeouts + if is_table(timings.cleanup) then + local args, err = cap_sdk.args.new.BandSetCleanupOpts(timings.cleanup) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_cleanup', err = err }) + else + band_rpc(band_cap, svc, 'set_cleanup', args) + end + end + + -- per-band settings + for band_key, band_data in pairs(bands) do + if is_table(band_data) then + if band_data.initial_score ~= nil then + local args, err = cap_sdk.args.new.BandSetBandPriorityOpts(band_key, band_data.initial_score) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_priority', err = err }) + else + band_rpc(band_cap, svc, 'set_band_priority', args) + end + end + if is_table(band_data.rssi_scoring) then + local args, err = cap_sdk.args.new.BandSetBandKickingOpts( + band_key, remap_keys(band_data.rssi_scoring, RSSI_SCORING_REMAP)) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_kicking', err = err }) + else + band_rpc(band_cap, svc, 'set_band_kicking', args) + end + end + if is_table(band_data.chan_util_scoring) then + local args, err = cap_sdk.args.new.BandSetBandKickingOpts( + band_key, remap_keys(band_data.chan_util_scoring, CHAN_UTIL_SCORING_REMAP)) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_band_kicking', err = err }) + else + band_rpc(band_cap, svc, 'set_band_kicking', args) + end + end + if is_table(band_data.support_bonuses) then + for support_key, reward in pairs(band_data.support_bonuses) do + local args, err = cap_sdk.args.new.BandSetSupportBonusOpts(band_key, support_key, reward) + if not args then + svc:obs_log('warn', { what = 'band_args_invalid', method = 'set_support_bonus', err = err }) + else + band_rpc(band_cap, svc, 'set_support_bonus', args) + end + end + end + end + end + + band_rpc(band_cap, svc, 'apply', {}, slow) + svc:obs_log('debug', { what = 'band_config_applied' }) end ------------------------------------------------------------------------ @@ -310,26 +310,26 @@ end ------------------------------------------------------------------------ local function radio_rpc(radio_cap, radio_id, svc, method, args) - local reply, err = radio_cap:call_control(method, args) - if err and err ~= "" then - svc:obs_log('warn', { - what = 'radio_rpc_failed', - radio = radio_id, - method = method, - err = tostring(err), - }) - return false - end - if not reply or reply.ok ~= true then - svc:obs_log('warn', { - what = 'radio_rpc_error', - radio = radio_id, - method = method, - reason = reply and reply.reason or 'nil', - }) - return false - end - return true, reply + local reply, err = radio_cap:call_control(method, args) + if err and err ~= "" then + svc:obs_log('warn', { + what = 'radio_rpc_failed', + radio = radio_id, + method = method, + err = tostring(err), + }) + return false + end + if not reply or reply.ok ~= true then + svc:obs_log('warn', { + what = 'radio_rpc_error', + radio = radio_id, + method = method, + reason = reply and reply.reason or 'nil', + }) + return false + end + return true, reply end ------------------------------------------------------------------------ @@ -343,179 +343,179 @@ end ---@param band_steering_cfg WifiBandSteeringConfig? ---@param svc ServiceBase local function apply_radio_config(radio_cap, radio_cfg, ssid_cfgs, fs_configs_cap, band_steering_cfg, svc) - local radio_id = radio_cap.id - - svc:obs_log('debug', { what = 'radio_config_start', radio = radio_id }) - - -- 1. Clear staged config - if not radio_rpc(radio_cap, radio_id, svc, 'clear_radio_config', {}) then return end - - -- 2. Set report period - do - local args, err = cap_sdk.args.new.RadioSetReportPeriodOpts(radio_cfg.report_period or 60) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_report_period', err = err }) - return - end - if not radio_rpc(radio_cap, radio_id, svc, 'set_report_period', args) then return end - end - - -- 3. Set channels - do - local args, err = cap_sdk.args.new.RadioSetChannelsOpts( - radio_cfg.band, radio_cfg.channel, radio_cfg.htmode, radio_cfg.channels) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_channels', err = err }) - return - end - if not radio_rpc(radio_cap, radio_id, svc, 'set_channels', args) then return end - svc:obs_log('debug', { - what = 'radio_channels_set', - radio = radio_id, - band = radio_cfg.band, - channel = radio_cfg.channel, - htmode = radio_cfg.htmode, - }) - end - - -- 4. txpower (optional) - if radio_cfg.txpower ~= nil then - local args, err = cap_sdk.args.new.RadioSetTxpowerOpts(radio_cfg.txpower) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_txpower', err = err }) - else - radio_rpc(radio_cap, radio_id, svc, 'set_txpower', args) - end - end - - -- 5. country (optional) - if radio_cfg.country then - local args, err = cap_sdk.args.new.RadioSetCountryOpts(radio_cfg.country) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_country', err = err }) - else - radio_rpc(radio_cap, radio_id, svc, 'set_country', args) - end - end - - -- 6. enabled / disabled (optional) - if radio_cfg.disabled ~= nil then - local args, err = cap_sdk.args.new.RadioSetEnabledOpts(not radio_cfg.disabled) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_enabled', err = err }) - else - radio_rpc(radio_cap, radio_id, svc, 'set_enabled', args) - end - end - - -- Determine enable_steering from band steering kick_mode - local kick_mode = band_steering_cfg - and is_table(band_steering_cfg.globals) - and is_table(band_steering_cfg.globals.kicking) - and band_steering_cfg.globals.kicking.kick_mode - local enable_steering = kick_mode and kick_mode ~= 'none' or false - - -- 7. Add interfaces per SSID - for _, ssid_cfg in ipairs(ssid_cfgs) do - repeat - -- Check if this SSID applies to this radio - local applies = false - if is_table(ssid_cfg.radios) then - for _, r in ipairs(ssid_cfg.radios) do - if r == radio_cap.id then - applies = true; break - end - end - end - if not applies then break end - - if ssid_cfg.mainflux_path then - -- Source credentials from filesystem cap; strict: skip on failure - if not fs_configs_cap then - svc:obs_log('warn', { what = 'no_fs_configs_cap', ssid = ssid_cfg.name }) - break - end - local base_cfg = { - segment = ssid_cfg.segment, - encryption = ssid_cfg.encryption or 'none', - password = ssid_cfg.password or '', - mode = map_mode(ssid_cfg.mode or 'access_point'), - } - local ssids, serr = utils.parse_mainflux_ssids(fs_configs_cap, ssid_cfg.mainflux_path, base_cfg) - if not ssids then - svc:obs_log('warn', { what = 'mainflux_ssid_failed', ssid = ssid_cfg.name, err = serr }) - break - end - for _, mssd in ipairs(ssids) do - if type(mssd.segment) ~= 'string' or mssd.segment == '' then - svc:obs_log('warn', { what = 'ssid_missing_segment', ssid = mssd.name or mssd.ssid }) - break - end - local args, err = cap_sdk.args.new.RadioAddInterfaceOpts( - mssd.name or mssd.ssid or '', - mssd.encryption or 'none', - mssd.password or '', - mssd.segment, - map_mode(mssd.mode or 'ap'), - enable_steering) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'add_interface', err = err }) - else - local ok, reply = radio_rpc(radio_cap, radio_id, svc, 'add_interface', args) - if ok then - svc:obs_log('debug', { - what = 'radio_iface_added', - radio = radio_id, - ssid = mssd.name or mssd.ssid, - segment = mssd.segment, - iface = reply and reply.reason or nil, - }) - end - end - end - else - -- Direct SSID from config - if type(ssid_cfg.segment) ~= 'string' or ssid_cfg.segment == '' then - svc:obs_log('warn', { what = 'ssid_missing_segment', ssid = ssid_cfg.name }) - break - end - local args, err = cap_sdk.args.new.RadioAddInterfaceOpts( - ssid_cfg.name or '', - ssid_cfg.encryption or 'none', - ssid_cfg.password or '', - ssid_cfg.segment, - map_mode(ssid_cfg.mode or 'access_point'), - enable_steering) - if not args then - svc:obs_log('warn', { what = 'radio_args_invalid', method = 'add_interface', err = err }) - else - local ok, reply = radio_rpc(radio_cap, radio_id, svc, 'add_interface', args) - if ok then - svc:obs_log('debug', { - what = 'radio_iface_added', - radio = radio_id, - ssid = ssid_cfg.name, - segment = ssid_cfg.segment, - iface = reply and reply.reason or nil, - }) - end - end - end - until true - end - - -- 8. Apply - if not radio_rpc(radio_cap, radio_id, svc, 'apply', {}) then return end - svc:obs_log('info', { - what = 'radio_ready', - summary = string.format('wifi radio %s ready band=%s channel=%s ssids=%d', tostring(radio_id), tostring(radio_cfg.band or '?'), tostring(radio_cfg.channel or '?'), ssids_for_radio(ssid_cfgs, radio_id)), - radio = radio_id, - band = radio_cfg.band, - channel = radio_cfg.channel, - htmode = radio_cfg.htmode, - txpower = radio_cfg.txpower, - ssids = ssids_for_radio(ssid_cfgs, radio_id), - }) + local radio_id = radio_cap.id + + svc:obs_log('debug', { what = 'radio_config_start', radio = radio_id }) + + -- 1. Clear staged config + if not radio_rpc(radio_cap, radio_id, svc, 'clear_radio_config', {}) then return end + + -- 2. Set report period + do + local args, err = cap_sdk.args.new.RadioSetReportPeriodOpts(radio_cfg.report_period or 60) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_report_period', err = err }) + return + end + if not radio_rpc(radio_cap, radio_id, svc, 'set_report_period', args) then return end + end + + -- 3. Set channels + do + local args, err = cap_sdk.args.new.RadioSetChannelsOpts( + radio_cfg.band, radio_cfg.channel, radio_cfg.htmode, radio_cfg.channels) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_channels', err = err }) + return + end + if not radio_rpc(radio_cap, radio_id, svc, 'set_channels', args) then return end + svc:obs_log('debug', { + what = 'radio_channels_set', + radio = radio_id, + band = radio_cfg.band, + channel = radio_cfg.channel, + htmode = radio_cfg.htmode, + }) + end + + -- 4. txpower (optional) + if radio_cfg.txpower ~= nil then + local args, err = cap_sdk.args.new.RadioSetTxpowerOpts(radio_cfg.txpower) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_txpower', err = err }) + else + radio_rpc(radio_cap, radio_id, svc, 'set_txpower', args) + end + end + + -- 5. country (optional) + if radio_cfg.country then + local args, err = cap_sdk.args.new.RadioSetCountryOpts(radio_cfg.country) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_country', err = err }) + else + radio_rpc(radio_cap, radio_id, svc, 'set_country', args) + end + end + + -- 6. enabled / disabled (optional) + if radio_cfg.disabled ~= nil then + local args, err = cap_sdk.args.new.RadioSetEnabledOpts(not radio_cfg.disabled) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'set_enabled', err = err }) + else + radio_rpc(radio_cap, radio_id, svc, 'set_enabled', args) + end + end + + -- Determine enable_steering from band steering kick_mode + local kick_mode = band_steering_cfg + and is_table(band_steering_cfg.globals) + and is_table(band_steering_cfg.globals.kicking) + and band_steering_cfg.globals.kicking.kick_mode + local enable_steering = kick_mode and kick_mode ~= 'none' or false + + -- 7. Add interfaces per SSID + for _, ssid_cfg in ipairs(ssid_cfgs) do + repeat + -- Check if this SSID applies to this radio + local applies = false + if is_table(ssid_cfg.radios) then + for _, r in ipairs(ssid_cfg.radios) do + if r == radio_cap.id then + applies = true; break + end + end + end + if not applies then break end + + if ssid_cfg.mainflux_path then + -- Source credentials from filesystem cap; strict: skip on failure + if not fs_configs_cap then + svc:obs_log('warn', { what = 'no_fs_configs_cap', ssid = ssid_cfg.name }) + break + end + local base_cfg = { + segment = ssid_cfg.segment, + encryption = ssid_cfg.encryption or 'none', + password = ssid_cfg.password or '', + mode = map_mode(ssid_cfg.mode or 'access_point'), + } + local ssids, serr = utils.parse_mainflux_ssids(fs_configs_cap, ssid_cfg.mainflux_path, base_cfg) + if not ssids then + svc:obs_log('warn', { what = 'mainflux_ssid_failed', ssid = ssid_cfg.name, err = serr }) + break + end + for _, mssd in ipairs(ssids) do + if type(mssd.segment) ~= 'string' or mssd.segment == '' then + svc:obs_log('warn', { what = 'ssid_missing_segment', ssid = mssd.name or mssd.ssid }) + break + end + local args, err = cap_sdk.args.new.RadioAddInterfaceOpts( + mssd.name or mssd.ssid or '', + mssd.encryption or 'none', + mssd.password or '', + mssd.segment, + map_mode(mssd.mode or 'ap'), + enable_steering) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'add_interface', err = err }) + else + local ok, reply = radio_rpc(radio_cap, radio_id, svc, 'add_interface', args) + if ok then + svc:obs_log('debug', { + what = 'radio_iface_added', + radio = radio_id, + ssid = mssd.name or mssd.ssid, + segment = mssd.segment, + iface = reply and reply.reason or nil, + }) + end + end + end + else + -- Direct SSID from config + if type(ssid_cfg.segment) ~= 'string' or ssid_cfg.segment == '' then + svc:obs_log('warn', { what = 'ssid_missing_segment', ssid = ssid_cfg.name }) + break + end + local args, err = cap_sdk.args.new.RadioAddInterfaceOpts( + ssid_cfg.name or '', + ssid_cfg.encryption or 'none', + ssid_cfg.password or '', + ssid_cfg.segment, + map_mode(ssid_cfg.mode or 'access_point'), + enable_steering) + if not args then + svc:obs_log('warn', { what = 'radio_args_invalid', method = 'add_interface', err = err }) + else + local ok, reply = radio_rpc(radio_cap, radio_id, svc, 'add_interface', args) + if ok then + svc:obs_log('debug', { + what = 'radio_iface_added', + radio = radio_id, + ssid = ssid_cfg.name, + segment = ssid_cfg.segment, + iface = reply and reply.reason or nil, + }) + end + end + end + until true + end + + -- 8. Apply + if not radio_rpc(radio_cap, radio_id, svc, 'apply', {}) then return end + svc:obs_log('info', { + what = 'radio_ready', + summary = string.format('wifi radio %s ready band=%s channel=%s ssids=%d', tostring(radio_id), tostring(radio_cfg.band or '?'), tostring(radio_cfg.channel or '?'), ssids_for_radio(ssid_cfgs, radio_id)), + radio = radio_id, + band = radio_cfg.band, + channel = radio_cfg.channel, + htmode = radio_cfg.htmode, + txpower = radio_cfg.txpower, + ssids = ssids_for_radio(ssid_cfgs, radio_id), + }) end ------------------------------------------------------------------------ @@ -528,114 +528,114 @@ end ---@param user_salt string ---@param svc ServiceBase local function radio_stats_loop(conn, id, radio_cfg, user_salt, svc) - local band = ((radio_cfg and radio_cfg.band) or ''):sub(1, 1) - local hw_platform = '1' - local sessions = {} - local iface_sta = {} - local radio_sta = 0 - - local function on_radio_state(msg) - local key = msg.topic and msg.topic[5] - local p = msg.payload - if not (key and is_table(p)) then return end - - local prefix, stat = key:match('^(iface)_(.+)$') - if prefix then - local idx = iface_idx(p.interface) - conn:retain(t_obs_metric(stat), { - value = p.value, - namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, idx, stat }, - }) - return - end - - prefix, stat = key:match('^(client)_(.+)$') - if prefix then - local uid = gen.userid(p.mac, user_salt) - local sid = sessions[p.mac] - if sid then - conn:retain(t_obs_metric(stat), { - value = p.value, - namespace = { 'wifi', 'clients', uid, 'sessions', sid, stat }, - }) - end - end - end - - local function on_radio_event(msg) - local event_name = msg.topic and msg.topic[5] - if event_name ~= 'client_event' then return end - - local p = msg.payload - if not (is_table(p) and p.mac) then return end - - local mac = p.mac - local iface = p.interface - local connected = p.connected - local timestamp = p.timestamp or os.time() - local uid = gen.userid(mac, user_salt) - local change = connected and 1 or -1 - - iface_sta[iface] = math.max(0, (iface_sta[iface] or 0) + change) - radio_sta = math.max(0, radio_sta + change) - - local idx = iface_idx(iface) - conn:retain(t_obs_metric('num_sta'), { - value = iface_sta[iface], - namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, idx, 'num_sta' }, - }) - conn:retain(t_obs_metric('num_sta'), { - value = radio_sta, - namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, 'num_sta' }, - }) - - if connected then - local sid = gen.gen_session_id() - sessions[mac] = sid - conn:publish(t_obs_event('session_start'), { - value = timestamp, - namespace = { 'wifi', 'clients', uid, 'sessions', sid, 'session_start' }, - }) - else - local sid = sessions[mac] - if sid then - conn:publish(t_obs_event('session_end'), { - value = timestamp, - namespace = { 'wifi', 'clients', uid, 'sessions', sid, 'session_end' }, - }) - sessions[mac] = nil - end - end - end - - local state_sub = conn:subscribe({ 'cap', 'radio', id, 'state', '+' }) - local event_sub = conn:subscribe({ 'cap', 'radio', id, 'event', '+' }) - - fibers.current_scope():finally(function() - state_sub:unsubscribe() - event_sub:unsubscribe() - end) - - while true do - local which, msg = perform(fibers.named_choice({ - state = state_sub:recv_op(), - event = event_sub:recv_op(), - cancel = fibers.current_scope():cancel_op(), - })) - - if which == 'cancel' then break end - - if not msg then - svc:obs_log('debug', { what = 'radio_sub_closed', radio = id }) - break - end - - if which == 'state' then - on_radio_state(msg) - elseif which == 'event' then - on_radio_event(msg) - end - end + local band = ((radio_cfg and radio_cfg.band) or ''):sub(1, 1) + local hw_platform = '1' + local sessions = {} + local iface_sta = {} + local radio_sta = 0 + + local function on_radio_state(msg) + local key = msg.topic and msg.topic[5] + local p = msg.payload + if not (key and is_table(p)) then return end + + local prefix, stat = key:match('^(iface)_(.+)$') + if prefix then + local idx = iface_idx(p.interface) + conn:retain(t_obs_metric(stat), { + value = p.value, + namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, idx, stat }, + }) + return + end + + prefix, stat = key:match('^(client)_(.+)$') + if prefix then + local uid = gen.userid(p.mac, user_salt) + local sid = sessions[p.mac] + if sid then + conn:retain(t_obs_metric(stat), { + value = p.value, + namespace = { 'wifi', 'clients', uid, 'sessions', sid, stat }, + }) + end + end + end + + local function on_radio_event(msg) + local event_name = msg.topic and msg.topic[5] + if event_name ~= 'client_event' then return end + + local p = msg.payload + if not (is_table(p) and p.mac) then return end + + local mac = p.mac + local iface = p.interface + local connected = p.connected + local timestamp = p.timestamp or os.time() + local uid = gen.userid(mac, user_salt) + local change = connected and 1 or -1 + + iface_sta[iface] = math.max(0, (iface_sta[iface] or 0) + change) + radio_sta = math.max(0, radio_sta + change) + + local idx = iface_idx(iface) + conn:retain(t_obs_metric('num_sta'), { + value = iface_sta[iface], + namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, idx, 'num_sta' }, + }) + conn:retain(t_obs_metric('num_sta'), { + value = radio_sta, + namespace = { 'wifi', 'hp', hw_platform, 'rd' .. band, 'num_sta' }, + }) + + if connected then + local sid = gen.gen_session_id() + sessions[mac] = sid + conn:publish(t_obs_event('session_start'), { + value = timestamp, + namespace = { 'wifi', 'clients', uid, 'sessions', sid, 'session_start' }, + }) + else + local sid = sessions[mac] + if sid then + conn:publish(t_obs_event('session_end'), { + value = timestamp, + namespace = { 'wifi', 'clients', uid, 'sessions', sid, 'session_end' }, + }) + sessions[mac] = nil + end + end + end + + local state_sub = conn:subscribe({ 'cap', 'radio', id, 'state', '+' }) + local event_sub = conn:subscribe({ 'cap', 'radio', id, 'event', '+' }) + + fibers.current_scope():finally(function() + state_sub:unsubscribe() + event_sub:unsubscribe() + end) + + while true do + local which, msg = perform(fibers.named_choice({ + state = state_sub:recv_op(), + event = event_sub:recv_op(), + cancel = fibers.current_scope():cancel_op(), + })) + + if which == 'cancel' then break end + + if not msg then + svc:obs_log('debug', { what = 'radio_sub_closed', radio = id }) + break + end + + if which == 'state' then + on_radio_state(msg) + elseif which == 'event' then + on_radio_event(msg) + end + end end ------------------------------------------------------------------------ @@ -647,27 +647,27 @@ end ---@param conn Connection ---@param parent_scope Scope local function run_global_num_sta(conn, parent_scope) - local global_sta = 0 - local global_event_sub = conn:subscribe({ 'cap', 'radio', '+', 'event', 'client_event' }) - - fibers.current_scope():finally(function() - global_event_sub:unsubscribe() - end) - - while true do - local which, msg = perform(fibers.named_choice({ - event = global_event_sub:recv_op(), - cancel = parent_scope:cancel_op(), - })) - - if which == 'cancel' then break end - - if msg and is_table(msg.payload) and msg.payload.mac then - local change = msg.payload.connected and 1 or -1 - global_sta = math.max(0, global_sta + change) - conn:retain(t_obs_metric('num_sta'), { value = global_sta, namespace = { 'wifi', 'num_sta' } }) - end - end + local global_sta = 0 + local global_event_sub = conn:subscribe({ 'cap', 'radio', '+', 'event', 'client_event' }) + + fibers.current_scope():finally(function() + global_event_sub:unsubscribe() + end) + + while true do + local which, msg = perform(fibers.named_choice({ + event = global_event_sub:recv_op(), + cancel = parent_scope:cancel_op(), + })) + + if which == 'cancel' then break end + + if msg and is_table(msg.payload) and msg.payload.mac then + local change = msg.payload.connected and 1 or -1 + global_sta = math.max(0, global_sta + change) + conn:retain(t_obs_metric('num_sta'), { value = global_sta, namespace = { 'wifi', 'num_sta' } }) + end + end end ------------------------------------------------------------------------ @@ -675,64 +675,64 @@ end ------------------------------------------------------------------------ local function get_radio_cfg(ctx, radio_id) - if not is_table(ctx.data) or not is_table(ctx.data.radios) then return nil end - for _, r in ipairs(ctx.data.radios) do - if r.name == radio_id then return r end - end - return nil + if not is_table(ctx.data) or not is_table(ctx.data.radios) then return nil end + for _, r in ipairs(ctx.data.radios) do + if r.name == radio_id then return r end + end + return nil end local function configure_radio(ctx, id) - local radio_cfg = get_radio_cfg(ctx, id) - if not radio_cfg then - ctx.svc:obs_log('debug', { what = 'no_config_for_radio', radio = id }) - return - end - local cap = cap_sdk.new_cap_ref(ctx.conn, 'radio', id) - local ssids = is_table(ctx.data.ssids) and ctx.data.ssids or {} - apply_radio_config(cap, radio_cfg, ssids, ctx.fs_configs_cap, - is_table(ctx.data) and ctx.data.band_steering or nil, ctx.svc) + local radio_cfg = get_radio_cfg(ctx, id) + if not radio_cfg then + ctx.svc:obs_log('debug', { what = 'no_config_for_radio', radio = id }) + return + end + local cap = cap_sdk.new_cap_ref(ctx.conn, 'radio', id) + local ssids = is_table(ctx.data.ssids) and ctx.data.ssids or {} + apply_radio_config(cap, radio_cfg, ssids, ctx.fs_configs_cap, + is_table(ctx.data) and ctx.data.band_steering or nil, ctx.svc) end local function radio_fiber_body(ctx, id) - local user_salt = is_table(ctx.data) and ctx.data.user_salt or nil - if not valid_user_salt(user_salt) then return end + local user_salt = is_table(ctx.data) and ctx.data.user_salt or nil + if not valid_user_salt(user_salt) then return end - configure_radio(ctx, id) - radio_stats_loop(ctx.conn, id, get_radio_cfg(ctx, id), user_salt, ctx.svc) + configure_radio(ctx, id) + radio_stats_loop(ctx.conn, id, get_radio_cfg(ctx, id), user_salt, ctx.svc) end local function spawn_radio_scope(ctx, id) - if ctx.radio_scopes[id] then - ctx.radio_scopes[id]:cancel('reconfigure') - ctx.radio_scopes[id] = nil - end - local scope, serr = ctx.parent_scope:child() - if not scope then - ctx.svc:obs_log('error', { what = 'radio_scope_failed', radio = id, err = serr }) - return - end - ctx.radio_scopes[id] = scope - scope:spawn(function() radio_fiber_body(ctx, id) end) + if ctx.radio_scopes[id] then + ctx.radio_scopes[id]:cancel('reconfigure') + ctx.radio_scopes[id] = nil + end + local scope, serr = ctx.parent_scope:child() + if not scope then + ctx.svc:obs_log('error', { what = 'radio_scope_failed', radio = id, err = serr }) + return + end + ctx.radio_scopes[id] = scope + scope:spawn(function() radio_fiber_body(ctx, id) end) end local function remove_radio(ctx, id) - if ctx.radio_scopes[id] then - ctx.radio_scopes[id]:cancel('radio removed') - ctx.radio_scopes[id] = nil - end + if ctx.radio_scopes[id] then + ctx.radio_scopes[id]:cancel('radio removed') + ctx.radio_scopes[id] = nil + end end local function apply_band_config(ctx) - if ctx.band_cap and is_table(ctx.data) and is_table(ctx.data.band_steering) then - apply_band_steering(ctx.band_cap, ctx.data.band_steering, ctx.svc) - end + if ctx.band_cap and is_table(ctx.data) and is_table(ctx.data.band_steering) then + apply_band_steering(ctx.band_cap, ctx.data.band_steering, ctx.svc) + end end local function reapply_all_radios(ctx) - for id in pairs(ctx.radio_scopes) do - spawn_radio_scope(ctx, id) - end + for id in pairs(ctx.radio_scopes) do + spawn_radio_scope(ctx, id) + end end ------------------------------------------------------------------------ @@ -740,69 +740,69 @@ end ------------------------------------------------------------------------ local function on_cfg(ctx, msg) - local payload = msg.payload - if not is_table(payload) then - ctx.svc:obs_log('warn', { what = 'invalid_config_payload' }) - return - end - local rev = payload.rev - local data = payload.data - if type(rev) == 'number' and rev <= ctx.last_rev then - ctx.svc:obs_log('debug', { what = 'stale_config', rev = rev, last_rev = ctx.last_rev }) - elseif not is_table(data) then - ctx.svc:obs_log('warn', { what = 'config_data_not_table' }) - elseif not valid_user_salt(data.user_salt) then - ctx.svc:obs_log('warn', { what = 'invalid_config_user_salt' }) - else - ctx.last_rev = rev or ctx.last_rev - ctx.data = data - ctx.svc:obs_event('config_applied', { rev = rev }) - ctx.svc:obs_log('info', { - what = 'wifi_config_applied', - summary = string.format('wifi config applied radios=%d ssids=%d', count_array(data.radios), count_array(data.ssids)), - rev = rev, - radios = count_array(data.radios), - ssids = count_array(data.ssids), - }) - reapply_all_radios(ctx) - apply_band_config(ctx) - end + local payload = msg.payload + if not is_table(payload) then + ctx.svc:obs_log('warn', { what = 'invalid_config_payload' }) + return + end + local rev = payload.rev + local data = payload.data + if type(rev) == 'number' and rev <= ctx.last_rev then + ctx.svc:obs_log('debug', { what = 'stale_config', rev = rev, last_rev = ctx.last_rev }) + elseif not is_table(data) then + ctx.svc:obs_log('warn', { what = 'config_data_not_table' }) + elseif not valid_user_salt(data.user_salt) then + ctx.svc:obs_log('warn', { what = 'invalid_config_user_salt' }) + else + ctx.last_rev = rev or ctx.last_rev + ctx.data = data + ctx.svc:obs_event('config_applied', { rev = rev }) + ctx.svc:obs_log('info', { + what = 'wifi_config_applied', + summary = string.format('wifi config applied radios=%d ssids=%d', count_array(data.radios), count_array(data.ssids)), + rev = rev, + radios = count_array(data.radios), + ssids = count_array(data.ssids), + }) + reapply_all_radios(ctx) + apply_band_config(ctx) + end end local function on_radio_cap(ctx, msg) - local id = msg.topic and msg.topic[3] - local state = msg.payload - if state == 'added' then - ctx.svc:obs_log('debug', { what = 'radio_cap_added', radio = id }) - spawn_radio_scope(ctx, id) - elseif state == 'removed' then - ctx.svc:obs_log('debug', { what = 'radio_cap_removed', radio = id }) - remove_radio(ctx, id) - end + local id = msg.topic and msg.topic[3] + local state = msg.payload + if state == 'added' then + ctx.svc:obs_log('debug', { what = 'radio_cap_added', radio = id }) + spawn_radio_scope(ctx, id) + elseif state == 'removed' then + ctx.svc:obs_log('debug', { what = 'radio_cap_removed', radio = id }) + remove_radio(ctx, id) + end end local function on_band_cap(ctx, msg) - local state = msg.payload - if state == 'added' then - ctx.svc:obs_log('debug', { what = 'band_cap_added' }) - ctx.band_cap = cap_sdk.new_cap_ref(ctx.conn, 'band', '1') - apply_band_config(ctx) - elseif state == 'removed' then - ctx.svc:obs_log('debug', { what = 'band_cap_removed' }) - ctx.band_cap = nil - end + local state = msg.payload + if state == 'added' then + ctx.svc:obs_log('debug', { what = 'band_cap_added' }) + ctx.band_cap = cap_sdk.new_cap_ref(ctx.conn, 'band', '1') + apply_band_config(ctx) + elseif state == 'removed' then + ctx.svc:obs_log('debug', { what = 'band_cap_removed' }) + ctx.band_cap = nil + end end local function on_fs_cap(ctx, msg) - local state = msg.payload - if state == 'added' then - ctx.svc:obs_log('debug', { what = 'fs_configs_cap_added' }) - ctx.fs_configs_cap = cap_sdk.new_cap_ref(ctx.conn, 'fs', 'credentials') - reapply_all_radios(ctx) - elseif state == 'removed' then - ctx.svc:obs_log('debug', { what = 'fs_configs_cap_removed' }) - ctx.fs_configs_cap = nil - end + local state = msg.payload + if state == 'added' then + ctx.svc:obs_log('debug', { what = 'fs_configs_cap_added' }) + ctx.fs_configs_cap = cap_sdk.new_cap_ref(ctx.conn, 'fs', 'credentials') + reapply_all_radios(ctx) + elseif state == 'removed' then + ctx.svc:obs_log('debug', { what = 'fs_configs_cap_removed' }) + ctx.fs_configs_cap = nil + end end ------------------------------------------------------------------------ @@ -814,83 +814,83 @@ local WifiService = {} ---@param conn Connection ---@param opts? WifiServiceOpts function WifiService.start(conn, opts) - local svc = base.new(conn, { name = opts and opts.name or 'wifi', env = opts and opts.env }) - - svc:obs_state('boot', { at = svc:wall(), ts = svc:now(), state = 'entered' }) - svc:obs_log('debug', 'service start() entered') - svc:status('starting') - svc:spawn_heartbeat(10, 'tick') - - local parent_scope = fibers.current_scope() - - parent_scope:finally(function() - local scope = fibers.current_scope() - local st, primary = scope:status() - if st == 'failed' then - svc:obs_log('error', { what = 'scope_failed', err = tostring(primary) }) - end - svc:status('stopped', primary and { reason = tostring(primary) } or nil) - svc:obs_log('debug', 'service stopped') - end) - - local ctx = { - conn = conn, - svc = svc, - parent_scope = parent_scope, - data = nil, -- data field of last valid config - last_rev = -1, - fs_configs_cap = nil, -- configs filesystem cap reference - band_cap = nil, -- band capability reference - radio_scopes = {}, -- radio_id → child scope - } - - -- Subscribe to config topic - local cfg_sub = conn:subscribe({ 'cfg', 'wifi' }) - - -- Subscribe to cap state notifications (radio, band, fs/configs) - local radio_cap_listener = cap_sdk.new_cap_listener(conn, 'radio', '+') - local band_cap_listener = cap_sdk.new_cap_listener(conn, 'band', '1') - local fs_cap_listener = cap_sdk.new_cap_listener(conn, 'fs', 'credentials') - - svc:status('running') - svc:obs_log('debug', 'service running') - - parent_scope:spawn(function() run_global_num_sta(conn, parent_scope) end) - - while true do - local which, msg = perform(fibers.named_choice({ - cfg = cfg_sub:recv_op(), - radio = radio_cap_listener.sub:recv_op(), - band = band_cap_listener.sub:recv_op(), - fs = fs_cap_listener.sub:recv_op(), - cancel = parent_scope:cancel_op(), - })) - - if which == 'cancel' then break end - - if not msg then - svc:obs_log('debug', { what = 'subscription_closed', source = which }) - elseif which == 'cfg' then - on_cfg(ctx, msg) - elseif which == 'radio' then - on_radio_cap(ctx, msg) - elseif which == 'band' then - on_band_cap(ctx, msg) - elseif which == 'fs' then - on_fs_cap(ctx, msg) - end - end - - -- Cleanup all radio scopes on exit - for id, scope in pairs(ctx.radio_scopes) do - scope:cancel('service stopping') - ctx.radio_scopes[id] = nil - end - - cfg_sub:unsubscribe() - radio_cap_listener:close() - band_cap_listener:close() - fs_cap_listener:close() + local svc = base.new(conn, { name = opts and opts.name or 'wifi', env = opts and opts.env }) + + svc:obs_state('boot', { at = svc:wall(), ts = svc:now(), state = 'entered' }) + svc:obs_log('debug', 'service start() entered') + svc:status('starting') + svc:spawn_heartbeat(10, 'tick') + + local parent_scope = fibers.current_scope() + + parent_scope:finally(function() + local scope = fibers.current_scope() + local st, primary = scope:status() + if st == 'failed' then + svc:obs_log('error', { what = 'scope_failed', err = tostring(primary) }) + end + svc:status('stopped', primary and { reason = tostring(primary) } or nil) + svc:obs_log('debug', 'service stopped') + end) + + local ctx = { + conn = conn, + svc = svc, + parent_scope = parent_scope, + data = nil, -- data field of last valid config + last_rev = -1, + fs_configs_cap = nil, -- configs filesystem cap reference + band_cap = nil, -- band capability reference + radio_scopes = {}, -- radio_id → child scope + } + + -- Subscribe to config topic + local cfg_sub = conn:subscribe({ 'cfg', 'wifi' }) + + -- Subscribe to cap state notifications (radio, band, fs/configs) + local radio_cap_listener = cap_sdk.new_cap_listener(conn, 'radio', '+') + local band_cap_listener = cap_sdk.new_cap_listener(conn, 'band', '1') + local fs_cap_listener = cap_sdk.new_cap_listener(conn, 'fs', 'credentials') + + svc:status('running') + svc:obs_log('debug', 'service running') + + parent_scope:spawn(function() run_global_num_sta(conn, parent_scope) end) + + while true do + local which, msg = perform(fibers.named_choice({ + cfg = cfg_sub:recv_op(), + radio = radio_cap_listener.sub:recv_op(), + band = band_cap_listener.sub:recv_op(), + fs = fs_cap_listener.sub:recv_op(), + cancel = parent_scope:cancel_op(), + })) + + if which == 'cancel' then break end + + if not msg then + svc:obs_log('debug', { what = 'subscription_closed', source = which }) + elseif which == 'cfg' then + on_cfg(ctx, msg) + elseif which == 'radio' then + on_radio_cap(ctx, msg) + elseif which == 'band' then + on_band_cap(ctx, msg) + elseif which == 'fs' then + on_fs_cap(ctx, msg) + end + end + + -- Cleanup all radio scopes on exit + for id, scope in pairs(ctx.radio_scopes) do + scope:cancel('service stopping') + ctx.radio_scopes[id] = nil + end + + cfg_sub:unsubscribe() + radio_cap_listener:close() + band_cap_listener:close() + fs_cap_listener:close() end return WifiService diff --git a/src/services/wifi/gen.lua b/src/services/wifi/gen.lua index 1b8fd467..7353d60f 100644 --- a/src/services/wifi/gen.lua +++ b/src/services/wifi/gen.lua @@ -2,34 +2,34 @@ local digest = require "openssl.digest" local string = require "string" function string.tohex(str) - return (str:gsub('.', function (c) - return string.format('%02X', string.byte(c)) - end)) + return (str:gsub('.', function (c) + return string.format('%02X', string.byte(c)) + end)) end if digest.digest == nil then - function digest.digest(algo, str) - return digest.new(algo):final(str):tohex():lower() - end + function digest.digest(algo, str) + return digest.new(algo):final(str):tohex():lower() + end end local USER_ID_LEN = 6 local function userid(mac, user_salt) - local hash = digest.digest("sha256", user_salt..mac) - return string.sub(hash, 1, USER_ID_LEN) + local hash = digest.digest("sha256", user_salt..mac) + return string.sub(hash, 1, USER_ID_LEN) end local function gen_session_id() - local random = math.random - local template = 'xxxxxxxx_xxxx_4xxx_yxxx_xxxxxxxxxxxx' - return string.gsub(template, '[xy]', function (char) - local value = (char == 'x') and random(0, 0xf) or random(8, 0xb) - return string.format('%x', value) - end) + local random = math.random + local template = 'xxxxxxxx_xxxx_4xxx_yxxx_xxxxxxxxxxxx' + return string.gsub(template, '[xy]', function (char) + local value = (char == 'x') and random(0, 0xf) or random(8, 0xb) + return string.format('%x', value) + end) end return { - userid = userid, - gen_session_id = gen_session_id, + userid = userid, + gen_session_id = gen_session_id, } diff --git a/src/services/wifi/utils.lua b/src/services/wifi/utils.lua index 36be7d3b..a3397a87 100644 --- a/src/services/wifi/utils.lua +++ b/src/services/wifi/utils.lua @@ -2,8 +2,8 @@ local json = require "cjson.safe" local cap_args = require "services.hal.types.capability_args" local mainflux_to_ssid_keys = { - name = "segment", - ssid = "name", + name = "segment", + ssid = "name", } ---Read mainflux SSID credentials from the configs filesystem capability @@ -14,62 +14,62 @@ local mainflux_to_ssid_keys = { ---@return table? ssids Array of SSID config tables, or nil on any error ---@return string err Error message, or "" on success local function parse_mainflux_ssids(fs_cap, mainflux_path, base_ssid_cfg) - local opts, opts_err = cap_args.new.FilesystemReadOpts(mainflux_path) - if not opts then - return nil, tostring(opts_err) - end + local opts, opts_err = cap_args.new.FilesystemReadOpts(mainflux_path) + if not opts then + return nil, tostring(opts_err) + end - local reply, call_err = fs_cap:call_control('read', opts) - if not reply then - return nil, tostring(call_err) - end - if reply.ok ~= true then - return nil, tostring(reply.reason or "filesystem read failed") - end + local reply, call_err = fs_cap:call_control('read', opts) + if not reply then + return nil, tostring(call_err) + end + if reply.ok ~= true then + return nil, tostring(reply.reason or "filesystem read failed") + end - local outer, derr = json.decode(reply.reason or '') - if not outer then - return nil, tostring(derr) - end + local outer, derr = json.decode(reply.reason or '') + if not outer then + return nil, tostring(derr) + end - -- The mainflux file wraps the actual config as a JSON-encoded string in `content` - local content_parsed - if type(outer.content) == 'string' then - local inner_err - content_parsed, inner_err = json.decode(outer.content) - if not content_parsed then - return nil, "failed to decode mainflux content field: " .. tostring(inner_err) - end - else - content_parsed = outer - end + -- The mainflux file wraps the actual config as a JSON-encoded string in `content` + local content_parsed + if type(outer.content) == 'string' then + local inner_err + content_parsed, inner_err = json.decode(outer.content) + if not content_parsed then + return nil, "failed to decode mainflux content field: " .. tostring(inner_err) + end + else + content_parsed = outer + end - local ssid_cfgs = content_parsed.networks - and content_parsed.networks.networks - or nil - if not ssid_cfgs then - return nil, "no ssid configs found in mainflux file" - end + local ssid_cfgs = content_parsed.networks + and content_parsed.networks.networks + or nil + if not ssid_cfgs then + return nil, "no ssid configs found in mainflux file" + end - local ssids = {} - for _, ssid_cfg in ipairs(ssid_cfgs) do - local ssid = {} - for k, v in pairs(base_ssid_cfg) do - ssid[k] = v - end - for k, v in pairs(ssid_cfg) do - local key = mainflux_to_ssid_keys[k] or k - -- Temporary workaround: mainflux uses "jng" for what we call "adm" - if key == "segment" and v == "jng" then - v = "adm" - end - ssid[key] = v - end - table.insert(ssids, ssid) - end - return ssids, "" + local ssids = {} + for _, ssid_cfg in ipairs(ssid_cfgs) do + local ssid = {} + for k, v in pairs(base_ssid_cfg) do + ssid[k] = v + end + for k, v in pairs(ssid_cfg) do + local key = mainflux_to_ssid_keys[k] or k + -- Temporary workaround: mainflux uses "jng" for what we call "adm" + if key == "segment" and v == "jng" then + v = "adm" + end + ssid[key] = v + end + table.insert(ssids, ssid) + end + return ssids, "" end return { - parse_mainflux_ssids = parse_mainflux_ssids, + parse_mainflux_ssids = parse_mainflux_ssids, } diff --git a/src/shared/binser.lua b/src/shared/binser.lua index 29316d14..17eeeb33 100644 --- a/src/shared/binser.lua +++ b/src/shared/binser.lua @@ -44,33 +44,33 @@ local unpack = unpack or table.unpack -- Lua 5.3 frexp polyfill -- From https://github.com/excessive/cpml/blob/master/modules/utils.lua if not frexp then - local log, abs, lfloor = math.log, math.abs, math.floor - local log2 = log(2) - frexp = function(x) - if x == 0 then return 0, 0 end - local e = lfloor(log(abs(x)) / log2 + 1) - return x / 2 ^ e, e - end + local log, abs, lfloor = math.log, math.abs, math.floor + local log2 = log(2) + frexp = function(x) + if x == 0 then return 0, 0 end + local e = lfloor(log(abs(x)) / log2 + 1) + return x / 2 ^ e, e + end end local function pack(...) - return {...}, select("#", ...) + return {...}, select("#", ...) end local function not_array_index(x, len) - return type(x) ~= "number" or x < 1 or x > len or x ~= floor(x) + return type(x) ~= "number" or x < 1 or x > len or x ~= floor(x) end local function type_check(x, tp, name) - assert(type(x) == tp, - format("Expected parameter %q to be of type %q.", name, tp)) + assert(type(x) == tp, + format("Expected parameter %q to be of type %q.", name, tp)) end local bigIntSupport = false local isInteger if math.type then -- Detect Lua 5.3 - local mtype = math.type - bigIntSupport = loadstring[[ + local mtype = math.type + bigIntSupport = loadstring[[ local char = string.char return function(n) local nn = n < 0 and -(n + 1) or n @@ -88,665 +88,665 @@ if math.type then -- Detect Lua 5.3 end return char(212, b1, b2, b3, b4, b5, b6, b7, b8) end]]() - isInteger = function(x) - return mtype(x) == 'integer' - end + isInteger = function(x) + return mtype(x) == 'integer' + end else - isInteger = function(x) - return floor(x) == x - end + isInteger = function(x) + return floor(x) == x + end end -- Copyright (C) 2012-2015 Francois Perrad. -- number serialization code modified from https://github.com/fperrad/lua-MessagePack -- Encode a number as a big-endian ieee-754 double, big-endian signed 64 bit integer, or a small integer local function number_to_str(n) - if isInteger(n) then -- int - if n <= 100 and n >= -27 then -- 1 byte, 7 bits of data - return char(n + 27) - elseif n <= 8191 and n >= -8192 then -- 2 bytes, 14 bits of data - n = n + 8192 - return char(128 + (floor(n / 0x100) % 0x100), n % 0x100) - elseif bigIntSupport then - return bigIntSupport(n) - end - end - local sign = 0 - if n < 0.0 then - sign = 0x80 - n = -n - end - local m, e = frexp(n) -- mantissa, exponent - if m ~= m then - return char(203, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - elseif m == 1/0 then - if sign == 0 then - return char(203, 0x7F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - else - return char(203, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - end - end - e = e + 0x3FE - if e < 1 then -- denormalized numbers - m = m * 2 ^ (52 + e) - e = 0 - else - m = (m * 2 - 1) * 2 ^ 52 - end - return char(203, - sign + floor(e / 0x10), - (e % 0x10) * 0x10 + floor(m / 0x1000000000000), - floor(m / 0x10000000000) % 0x100, - floor(m / 0x100000000) % 0x100, - floor(m / 0x1000000) % 0x100, - floor(m / 0x10000) % 0x100, - floor(m / 0x100) % 0x100, - m % 0x100) + if isInteger(n) then -- int + if n <= 100 and n >= -27 then -- 1 byte, 7 bits of data + return char(n + 27) + elseif n <= 8191 and n >= -8192 then -- 2 bytes, 14 bits of data + n = n + 8192 + return char(128 + (floor(n / 0x100) % 0x100), n % 0x100) + elseif bigIntSupport then + return bigIntSupport(n) + end + end + local sign = 0 + if n < 0.0 then + sign = 0x80 + n = -n + end + local m, e = frexp(n) -- mantissa, exponent + if m ~= m then + return char(203, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + elseif m == 1/0 then + if sign == 0 then + return char(203, 0x7F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + else + return char(203, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + end + end + e = e + 0x3FE + if e < 1 then -- denormalized numbers + m = m * 2 ^ (52 + e) + e = 0 + else + m = (m * 2 - 1) * 2 ^ 52 + end + return char(203, + sign + floor(e / 0x10), + (e % 0x10) * 0x10 + floor(m / 0x1000000000000), + floor(m / 0x10000000000) % 0x100, + floor(m / 0x100000000) % 0x100, + floor(m / 0x1000000) % 0x100, + floor(m / 0x10000) % 0x100, + floor(m / 0x100) % 0x100, + m % 0x100) end -- Copyright (C) 2012-2015 Francois Perrad. -- number deserialization code also modified from https://github.com/fperrad/lua-MessagePack local function number_from_str(str, index) - local b = byte(str, index) - if not b then error("Expected more bytes of input.") end - if b < 128 then - return b - 27, index + 1 - elseif b < 192 then - local b2 = byte(str, index + 1) - if not b2 then error("Expected more bytes of input.") end - return b2 + 0x100 * (b - 128) - 8192, index + 2 - end - local b1, b2, b3, b4, b5, b6, b7, b8 = byte(str, index + 1, index + 8) - if (not b1) or (not b2) or (not b3) or (not b4) or - (not b5) or (not b6) or (not b7) or (not b8) then - error("Expected more bytes of input.") - end - if b == 212 then - local flip = b1 >= 128 - if flip then -- negative - b1, b2, b3, b4 = 0xFF - b1, 0xFF - b2, 0xFF - b3, 0xFF - b4 - b5, b6, b7, b8 = 0xFF - b5, 0xFF - b6, 0xFF - b7, 0xFF - b8 - end - local n = ((((((b1 * 0x100 + b2) * 0x100 + b3) * 0x100 + b4) * - 0x100 + b5) * 0x100 + b6) * 0x100 + b7) * 0x100 + b8 - if flip then - return (-n) - 1, index + 9 - else - return n, index + 9 - end - end - if b ~= 203 then - error("Expected number") - end - local sign = b1 > 0x7F and -1 or 1 - local e = (b1 % 0x80) * 0x10 + floor(b2 / 0x10) - local m = ((((((b2 % 0x10) * 0x100 + b3) * 0x100 + b4) * 0x100 + b5) * 0x100 + b6) * 0x100 + b7) * 0x100 + b8 - local n - if e == 0 then - if m == 0 then - n = sign * 0.0 - else - n = sign * (m / 2 ^ 52) * 2 ^ -1022 - end - elseif e == 0x7FF then - if m == 0 then - n = sign * (1/0) - else - n = 0.0/0.0 - end - else - n = sign * (1.0 + m / 2 ^ 52) * 2 ^ (e - 0x3FF) - end - return n, index + 9 + local b = byte(str, index) + if not b then error("Expected more bytes of input.") end + if b < 128 then + return b - 27, index + 1 + elseif b < 192 then + local b2 = byte(str, index + 1) + if not b2 then error("Expected more bytes of input.") end + return b2 + 0x100 * (b - 128) - 8192, index + 2 + end + local b1, b2, b3, b4, b5, b6, b7, b8 = byte(str, index + 1, index + 8) + if (not b1) or (not b2) or (not b3) or (not b4) or + (not b5) or (not b6) or (not b7) or (not b8) then + error("Expected more bytes of input.") + end + if b == 212 then + local flip = b1 >= 128 + if flip then -- negative + b1, b2, b3, b4 = 0xFF - b1, 0xFF - b2, 0xFF - b3, 0xFF - b4 + b5, b6, b7, b8 = 0xFF - b5, 0xFF - b6, 0xFF - b7, 0xFF - b8 + end + local n = ((((((b1 * 0x100 + b2) * 0x100 + b3) * 0x100 + b4) * + 0x100 + b5) * 0x100 + b6) * 0x100 + b7) * 0x100 + b8 + if flip then + return (-n) - 1, index + 9 + else + return n, index + 9 + end + end + if b ~= 203 then + error("Expected number") + end + local sign = b1 > 0x7F and -1 or 1 + local e = (b1 % 0x80) * 0x10 + floor(b2 / 0x10) + local m = ((((((b2 % 0x10) * 0x100 + b3) * 0x100 + b4) * 0x100 + b5) * 0x100 + b6) * 0x100 + b7) * 0x100 + b8 + local n + if e == 0 then + if m == 0 then + n = sign * 0.0 + else + n = sign * (m / 2 ^ 52) * 2 ^ -1022 + end + elseif e == 0x7FF then + if m == 0 then + n = sign * (1/0) + else + n = 0.0/0.0 + end + else + n = sign * (1.0 + m / 2 ^ 52) * 2 ^ (e - 0x3FF) + end + return n, index + 9 end local function newbinser() - -- unique table key for getting next value - local NEXT = {} - local CTORSTACK = {} - - -- NIL = 202 - -- FLOAT = 203 - -- TRUE = 204 - -- FALSE = 205 - -- STRING = 206 - -- TABLE = 207 - -- REFERENCE = 208 - -- CONSTRUCTOR = 209 - -- FUNCTION = 210 - -- RESOURCE = 211 - -- INT64 = 212 - -- TABLE WITH META = 213 - - local mts = {} - local ids = {} - local serializers = {} - local deserializers = {} - local resources = {} - local resources_by_name = {} - local types = {} - - types["nil"] = function(_, _, accum) - accum[#accum + 1] = "\202" - end - - function types.number(x, _, accum) - accum[#accum + 1] = number_to_str(x) - end - - function types.boolean(x, _, accum) - accum[#accum + 1] = x and "\204" or "\205" - end - - function types.string(x, visited, accum) - local alen = #accum - if visited[x] then - accum[alen + 1] = "\208" - accum[alen + 2] = number_to_str(visited[x]) - else - visited[x] = visited[NEXT] - visited[NEXT] = visited[NEXT] + 1 - accum[alen + 1] = "\206" - accum[alen + 2] = number_to_str(#x) - accum[alen + 3] = x - end - end - - local function check_custom_type(x, visited, accum) - local res = resources[x] - if res then - accum[#accum + 1] = "\211" - types[type(res)](res, visited, accum) - return true - end - local mt = getmetatable(x) - local id = mt and ids[mt] - if id then - local constructing = visited[CTORSTACK] - if constructing[x] then - error("Infinite loop in constructor.") - end - constructing[x] = true - accum[#accum + 1] = "\209" - types[type(id)](id, visited, accum) - local args, len = pack(serializers[id](x)) - accum[#accum + 1] = number_to_str(len) - for i = 1, len do - local arg = args[i] - types[type(arg)](arg, visited, accum) - end - visited[x] = visited[NEXT] - visited[NEXT] = visited[NEXT] + 1 - -- We finished constructing - constructing[x] = nil - return true - end - end - - function types.userdata(x, visited, accum) - if visited[x] then - accum[#accum + 1] = "\208" - accum[#accum + 1] = number_to_str(visited[x]) - else - if check_custom_type(x, visited, accum) then return end - error("Cannot serialize this userdata.") - end - end - - function types.table(x, visited, accum) - if visited[x] then - accum[#accum + 1] = "\208" - accum[#accum + 1] = number_to_str(visited[x]) - else - if check_custom_type(x, visited, accum) then return end - visited[x] = visited[NEXT] - visited[NEXT] = visited[NEXT] + 1 - local xlen = #x - local mt = getmetatable(x) - if mt then - accum[#accum + 1] = "\213" - types.table(mt, visited, accum) - else - accum[#accum + 1] = "\207" - end - accum[#accum + 1] = number_to_str(xlen) - for i = 1, xlen do - local v = x[i] - types[type(v)](v, visited, accum) - end - local key_count = 0 - for k in pairs(x) do - if not_array_index(k, xlen) then - key_count = key_count + 1 - end - end - accum[#accum + 1] = number_to_str(key_count) - for k, v in pairs(x) do - if not_array_index(k, xlen) then - types[type(k)](k, visited, accum) - types[type(v)](v, visited, accum) - end - end - end - end - - types["function"] = function(x, visited, accum) - if visited[x] then - accum[#accum + 1] = "\208" - accum[#accum + 1] = number_to_str(visited[x]) - else - if check_custom_type(x, visited, accum) then return end - visited[x] = visited[NEXT] - visited[NEXT] = visited[NEXT] + 1 - local str = dump(x) - accum[#accum + 1] = "\210" - accum[#accum + 1] = number_to_str(#str) - accum[#accum + 1] = str - end - end - - types.cdata = function(x, visited, accum) - if visited[x] then - accum[#accum + 1] = "\208" - accum[#accum + 1] = number_to_str(visited[x]) - else - if check_custom_type(x, visited, #accum) then return end - error("Cannot serialize this cdata.") - end - end - - types.thread = function() error("Cannot serialize threads.") end - - local function deserialize_value(str, index, visited) - local t = byte(str, index) - if not t then return nil, index end - if t < 128 then - return t - 27, index + 1 - elseif t < 192 then - local b2 = byte(str, index + 1) - if not b2 then error("Expected more bytes of input.") end - return b2 + 0x100 * (t - 128) - 8192, index + 2 - elseif t == 202 then - return nil, index + 1 - elseif t == 203 or t == 212 then - return number_from_str(str, index) - elseif t == 204 then - return true, index + 1 - elseif t == 205 then - return false, index + 1 - elseif t == 206 then - local length, dataindex = number_from_str(str, index + 1) - local nextindex = dataindex + length - if length < 0 then error("Bad string length") end - if #str < nextindex - 1 then error("Expected more bytes of string") end - local substr = sub(str, dataindex, nextindex - 1) - visited[#visited + 1] = substr - return substr, nextindex - elseif t == 207 or t == 213 then - local mt, count, nextindex - local ret = {} - visited[#visited + 1] = ret - nextindex = index + 1 - if t == 213 then - mt, nextindex = deserialize_value(str, nextindex, visited) - if type(mt) ~= "table" then error("Expected table metatable") end - end - count, nextindex = number_from_str(str, nextindex) - for i = 1, count do - local oldindex = nextindex - ret[i], nextindex = deserialize_value(str, nextindex, visited) - if nextindex == oldindex then error("Expected more bytes of input.") end - end - count, nextindex = number_from_str(str, nextindex) - for _ = 1, count do - local k, v - local oldindex = nextindex - k, nextindex = deserialize_value(str, nextindex, visited) - if nextindex == oldindex then error("Expected more bytes of input.") end - oldindex = nextindex - v, nextindex = deserialize_value(str, nextindex, visited) - if nextindex == oldindex then error("Expected more bytes of input.") end - if k == nil then error("Can't have nil table keys") end - ret[k] = v - end - if mt then setmetatable(ret, mt) end - return ret, nextindex - elseif t == 208 then - local ref, nextindex = number_from_str(str, index + 1) - return visited[ref], nextindex - elseif t == 209 then - local count - local name, nextindex = deserialize_value(str, index + 1, visited) - count, nextindex = number_from_str(str, nextindex) - local args = {} - for i = 1, count do - local oldindex = nextindex - args[i], nextindex = deserialize_value(str, nextindex, visited) - if nextindex == oldindex then error("Expected more bytes of input.") end - end - if not name or not deserializers[name] then - error(("Cannot deserialize class '%s'"):format(tostring(name))) - end - local ret = deserializers[name](unpack(args)) - visited[#visited + 1] = ret - return ret, nextindex - elseif t == 210 then - local length, dataindex = number_from_str(str, index + 1) - local nextindex = dataindex + length - if length < 0 then error("Bad string length") end - if #str < nextindex - 1 then error("Expected more bytes of string") end - local ret = loadstring(sub(str, dataindex, nextindex - 1)) - visited[#visited + 1] = ret - return ret, nextindex - elseif t == 211 then - local resname, nextindex = deserialize_value(str, index + 1, visited) - if resname == nil then error("Got nil resource name") end - local res = resources_by_name[resname] - if res == nil then - error(("No resources found for name '%s'"):format(tostring(resname))) - end - return res, nextindex - else - error("Could not deserialize type byte " .. t .. ".") - end - end - - local function serialize(...) - local visited = {[NEXT] = 1, [CTORSTACK] = {}} - local accum = {} - for i = 1, select("#", ...) do - local x = select(i, ...) - types[type(x)](x, visited, accum) - end - return concat(accum) - end - - local function make_file_writer(file) - return setmetatable({}, { - __newindex = function(_, _, v) - file:write(v) - end - }) - end - - local function serialize_to_file(path, mode, ...) - local file, err = io.open(path, mode) - assert(file, err) - local visited = {[NEXT] = 1, [CTORSTACK] = {}} - local accum = make_file_writer(file) - for i = 1, select("#", ...) do - local x = select(i, ...) - types[type(x)](x, visited, accum) - end - -- flush the writer - file:flush() - file:close() - end - - local function writeFile(path, ...) - return serialize_to_file(path, "wb", ...) - end - - local function appendFile(path, ...) - return serialize_to_file(path, "ab", ...) - end - - local function deserialize(str, index) - assert(type(str) == "string", "Expected string to deserialize.") - local vals = {} - index = index or 1 - local visited = {} - local len = 0 - local val - while true do - local nextindex - val, nextindex = deserialize_value(str, index, visited) - if nextindex > index then - len = len + 1 - vals[len] = val - index = nextindex - else - break - end - end - return vals, len - end - - local function deserializeN(str, n, index) - assert(type(str) == "string", "Expected string to deserialize.") - n = n or 1 - assert(type(n) == "number", "Expected a number for parameter n.") - assert(n > 0 and floor(n) == n, "N must be a poitive integer.") - local vals = {} - index = index or 1 - local visited = {} - local len = 0 - local val - while len < n do - local nextindex - val, nextindex = deserialize_value(str, index, visited) - if nextindex > index then - len = len + 1 - vals[len] = val - index = nextindex - else - break - end - end - vals[len + 1] = index - return unpack(vals, 1, n + 1) - end - - local function readFile(path) - local file, err = fiber_file.open(path, "rb") - assert(file, err) - local file_chars = file:read("*a") - file:close() - return deserialize(file_chars) - end - - -- Resources - - local function registerResource(resource, name) - type_check(name, "string", "name") - assert(not resources[resource], - "Resource already registered.") - assert(not resources_by_name[name], - format("Resource %q already exists.", name)) - resources_by_name[name] = resource - resources[resource] = name - return resource - end - - local function unregisterResource(name) - type_check(name, "string", "name") - assert(resources_by_name[name], format("Resource %q does not exist.", name)) - local resource = resources_by_name[name] - resources_by_name[name] = nil - resources[resource] = nil - return resource - end - - -- Templating - - local function normalize_template(template) - local ret = {} - for i = 1, #template do - ret[i] = template[i] - end - local non_array_part = {} - -- The non-array part of the template (nested templates) have to be deterministic, so they are sorted. - -- This means that inherently non deterministicly sortable keys (tables, functions) should NOT be used - -- in templates. Looking for way around this. - for k in pairs(template) do - if not_array_index(k, #template) then - non_array_part[#non_array_part + 1] = k - end - end - table.sort(non_array_part) - for i = 1, #non_array_part do - local name = non_array_part[i] - ret[#ret + 1] = {name, normalize_template(template[name])} - end - return ret - end - - local function templatepart_serialize(part, argaccum, x, len) - local extras = {} - local extracount = 0 - for k, v in pairs(x) do - extras[k] = v - extracount = extracount + 1 - end - for i = 1, #part do - local name - if type(part[i]) == "table" then - name = part[i][1] - len = templatepart_serialize(part[i][2], argaccum, x[name], len) - else - name = part[i] - len = len + 1 - argaccum[len] = x[part[i]] - end - if extras[name] ~= nil then - extracount = extracount - 1 - extras[name] = nil - end - end - if extracount > 0 then - argaccum[len + 1] = extras - else - argaccum[len + 1] = nil - end - return len + 1 - end - - local function templatepart_deserialize(ret, part, values, vindex) - for i = 1, #part do - local name = part[i] - if type(name) == "table" then - local newret = {} - ret[name[1]] = newret - vindex = templatepart_deserialize(newret, name[2], values, vindex) - else - ret[name] = values[vindex] - vindex = vindex + 1 - end - end - local extras = values[vindex] - if extras then - for k, v in pairs(extras) do - ret[k] = v - end - end - return vindex + 1 - end - - local function template_serializer_and_deserializer(metatable, template) - return function(x) - local argaccum = {} - local len = templatepart_serialize(template, argaccum, x, 0) - return unpack(argaccum, 1, len) - end, function(...) - local ret = {} - local args = {...} - templatepart_deserialize(ret, template, args, 1) - return setmetatable(ret, metatable) - end - end - - -- Used to serialize classes withh custom serializers and deserializers. - -- If no _serialize or _deserialize (or no _template) value is found in the - -- metatable, then the metatable is registered as a resources. - local function register(metatable, name, serialize, deserialize) - if type(metatable) == "table" then - name = name or metatable.name - serialize = serialize or metatable._serialize - deserialize = deserialize or metatable._deserialize - if (not serialize) or (not deserialize) then - if metatable._template then - -- Register as template - local t = normalize_template(metatable._template) - serialize, deserialize = template_serializer_and_deserializer(metatable, t) - else - -- Register the metatable as a resource. This is semantically - -- similar and more flexible (handles cycles). - registerResource(metatable, name) - return - end - end - elseif type(metatable) == "string" then - name = name or metatable - end - type_check(name, "string", "name") - type_check(serialize, "function", "serialize") - type_check(deserialize, "function", "deserialize") - assert((not ids[metatable]) and (not resources[metatable]), - "Metatable already registered.") - assert((not mts[name]) and (not resources_by_name[name]), - ("Name %q already registered."):format(name)) - mts[name] = metatable - ids[metatable] = name - serializers[name] = serialize - deserializers[name] = deserialize - return metatable - end - - local function unregister(item) - local name, metatable - if type(item) == "string" then -- assume name - name, metatable = item, mts[item] - else -- assume metatable - name, metatable = ids[item], item - end - type_check(name, "string", "name") - mts[name] = nil - if (metatable) then - resources[metatable] = nil - ids[metatable] = nil - end - serializers[name] = nil - deserializers[name] = nil - resources_by_name[name] = nil; - return metatable - end - - local function registerClass(class, name) - name = name or class.name - if class.__instanceDict then -- middleclass - register(class.__instanceDict, name) - else -- assume 30log or similar library - register(class, name) - end - return class - end - - return { - VERSION = "0.0-8", - -- aliases - s = serialize, - d = deserialize, - dn = deserializeN, - r = readFile, - w = writeFile, - a = appendFile, - - serialize = serialize, - deserialize = deserialize, - deserializeN = deserializeN, - readFile = readFile, - writeFile = writeFile, - appendFile = appendFile, - register = register, - unregister = unregister, - registerResource = registerResource, - unregisterResource = unregisterResource, - registerClass = registerClass, - - newbinser = newbinser - } + -- unique table key for getting next value + local NEXT = {} + local CTORSTACK = {} + + -- NIL = 202 + -- FLOAT = 203 + -- TRUE = 204 + -- FALSE = 205 + -- STRING = 206 + -- TABLE = 207 + -- REFERENCE = 208 + -- CONSTRUCTOR = 209 + -- FUNCTION = 210 + -- RESOURCE = 211 + -- INT64 = 212 + -- TABLE WITH META = 213 + + local mts = {} + local ids = {} + local serializers = {} + local deserializers = {} + local resources = {} + local resources_by_name = {} + local types = {} + + types["nil"] = function(_, _, accum) + accum[#accum + 1] = "\202" + end + + function types.number(x, _, accum) + accum[#accum + 1] = number_to_str(x) + end + + function types.boolean(x, _, accum) + accum[#accum + 1] = x and "\204" or "\205" + end + + function types.string(x, visited, accum) + local alen = #accum + if visited[x] then + accum[alen + 1] = "\208" + accum[alen + 2] = number_to_str(visited[x]) + else + visited[x] = visited[NEXT] + visited[NEXT] = visited[NEXT] + 1 + accum[alen + 1] = "\206" + accum[alen + 2] = number_to_str(#x) + accum[alen + 3] = x + end + end + + local function check_custom_type(x, visited, accum) + local res = resources[x] + if res then + accum[#accum + 1] = "\211" + types[type(res)](res, visited, accum) + return true + end + local mt = getmetatable(x) + local id = mt and ids[mt] + if id then + local constructing = visited[CTORSTACK] + if constructing[x] then + error("Infinite loop in constructor.") + end + constructing[x] = true + accum[#accum + 1] = "\209" + types[type(id)](id, visited, accum) + local args, len = pack(serializers[id](x)) + accum[#accum + 1] = number_to_str(len) + for i = 1, len do + local arg = args[i] + types[type(arg)](arg, visited, accum) + end + visited[x] = visited[NEXT] + visited[NEXT] = visited[NEXT] + 1 + -- We finished constructing + constructing[x] = nil + return true + end + end + + function types.userdata(x, visited, accum) + if visited[x] then + accum[#accum + 1] = "\208" + accum[#accum + 1] = number_to_str(visited[x]) + else + if check_custom_type(x, visited, accum) then return end + error("Cannot serialize this userdata.") + end + end + + function types.table(x, visited, accum) + if visited[x] then + accum[#accum + 1] = "\208" + accum[#accum + 1] = number_to_str(visited[x]) + else + if check_custom_type(x, visited, accum) then return end + visited[x] = visited[NEXT] + visited[NEXT] = visited[NEXT] + 1 + local xlen = #x + local mt = getmetatable(x) + if mt then + accum[#accum + 1] = "\213" + types.table(mt, visited, accum) + else + accum[#accum + 1] = "\207" + end + accum[#accum + 1] = number_to_str(xlen) + for i = 1, xlen do + local v = x[i] + types[type(v)](v, visited, accum) + end + local key_count = 0 + for k in pairs(x) do + if not_array_index(k, xlen) then + key_count = key_count + 1 + end + end + accum[#accum + 1] = number_to_str(key_count) + for k, v in pairs(x) do + if not_array_index(k, xlen) then + types[type(k)](k, visited, accum) + types[type(v)](v, visited, accum) + end + end + end + end + + types["function"] = function(x, visited, accum) + if visited[x] then + accum[#accum + 1] = "\208" + accum[#accum + 1] = number_to_str(visited[x]) + else + if check_custom_type(x, visited, accum) then return end + visited[x] = visited[NEXT] + visited[NEXT] = visited[NEXT] + 1 + local str = dump(x) + accum[#accum + 1] = "\210" + accum[#accum + 1] = number_to_str(#str) + accum[#accum + 1] = str + end + end + + types.cdata = function(x, visited, accum) + if visited[x] then + accum[#accum + 1] = "\208" + accum[#accum + 1] = number_to_str(visited[x]) + else + if check_custom_type(x, visited, #accum) then return end + error("Cannot serialize this cdata.") + end + end + + types.thread = function() error("Cannot serialize threads.") end + + local function deserialize_value(str, index, visited) + local t = byte(str, index) + if not t then return nil, index end + if t < 128 then + return t - 27, index + 1 + elseif t < 192 then + local b2 = byte(str, index + 1) + if not b2 then error("Expected more bytes of input.") end + return b2 + 0x100 * (t - 128) - 8192, index + 2 + elseif t == 202 then + return nil, index + 1 + elseif t == 203 or t == 212 then + return number_from_str(str, index) + elseif t == 204 then + return true, index + 1 + elseif t == 205 then + return false, index + 1 + elseif t == 206 then + local length, dataindex = number_from_str(str, index + 1) + local nextindex = dataindex + length + if length < 0 then error("Bad string length") end + if #str < nextindex - 1 then error("Expected more bytes of string") end + local substr = sub(str, dataindex, nextindex - 1) + visited[#visited + 1] = substr + return substr, nextindex + elseif t == 207 or t == 213 then + local mt, count, nextindex + local ret = {} + visited[#visited + 1] = ret + nextindex = index + 1 + if t == 213 then + mt, nextindex = deserialize_value(str, nextindex, visited) + if type(mt) ~= "table" then error("Expected table metatable") end + end + count, nextindex = number_from_str(str, nextindex) + for i = 1, count do + local oldindex = nextindex + ret[i], nextindex = deserialize_value(str, nextindex, visited) + if nextindex == oldindex then error("Expected more bytes of input.") end + end + count, nextindex = number_from_str(str, nextindex) + for _ = 1, count do + local k, v + local oldindex = nextindex + k, nextindex = deserialize_value(str, nextindex, visited) + if nextindex == oldindex then error("Expected more bytes of input.") end + oldindex = nextindex + v, nextindex = deserialize_value(str, nextindex, visited) + if nextindex == oldindex then error("Expected more bytes of input.") end + if k == nil then error("Can't have nil table keys") end + ret[k] = v + end + if mt then setmetatable(ret, mt) end + return ret, nextindex + elseif t == 208 then + local ref, nextindex = number_from_str(str, index + 1) + return visited[ref], nextindex + elseif t == 209 then + local count + local name, nextindex = deserialize_value(str, index + 1, visited) + count, nextindex = number_from_str(str, nextindex) + local args = {} + for i = 1, count do + local oldindex = nextindex + args[i], nextindex = deserialize_value(str, nextindex, visited) + if nextindex == oldindex then error("Expected more bytes of input.") end + end + if not name or not deserializers[name] then + error(("Cannot deserialize class '%s'"):format(tostring(name))) + end + local ret = deserializers[name](unpack(args)) + visited[#visited + 1] = ret + return ret, nextindex + elseif t == 210 then + local length, dataindex = number_from_str(str, index + 1) + local nextindex = dataindex + length + if length < 0 then error("Bad string length") end + if #str < nextindex - 1 then error("Expected more bytes of string") end + local ret = loadstring(sub(str, dataindex, nextindex - 1)) + visited[#visited + 1] = ret + return ret, nextindex + elseif t == 211 then + local resname, nextindex = deserialize_value(str, index + 1, visited) + if resname == nil then error("Got nil resource name") end + local res = resources_by_name[resname] + if res == nil then + error(("No resources found for name '%s'"):format(tostring(resname))) + end + return res, nextindex + else + error("Could not deserialize type byte " .. t .. ".") + end + end + + local function serialize(...) + local visited = {[NEXT] = 1, [CTORSTACK] = {}} + local accum = {} + for i = 1, select("#", ...) do + local x = select(i, ...) + types[type(x)](x, visited, accum) + end + return concat(accum) + end + + local function make_file_writer(file) + return setmetatable({}, { + __newindex = function(_, _, v) + file:write(v) + end + }) + end + + local function serialize_to_file(path, mode, ...) + local file, err = io.open(path, mode) + assert(file, err) + local visited = {[NEXT] = 1, [CTORSTACK] = {}} + local accum = make_file_writer(file) + for i = 1, select("#", ...) do + local x = select(i, ...) + types[type(x)](x, visited, accum) + end + -- flush the writer + file:flush() + file:close() + end + + local function writeFile(path, ...) + return serialize_to_file(path, "wb", ...) + end + + local function appendFile(path, ...) + return serialize_to_file(path, "ab", ...) + end + + local function deserialize(str, index) + assert(type(str) == "string", "Expected string to deserialize.") + local vals = {} + index = index or 1 + local visited = {} + local len = 0 + local val + while true do + local nextindex + val, nextindex = deserialize_value(str, index, visited) + if nextindex > index then + len = len + 1 + vals[len] = val + index = nextindex + else + break + end + end + return vals, len + end + + local function deserializeN(str, n, index) + assert(type(str) == "string", "Expected string to deserialize.") + n = n or 1 + assert(type(n) == "number", "Expected a number for parameter n.") + assert(n > 0 and floor(n) == n, "N must be a poitive integer.") + local vals = {} + index = index or 1 + local visited = {} + local len = 0 + local val + while len < n do + local nextindex + val, nextindex = deserialize_value(str, index, visited) + if nextindex > index then + len = len + 1 + vals[len] = val + index = nextindex + else + break + end + end + vals[len + 1] = index + return unpack(vals, 1, n + 1) + end + + local function readFile(path) + local file, err = fiber_file.open(path, "rb") + assert(file, err) + local file_chars = file:read("*a") + file:close() + return deserialize(file_chars) + end + + -- Resources + + local function registerResource(resource, name) + type_check(name, "string", "name") + assert(not resources[resource], + "Resource already registered.") + assert(not resources_by_name[name], + format("Resource %q already exists.", name)) + resources_by_name[name] = resource + resources[resource] = name + return resource + end + + local function unregisterResource(name) + type_check(name, "string", "name") + assert(resources_by_name[name], format("Resource %q does not exist.", name)) + local resource = resources_by_name[name] + resources_by_name[name] = nil + resources[resource] = nil + return resource + end + + -- Templating + + local function normalize_template(template) + local ret = {} + for i = 1, #template do + ret[i] = template[i] + end + local non_array_part = {} + -- The non-array part of the template (nested templates) have to be deterministic, so they are sorted. + -- This means that inherently non deterministicly sortable keys (tables, functions) should NOT be used + -- in templates. Looking for way around this. + for k in pairs(template) do + if not_array_index(k, #template) then + non_array_part[#non_array_part + 1] = k + end + end + table.sort(non_array_part) + for i = 1, #non_array_part do + local name = non_array_part[i] + ret[#ret + 1] = {name, normalize_template(template[name])} + end + return ret + end + + local function templatepart_serialize(part, argaccum, x, len) + local extras = {} + local extracount = 0 + for k, v in pairs(x) do + extras[k] = v + extracount = extracount + 1 + end + for i = 1, #part do + local name + if type(part[i]) == "table" then + name = part[i][1] + len = templatepart_serialize(part[i][2], argaccum, x[name], len) + else + name = part[i] + len = len + 1 + argaccum[len] = x[part[i]] + end + if extras[name] ~= nil then + extracount = extracount - 1 + extras[name] = nil + end + end + if extracount > 0 then + argaccum[len + 1] = extras + else + argaccum[len + 1] = nil + end + return len + 1 + end + + local function templatepart_deserialize(ret, part, values, vindex) + for i = 1, #part do + local name = part[i] + if type(name) == "table" then + local newret = {} + ret[name[1]] = newret + vindex = templatepart_deserialize(newret, name[2], values, vindex) + else + ret[name] = values[vindex] + vindex = vindex + 1 + end + end + local extras = values[vindex] + if extras then + for k, v in pairs(extras) do + ret[k] = v + end + end + return vindex + 1 + end + + local function template_serializer_and_deserializer(metatable, template) + return function(x) + local argaccum = {} + local len = templatepart_serialize(template, argaccum, x, 0) + return unpack(argaccum, 1, len) + end, function(...) + local ret = {} + local args = {...} + templatepart_deserialize(ret, template, args, 1) + return setmetatable(ret, metatable) + end + end + + -- Used to serialize classes withh custom serializers and deserializers. + -- If no _serialize or _deserialize (or no _template) value is found in the + -- metatable, then the metatable is registered as a resources. + local function register(metatable, name, serialize, deserialize) + if type(metatable) == "table" then + name = name or metatable.name + serialize = serialize or metatable._serialize + deserialize = deserialize or metatable._deserialize + if (not serialize) or (not deserialize) then + if metatable._template then + -- Register as template + local t = normalize_template(metatable._template) + serialize, deserialize = template_serializer_and_deserializer(metatable, t) + else + -- Register the metatable as a resource. This is semantically + -- similar and more flexible (handles cycles). + registerResource(metatable, name) + return + end + end + elseif type(metatable) == "string" then + name = name or metatable + end + type_check(name, "string", "name") + type_check(serialize, "function", "serialize") + type_check(deserialize, "function", "deserialize") + assert((not ids[metatable]) and (not resources[metatable]), + "Metatable already registered.") + assert((not mts[name]) and (not resources_by_name[name]), + ("Name %q already registered."):format(name)) + mts[name] = metatable + ids[metatable] = name + serializers[name] = serialize + deserializers[name] = deserialize + return metatable + end + + local function unregister(item) + local name, metatable + if type(item) == "string" then -- assume name + name, metatable = item, mts[item] + else -- assume metatable + name, metatable = ids[item], item + end + type_check(name, "string", "name") + mts[name] = nil + if (metatable) then + resources[metatable] = nil + ids[metatable] = nil + end + serializers[name] = nil + deserializers[name] = nil + resources_by_name[name] = nil; + return metatable + end + + local function registerClass(class, name) + name = name or class.name + if class.__instanceDict then -- middleclass + register(class.__instanceDict, name) + else -- assume 30log or similar library + register(class, name) + end + return class + end + + return { + VERSION = "0.0-8", + -- aliases + s = serialize, + d = deserialize, + dn = deserializeN, + r = readFile, + w = writeFile, + a = appendFile, + + serialize = serialize, + deserialize = deserialize, + deserializeN = deserializeN, + readFile = readFile, + writeFile = writeFile, + appendFile = appendFile, + register = register, + unregister = unregister, + registerResource = registerResource, + unregisterResource = unregisterResource, + registerClass = registerClass, + + newbinser = newbinser + } end return newbinser() diff --git a/src/shared/cache.lua b/src/shared/cache.lua index f4bee5d4..6c4738f3 100644 --- a/src/shared/cache.lua +++ b/src/shared/cache.lua @@ -15,12 +15,12 @@ Cache.__index = Cache ---@param separator string? ---@return Cache local function new(default_timeout, custom_time_func, separator) - local self = setmetatable({}, Cache) - self.default_timeout = default_timeout or 10 - self.time_func = custom_time_func or os.time - self.separator = separator or string.char(31) - self.store = {} - return self + local self = setmetatable({}, Cache) + self.default_timeout = default_timeout or 10 + self.time_func = custom_time_func or os.time + self.separator = separator or string.char(31) + self.store = {} + return self end --- Setting a value in the cache @@ -28,49 +28,49 @@ end ---@param value any ---@param timeout number? function Cache:set(key, value, timeout) - timeout = timeout or self.default_timeout - -- if type(value) == 'table' then - -- if is_array(value) then - -- self.store[key] = {value=value, timestamp=self.time_func() + timeout, timeout = timeout} - -- else - -- for k, v in pairs(value) do - -- self:set(key .. self.separator .. k, v, timeout) - -- end - -- end - -- else - -- self.store[key] = {value = value, timestamp=self.time_func(), timeout = timeout} - -- end - self.store[key] = {value = value, timestamp=self.time_func(), timeout = timeout} + timeout = timeout or self.default_timeout + -- if type(value) == 'table' then + -- if is_array(value) then + -- self.store[key] = {value=value, timestamp=self.time_func() + timeout, timeout = timeout} + -- else + -- for k, v in pairs(value) do + -- self:set(key .. self.separator .. k, v, timeout) + -- end + -- end + -- else + -- self.store[key] = {value = value, timestamp=self.time_func(), timeout = timeout} + -- end + self.store[key] = {value = value, timestamp=self.time_func(), timeout = timeout} end --- Delete a value from the cache. ---@param key CacheKey function Cache:delete(key) - if type(key) ~= 'string' then - key = table.concat(key, self.separator) - end - self.store[key] = nil + if type(key) ~= 'string' then + key = table.concat(key, self.separator) + end + self.store[key] = nil end --- Delete all cache entries whose string key starts with prefix. ---@param prefix string function Cache:clear_prefix(prefix) - prefix = tostring(prefix or '') - local remove = {} - for key in pairs(self.store) do - if key == prefix or key:sub(1, #prefix) == prefix then - remove[#remove + 1] = key - end - end - for _, key in ipairs(remove) do - self.store[key] = nil - end + prefix = tostring(prefix or '') + local remove = {} + for key in pairs(self.store) do + if key == prefix or key:sub(1, #prefix) == prefix then + remove[#remove + 1] = key + end + end + for _, key in ipairs(remove) do + self.store[key] = nil + end end --- Delete all cache entries. function Cache:clear() - self.store = {} + self.store = {} end --- Getting a value from the cache @@ -78,20 +78,20 @@ end ---@param timeout number? ---@return any function Cache:get(key, timeout) - if type(key) ~= 'string' then - key = table.concat(key, self.separator) - end - local item = self.store[key] - if item then - timeout = timeout or item.timeout - if self.time_func() < (item.timestamp + timeout) then - return item.value - end - end - return nil -- or a default value + if type(key) ~= 'string' then + key = table.concat(key, self.separator) + end + local item = self.store[key] + if item then + timeout = timeout or item.timeout + if self.time_func() < (item.timestamp + timeout) then + return item.value + end + end + return nil -- or a default value end return { - new = new, - Cache = Cache + new = new, + Cache = Cache } diff --git a/tests/integration/devhost/mcu_update_full_path_spec.lua b/tests/integration/devhost/mcu_update_full_path_spec.lua index d345f3bd..92e605ad 100644 --- a/tests/integration/devhost/mcu_update_full_path_spec.lua +++ b/tests/integration/devhost/mcu_update_full_path_spec.lua @@ -44,747 +44,747 @@ local function assert_true(v, msg) if v ~= true then fail(msg or ('expected true local function assert_not_nil(v, msg) if v == nil then fail(msg or 'expected non-nil') end end local function log(msg) - io.stderr:write('[mcu-full-path] ' .. tostring(msg) .. '\n') + io.stderr:write('[mcu-full-path] ' .. tostring(msg) .. '\n') end local function shquote(s) - return "'" .. tostring(s):gsub("'", "'\\''") .. "'" + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end local function mkdir_p(path) - local ok = os.execute('mkdir -p ' .. shquote(path)) - if ok ~= true and ok ~= 0 then error('mkdir failed: ' .. tostring(path), 0) end + local ok = os.execute('mkdir -p ' .. shquote(path)) + if ok ~= true and ok ~= 0 then error('mkdir failed: ' .. tostring(path), 0) end end local function rm_rf(path) - os.execute('rm -rf ' .. shquote(path)) + os.execute('rm -rf ' .. shquote(path)) end local function write_file(path, body) - local f = assert(io.open(path, 'wb')) - assert(f:write(body)) - assert(f:close()) + local f = assert(io.open(path, 'wb')) + assert(f:write(body)) + assert(f:close()) end local function temp_roots() - local base = ('/tmp/devicecode-mcu-full-%d-%d'):format(os.time(), math.random(100000, 999999)) - rm_rf(base) - local roots = { - base = base, - config = base .. '/config', - static = base .. '/static', - artifact = { - transient = base .. '/cm5/artifacts/transient', - durable = base .. '/cm5/artifacts/durable', - import = base .. '/cm5/artifacts/import', - }, - control = base .. '/cm5/control/update', - mcu = base .. '/mcu', - } - mkdir_p(roots.config) - mkdir_p(roots.static) - mkdir_p(roots.artifact.transient) - mkdir_p(roots.artifact.durable) - mkdir_p(roots.artifact.import) - mkdir_p(roots.control) - mkdir_p(roots.mcu) - write_file(roots.static .. '/index.html', 'ok') - return roots + local base = ('/tmp/devicecode-mcu-full-%d-%d'):format(os.time(), math.random(100000, 999999)) + rm_rf(base) + local roots = { + base = base, + config = base .. '/config', + static = base .. '/static', + artifact = { + transient = base .. '/cm5/artifacts/transient', + durable = base .. '/cm5/artifacts/durable', + import = base .. '/cm5/artifacts/import', + }, + control = base .. '/cm5/control/update', + mcu = base .. '/mcu', + } + mkdir_p(roots.config) + mkdir_p(roots.static) + mkdir_p(roots.artifact.transient) + mkdir_p(roots.artifact.durable) + mkdir_p(roots.artifact.import) + mkdir_p(roots.control) + mkdir_p(roots.mcu) + write_file(roots.static .. '/index.html', 'ok') + return roots end local function copy_frame(frame) - local out = {} - for k, v in pairs(frame or {}) do - if type(v) == 'table' then - local t = {} - for k2, v2 in pairs(v) do t[k2] = v2 end - out[k] = t - else - out[k] = v - end - end - return out + local out = {} + for k, v in pairs(frame or {}) do + if type(v) == 'table' then + local t = {} + for k2, v2 in pairs(v) do t[k2] = v2 end + out[k] = t + else + out[k] = v + end + end + return out end local function new_line_transport() - local in_tx, in_rx = mailbox.new(128, { full = 'reject_newest' }) - local written = {} - local session = {} - - function session:read_line_op() - return in_rx:recv_op():wrap(function(frame) - if frame == nil then return nil, in_rx:why() or 'closed' end - local line, err = fabric_protocol.encode_line(frame) - if not line then return nil, err end - return line, nil - end) - end - - function session:write_line_op(line) - local frame, err = fabric_protocol.decode_line(line) - if not frame then return fibers.always(nil, err) end - written[#written + 1] = copy_frame(frame) - return fibers.always(true, nil) - end - - function session:flush_op() - return fibers.always(true, nil) - end - - function session:terminate(reason) - in_tx:close(reason or 'transport terminated') - return true, nil - end - - return { session = session, in_tx = in_tx, written = written } + local in_tx, in_rx = mailbox.new(128, { full = 'reject_newest' }) + local written = {} + local session = {} + + function session:read_line_op() + return in_rx:recv_op():wrap(function(frame) + if frame == nil then return nil, in_rx:why() or 'closed' end + local line, err = fabric_protocol.encode_line(frame) + if not line then return nil, err end + return line, nil + end) + end + + function session:write_line_op(line) + local frame, err = fabric_protocol.decode_line(line) + if not frame then return fibers.always(nil, err) end + written[#written + 1] = copy_frame(frame) + return fibers.always(true, nil) + end + + function session:flush_op() + return fibers.always(true, nil) + end + + function session:terminate(reason) + in_tx:close(reason or 'transport terminated') + return true, nil + end + + return { session = session, in_tx = in_tx, written = written } end local function connect_line_transports(a, b) - local write_a = a.session.write_line_op - function a.session:write_line_op(line) - local frame = assert(fabric_protocol.decode_line(line)) - write_a(self, line) - return b.in_tx:send_op(copy_frame(frame)) - end + local write_a = a.session.write_line_op + function a.session:write_line_op(line) + local frame = assert(fabric_protocol.decode_line(line)) + write_a(self, line) + return b.in_tx:send_op(copy_frame(frame)) + end - local write_b = b.session.write_line_op - function b.session:write_line_op(line) - local frame = assert(fabric_protocol.decode_line(line)) - write_b(self, line) - return a.in_tx:send_op(copy_frame(frame)) - end + local write_b = b.session.write_line_op + function b.session:write_line_op(line) + local frame = assert(fabric_protocol.decode_line(line)) + write_b(self, line) + return a.in_tx:send_op(copy_frame(frame)) + end end local function wrap_session_op(session) - local wrapped, err = hal_transport.wrap_transport(session) - return fibers.always(wrapped, err) + local wrapped, err = hal_transport.wrap_transport(session) + return fibers.always(wrapped, err) end local function wait_retained_payload_where(conn, topic, label, pred, opts) - opts = opts or {} - local view = conn:retained_view(topic) - local value = probe.wait_versioned_until(label, function () - return view:version() - end, function (seen) - return view:changed_op(seen) - end, function () - local msg = view:get(topic) - local payload = msg and msg.payload or nil - if pred(payload) then return payload end - return nil - end, opts) - view:close() - return value + opts = opts or {} + local view = conn:retained_view(topic) + local value = probe.wait_versioned_until(label, function () + return view:version() + end, function (seen) + return view:changed_op(seen) + end, function () + local msg = view:get(topic) + local payload = msg and msg.payload or nil + if pred(payload) then return payload end + return nil + end, opts) + view:close() + return value end local function wait_job(conn, job_id, state, timeout) - return wait_retained_payload_where(conn, update_topics.update_component('mcu'), 'update job ' .. tostring(job_id) .. ' ' .. tostring(state), function (p) - local job = p and (p.current_job or p.last_job) or nil - if job and job.job_id == job_id and job.state == state then return job end - return nil - end, { timeout = timeout or 4.0 }) + return wait_retained_payload_where(conn, update_topics.update_component('mcu'), 'update job ' .. tostring(job_id) .. ' ' .. tostring(state), function (p) + local job = p and (p.current_job or p.last_job) or nil + if job and job.job_id == job_id and job.state == state then return job end + return nil + end, { timeout = timeout or 4.0 }) end local function wait_component_software(conn, image_id, boot_id) - return wait_retained_payload_where(conn, device_topics.component_software('mcu'), 'mcu software canonical state', function (p) - if p and (image_id == nil or p.image_id == image_id) and (boot_id == nil or p.boot_id == boot_id) then return p end - return nil - end, { timeout = 4.0 }) + return wait_retained_payload_where(conn, device_topics.component_software('mcu'), 'mcu software canonical state', function (p) + if p and (image_id == nil or p.image_id == image_id) and (boot_id == nil or p.boot_id == boot_id) then return p end + return nil + end, { timeout = 4.0 }) end local function start_public_fabric(scope, conn, cfg, transport, opts) - opts = opts or {} - local link_overrides = opts.link_overrides or { - ['link-a'] = { - open_transport_op = function () return wrap_session_op(transport.session) end, - transfer = opts.transfer, - }, - } - local ok, err = scope:spawn(function () - fabric.start(conn, { - name = opts.name or 'fabric', - env = 'test', - config = cfg, - link_overrides = link_overrides, - }) - end) - assert_true(ok, tostring(err)) + opts = opts or {} + local link_overrides = opts.link_overrides or { + ['link-a'] = { + open_transport_op = function () return wrap_session_op(transport.session) end, + transfer = opts.transfer, + }, + } + local ok, err = scope:spawn(function () + fabric.start(conn, { + name = opts.name or 'fabric', + env = 'test', + config = cfg, + link_overrides = link_overrides, + }) + end) + assert_true(ok, tostring(err)) end local function fabric_config(local_node, peer_node, bridge, transfer) - return { - schema = fabric.config.SCHEMA, - local_node = local_node, - links = { - { - id = 'link-a', - peer_id = peer_node, - transport = { source = 'test', class = 'jsonl', id = 'link-a' }, - session = { hello_interval_s = 5.0, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, - bridge = bridge or {}, - transfer = transfer or { chunk_size = 2048, timeout_s = 3.0 }, - }, - }, - } + return { + schema = fabric.config.SCHEMA, + local_node = local_node, + links = { + { + id = 'link-a', + peer_id = peer_node, + transport = { source = 'test', class = 'jsonl', id = 'link-a' }, + session = { hello_interval_s = 5.0, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, + bridge = bridge or {}, + transfer = transfer or { chunk_size = 2048, timeout_s = 3.0 }, + }, + }, + } end local function cm5_fabric_config() - return fabric_config('cm5', 'mcu', { - imports = { - { id = 'mcu-state', remote = { 'state', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'state' } }, - { id = 'mcu-event', remote = { 'event', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap', 'telemetry', 'main', 'event' } }, - { id = 'mcu-cap', remote = { 'cap', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap' } }, - }, - rpc = { - outbound = { - { - id = 'mcu-prepare', - ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'prepare-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - timeout_s = 2.0, - }, - { - id = 'mcu-commit', - ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'commit-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - timeout_s = 2.0, - }, - }, - }, - }, { chunk_size = 2048, timeout_s = 4.0 }) + return fabric_config('cm5', 'mcu', { + imports = { + { id = 'mcu-state', remote = { 'state', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'state' } }, + { id = 'mcu-event', remote = { 'event', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap', 'telemetry', 'main', 'event' } }, + { id = 'mcu-cap', remote = { 'cap', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap' } }, + }, + rpc = { + outbound = { + { + id = 'mcu-prepare', + ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'prepare-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + timeout_s = 2.0, + }, + { + id = 'mcu-commit', + ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'commit-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + timeout_s = 2.0, + }, + }, + }, + }, { chunk_size = 2048, timeout_s = 4.0 }) end local function mcu_fabric_config() - return fabric_config('mcu', 'cm5', { - exports = { - { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, - { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, - }, - rpc = { - inbound = { - { - id = 'mcu-prepare-in', - ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - timeout_s = 2.0, - }, - { - id = 'mcu-commit-in', - ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - timeout_s = 2.0, - }, - }, - }, - }, { chunk_size = 2048, timeout_s = 4.0 }) + return fabric_config('mcu', 'cm5', { + exports = { + { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, + { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, + }, + rpc = { + inbound = { + { + id = 'mcu-prepare-in', + ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + timeout_s = 2.0, + }, + { + id = 'mcu-commit-in', + ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + timeout_s = 2.0, + }, + }, + }, + }, { chunk_size = 2048, timeout_s = 4.0 }) end local function publish_mcu_facts(conn, fake) - local image = fake.committed_image_id or fake.old_image_id - local boot = 'mcu-boot-' .. tostring(fake.boot_seq) - conn:retain({ 'state', 'self', 'software' }, { - image_id = image, - boot_id = boot, - version = image, - }) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'ready', - last_error = nil, - staged_image_id = fake.staged and fake.staged.image_id or nil, - pending_image_id = fake.committed_image_id, - job_id = fake.job_id, - }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { - class = 'updater', - id = 'main', - methods = { 'prepare-update', 'commit-update' }, - }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { - available = true, - state = 'available', - }) + local image = fake.committed_image_id or fake.old_image_id + local boot = 'mcu-boot-' .. tostring(fake.boot_seq) + conn:retain({ 'state', 'self', 'software' }, { + image_id = image, + boot_id = boot, + version = image, + }) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'ready', + last_error = nil, + staged_image_id = fake.staged and fake.staged.image_id or nil, + pending_image_id = fake.committed_image_id, + job_id = fake.job_id, + }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { + class = 'updater', + id = 'main', + methods = { 'prepare-update', 'commit-update' }, + }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { + available = true, + state = 'available', + }) end local function new_mcu_receive_target(fake) - local target = {} - function target:open_sink_op(req) - fake.transfer_begin = req - assert_eq(req.target, 'updater/main') - local chunks = {} - local sink = {} - function sink:append_op(chunk) - chunks[#chunks + 1] = chunk - return fibers.always(true, nil) - end - function sink:commit_op(req2) - local bytes = table.concat(chunks) - fake.staged = { - bytes = bytes, - digest = req2.digest, - size = req2.size, - image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), - job_id = req.meta and req.meta.job_id, - } - fake.staged_signal = (fake.staged_signal or 0) + 1 - return fibers.always({ staged = true, digest = req2.digest }, nil) - end - function sink:abort(reason) - fake.abort_reason = reason - return true, nil - end - return fibers.always(sink, nil) - end - return target + local target = {} + function target:open_sink_op(req) + fake.transfer_begin = req + assert_eq(req.target, 'updater/main') + local chunks = {} + local sink = {} + function sink:append_op(chunk) + chunks[#chunks + 1] = chunk + return fibers.always(true, nil) + end + function sink:commit_op(req2) + local bytes = table.concat(chunks) + fake.staged = { + bytes = bytes, + digest = req2.digest, + size = req2.size, + image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), + job_id = req.meta and req.meta.job_id, + } + fake.staged_signal = (fake.staged_signal or 0) + 1 + return fibers.always({ staged = true, digest = req2.digest }, nil) + end + function sink:abort(reason) + fake.abort_reason = reason + return true, nil + end + return fibers.always(sink, nil) + end + return target end local function start_fake_mcu(scope, bus, fake) - local conn = bus:connect({ origin_base = { service = 'fake-mcu' } }) - fake.boot_seq = (fake.boot_seq or 0) + 1 - publish_mcu_facts(conn, fake) - - local eps = {} - local function bind(topic) - local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) - assert_not_nil(ep, err) - eps[#eps + 1] = ep - return ep - end - - local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) - local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) - - scope:finally(function () - for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end - bus_cleanup.disconnect(conn) - end) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(prepare_ep:recv_op()) - if req == nil then return end - fake.prepare_payload = req.payload - assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') - fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil - conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) - req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) - end - end)) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(commit_ep:recv_op()) - if req == nil then return end - fake.commit_payload = req.payload - assert_eq(type(req.payload), 'table') - assert_eq(req.payload.job_id, fake.job_id) - assert_eq(req.payload.metadata, nil) - fake.commit_seen = true - fake.committed_image_id = (fake.staged and fake.staged.image_id) - or (type(req.payload) == 'table' and req.payload.expected_image_id) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'rebooting', - last_error = nil, - pending_image_id = fake.committed_image_id, - staged_image_id = fake.staged and fake.staged.image_id or nil, - job_id = fake.job_id, - }) - req:reply({ accepted = true, reboot_required = true }) - end - end)) - - return conn + local conn = bus:connect({ origin_base = { service = 'fake-mcu' } }) + fake.boot_seq = (fake.boot_seq or 0) + 1 + publish_mcu_facts(conn, fake) + + local eps = {} + local function bind(topic) + local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) + assert_not_nil(ep, err) + eps[#eps + 1] = ep + return ep + end + + local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) + local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) + + scope:finally(function () + for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end + bus_cleanup.disconnect(conn) + end) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(prepare_ep:recv_op()) + if req == nil then return end + fake.prepare_payload = req.payload + assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') + fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil + conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) + req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) + end + end)) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(commit_ep:recv_op()) + if req == nil then return end + fake.commit_payload = req.payload + assert_eq(type(req.payload), 'table') + assert_eq(req.payload.job_id, fake.job_id) + assert_eq(req.payload.metadata, nil) + fake.commit_seen = true + fake.committed_image_id = (fake.staged and fake.staged.image_id) + or (type(req.payload) == 'table' and req.payload.expected_image_id) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'rebooting', + last_error = nil, + pending_image_id = fake.committed_image_id, + staged_image_id = fake.staged and fake.staged.image_id or nil, + job_id = fake.job_id, + }) + req:reply({ accepted = true, reboot_required = true }) + end + end)) + + return conn end local function new_fabric_client(conn) - local client = {} - function client:send_blob_op(params, opts) - params = params or {} - opts = opts or {} - assert(params.source_owner, 'fabric client source_owner required') - return conn:call_op(fabric_topics.transfer_manager_rpc('send-blob'), { - link_id = params.link_id or 'link-a', - request_id = params.request_id or ('device-stage-' .. tostring(params.job_id or os.clock())), - xfer_id = params.xfer_id, - target = assert(params.target, 'fabric client params.target required'), - source_owner = params.source_owner, - size = params.size, - digest_alg = params.digest_alg, - digest = params.digest, - chunk_size = params.chunk_size or 2048, - meta = params.meta, - timeout_s = opts.timeout or params.timeout or 4.0, - }, { timeout = opts.timeout or params.timeout or 4.0 }):wrap(function (reply, err) - if reply == nil then return nil, err end - return { - ok = reply.ok, - committed = reply.committed, - transfer = reply, - }, nil - end) - end - return client + local client = {} + function client:send_blob_op(params, opts) + params = params or {} + opts = opts or {} + assert(params.source_owner, 'fabric client source_owner required') + return conn:call_op(fabric_topics.transfer_manager_rpc('send-blob'), { + link_id = params.link_id or 'link-a', + request_id = params.request_id or ('device-stage-' .. tostring(params.job_id or os.clock())), + xfer_id = params.xfer_id, + target = assert(params.target, 'fabric client params.target required'), + source_owner = params.source_owner, + size = params.size, + digest_alg = params.digest_alg, + digest = params.digest, + chunk_size = params.chunk_size or 2048, + meta = params.meta, + timeout_s = opts.timeout or params.timeout or 4.0, + }, { timeout = opts.timeout or params.timeout or 4.0 }):wrap(function (reply, err) + if reply == nil then return nil, err end + return { + ok = reply.ok, + committed = reply.committed, + transfer = reply, + }, nil + end) + end + return client end local function update_config() - return { - schema = 'devicecode.update/1', - components = { - { component = 'mcu' }, - }, - } + return { + schema = 'devicecode.update/1', + components = { + { component = 'mcu' }, + }, + } end local function start_hal(scope, bus, roots) - local conn = bus:connect({ origin_base = { service = 'hal' } }) - if posix_ok and stdlib and stdlib.setenv then - stdlib.setenv('DEVICECODE_CONFIG_DIR', roots.config, true) - end - assert_true(scope:spawn(function () - hal_service.start(conn, { name = 'hal', env = 'test', heartbeat_s = false }) - end)) - conn:retain({ 'cfg', 'hal' }, { data = { - schema = 'devicecode.config/hal/1', - artifact_store = { - stores = { - { - id = 'main', - transient_root = roots.artifact.transient, - durable_root = roots.artifact.durable, - import_root = roots.artifact.import, - durable_enabled = true, - }, - }, - }, - control_store = { - { name = 'update', root = roots.control }, - }, - } }) - wait_retained_payload_where(conn, { 'cap', 'artifact-store', 'main', 'status' }, 'artifact-store available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - wait_retained_payload_where(conn, { 'cap', 'control-store', 'update', 'status' }, 'control-store available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'hal' } }) + if posix_ok and stdlib and stdlib.setenv then + stdlib.setenv('DEVICECODE_CONFIG_DIR', roots.config, true) + end + assert_true(scope:spawn(function () + hal_service.start(conn, { name = 'hal', env = 'test', heartbeat_s = false }) + end)) + conn:retain({ 'cfg', 'hal' }, { data = { + schema = 'devicecode.config/hal/1', + artifact_store = { + stores = { + { + id = 'main', + transient_root = roots.artifact.transient, + durable_root = roots.artifact.durable, + import_root = roots.artifact.import, + durable_enabled = true, + }, + }, + }, + control_store = { + { name = 'update', root = roots.control }, + }, + } }) + wait_retained_payload_where(conn, { 'cap', 'artifact-store', 'main', 'status' }, 'artifact-store available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + wait_retained_payload_where(conn, { 'cap', 'control-store', 'update', 'status' }, 'control-store available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_http(scope, bus) - local conn = bus:connect({ origin_base = { service = 'http' } }) - assert_true(scope:spawn(function (s) - http_service.run(s, { - conn = conn, - id = 'main', - config = { - schema = 'devicecode.config/http/1', - id = 'main', - policy = { allow_loopback = true, max_request_body = 16 * 1024 * 1024, max_response_body = 16 * 1024 * 1024 }, - }, - backend_timeout = 3.0, - connection_setup_timeout = 3.0, - intra_stream_timeout = 3.0, - }) - end)) - wait_retained_payload_where(conn, { 'cap', 'http', 'main', 'status' }, 'http service available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'http' } }) + assert_true(scope:spawn(function (s) + http_service.run(s, { + conn = conn, + id = 'main', + config = { + schema = 'devicecode.config/http/1', + id = 'main', + policy = { allow_loopback = true, max_request_body = 16 * 1024 * 1024, max_response_body = 16 * 1024 * 1024 }, + }, + backend_timeout = 3.0, + connection_setup_timeout = 3.0, + intra_stream_timeout = 3.0, + }) + end)) + wait_retained_payload_where(conn, { 'cap', 'http', 'main', 'status' }, 'http service available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_device(scope, bus, fabric_client) - local conn = bus:connect({ origin_base = { service = 'device' } }) - assert_true(scope:spawn(function (s) - device_service.run(s, { - conn = conn, - initial_config = { schema = 'devicecode.config/device/1' }, - fabric_client = fabric_client, - watch_config = false, - }) - end)) - wait_retained_payload_where(conn, device_topics.component_cap_status('mcu'), 'mcu component cap available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'device' } }) + assert_true(scope:spawn(function (s) + device_service.run(s, { + conn = conn, + initial_config = { schema = 'devicecode.config/device/1' }, + fabric_client = fabric_client, + watch_config = false, + }) + end)) + wait_retained_payload_where(conn, device_topics.component_cap_status('mcu'), 'mcu component cap available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_update(scope, bus) - local conn = bus:connect({ origin_base = { service = 'update' } }) - assert_true(scope:spawn(function (s) - update_service.run(s, { - conn = conn, - service_id = 'update', - watch_config = false, - config = update_config(), - job_store_call_opts = { timeout = 3.0 }, - }) - end)) - wait_retained_payload_where(conn, update_topics.update_manager_status(), 'update manager available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - wait_retained_payload_where(conn, update_topics.artifact_ingest_status(), 'artifact ingest available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'update' } }) + assert_true(scope:spawn(function (s) + update_service.run(s, { + conn = conn, + service_id = 'update', + watch_config = false, + config = update_config(), + job_store_call_opts = { timeout = 3.0 }, + }) + end)) + wait_retained_payload_where(conn, update_topics.update_manager_status(), 'update manager available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + wait_retained_payload_where(conn, update_topics.artifact_ingest_status(), 'artifact ingest available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_ui(scope, bus, port, roots) - local conn = bus:connect({ origin_base = { service = 'ui' } }) - assert_true(scope:spawn(function (s) - ui_service.run(s, { - conn = conn, - service_id = 'ui', - auth_opts = { - users = { - tester = { password = 'test-password', principal = { kind = 'user', id = 'tester' } }, - }, - }, - bus = bus, - connect = function (principal) - return bus:connect({ principal = principal or { kind = 'ui-test' } }) - end, - encode_json = function (v) return assert(cjson.encode(v)) end, - update = { - bus = bus, - component = 'mcu', - ingest_id = 'ing-mcu-full-path', - job_id = 'job-mcu-full-path', - create_job = true, - start_job = true, - timeout = 8.0, - chunk_size = 4096, - metadata = { - source = 'browser', - format = 'dcmcu-v1', - }, - }, - updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, - }) - end)) - conn:retain({ 'cfg', 'ui' }, { data = { - schema = 'devicecode.config/ui/1', - enabled = true, - http = { enabled = true, cap_id = 'main', host = '127.0.0.1', port = port, max_active_requests = 8 }, - static = { root = roots.static, index = 'index.html' }, - updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, - sse = { enabled = false }, - sessions = { prune_interval = false }, - } }) - wait_retained_payload_where(conn, http_topics.state('main', 'stats'), 'ui listener active', function (p) - return p and type(p.active_listeners) == 'number' and p.active_listeners > 0 and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'ui' } }) + assert_true(scope:spawn(function (s) + ui_service.run(s, { + conn = conn, + service_id = 'ui', + auth_opts = { + users = { + tester = { password = 'test-password', principal = { kind = 'user', id = 'tester' } }, + }, + }, + bus = bus, + connect = function (principal) + return bus:connect({ principal = principal or { kind = 'ui-test' } }) + end, + encode_json = function (v) return assert(cjson.encode(v)) end, + update = { + bus = bus, + component = 'mcu', + ingest_id = 'ing-mcu-full-path', + job_id = 'job-mcu-full-path', + create_job = true, + start_job = true, + timeout = 8.0, + chunk_size = 4096, + metadata = { + source = 'browser', + format = 'dcmcu-v1', + }, + }, + updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, + }) + end)) + conn:retain({ 'cfg', 'ui' }, { data = { + schema = 'devicecode.config/ui/1', + enabled = true, + http = { enabled = true, cap_id = 'main', host = '127.0.0.1', port = port, max_active_requests = 8 }, + static = { root = roots.static, index = 'index.html' }, + updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, + sse = { enabled = false }, + sessions = { prune_interval = false }, + } }) + wait_retained_payload_where(conn, http_topics.state('main', 'stats'), 'ui listener active', function (p) + return p and type(p.active_listeners) == 'number' and p.active_listeners > 0 and p + end, { timeout = 4.0 }) + return conn end local function start_fabric_pair(parent_scope, cm5_bus, mcu_bus, fake) - local pair_scope = assert(parent_scope:child()) - local transport_cm5 = new_line_transport() - local transport_mcu = new_line_transport() - connect_line_transports(transport_cm5, transport_mcu) - - local cm5_conn = cm5_bus:connect({ origin_base = { service = 'fabric-cm5' } }) - local mcu_conn = mcu_bus:connect({ origin_base = { service = 'fabric-mcu' } }) - - start_public_fabric(pair_scope, cm5_conn, cm5_fabric_config(), transport_cm5, { name = 'fabric-cm5' }) - start_public_fabric(pair_scope, mcu_conn, mcu_fabric_config(), transport_mcu, { - name = 'fabric-mcu', - link_overrides = { - ['link-a'] = { - open_transport_op = function () return wrap_session_op(transport_mcu.session) end, - transfer = { - chunk_size = 2048, - timeout_s = 4.0, - receive_targets = { ['updater/main'] = new_mcu_receive_target(fake) }, - }, - }, - }, - }) - - wait_retained_payload_where(cm5_conn, fabric_topics.transfer_manager_status(), 'cm5 fabric transfer manager available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - - return { - scope = pair_scope, - cm5_conn = cm5_conn, - mcu_conn = mcu_conn, - } + local pair_scope = assert(parent_scope:child()) + local transport_cm5 = new_line_transport() + local transport_mcu = new_line_transport() + connect_line_transports(transport_cm5, transport_mcu) + + local cm5_conn = cm5_bus:connect({ origin_base = { service = 'fabric-cm5' } }) + local mcu_conn = mcu_bus:connect({ origin_base = { service = 'fabric-mcu' } }) + + start_public_fabric(pair_scope, cm5_conn, cm5_fabric_config(), transport_cm5, { name = 'fabric-cm5' }) + start_public_fabric(pair_scope, mcu_conn, mcu_fabric_config(), transport_mcu, { + name = 'fabric-mcu', + link_overrides = { + ['link-a'] = { + open_transport_op = function () return wrap_session_op(transport_mcu.session) end, + transfer = { + chunk_size = 2048, + timeout_s = 4.0, + receive_targets = { ['updater/main'] = new_mcu_receive_target(fake) }, + }, + }, + }, + }) + + wait_retained_payload_where(cm5_conn, fabric_topics.transfer_manager_status(), 'cm5 fabric transfer manager available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + + return { + scope = pair_scope, + cm5_conn = cm5_conn, + mcu_conn = mcu_conn, + } end local function stop_fabric_pair(pair, reason) - if not pair then return end - if pair.scope then - pair.scope:cancel(reason or 'fabric pair stop') - fibers.perform(pair.scope:join_op()) - end - bus_cleanup.disconnect(pair.cm5_conn) - bus_cleanup.disconnect(pair.mcu_conn) + if not pair then return end + if pair.scope then + pair.scope:cancel(reason or 'fabric pair stop') + fibers.perform(pair.scope:join_op()) + end + bus_cleanup.disconnect(pair.cm5_conn) + bus_cleanup.disconnect(pair.mcu_conn) end local function start_cm5_instance(parent_scope, roots, port) - local scope = assert(parent_scope:child()) - local bus = busmod.new() - local conn = bus:connect({ origin_base = { service = 'test-cm5' } }) + local scope = assert(parent_scope:child()) + local bus = busmod.new() + local conn = bus:connect({ origin_base = { service = 'test-cm5' } }) - start_hal(scope, bus, roots) - start_http(scope, bus) + start_hal(scope, bus, roots) + start_http(scope, bus) - return { - scope = scope, - bus = bus, - conn = conn, - start_services_after_fabric = function (fabric_client) - start_device(scope, bus, fabric_client) - start_update(scope, bus) - if port then start_ui(scope, bus, port, roots) end - end, - } + return { + scope = scope, + bus = bus, + conn = conn, + start_services_after_fabric = function (fabric_client) + start_device(scope, bus, fabric_client) + start_update(scope, bus) + if port then start_ui(scope, bus, port, roots) end + end, + } end local function start_mcu_instance(parent_scope, fake) - local scope = assert(parent_scope:child()) - local bus = busmod.new() - local conn = bus:connect({ origin_base = { service = 'test-mcu' } }) - start_fake_mcu(scope, bus, fake) - return { scope = scope, bus = bus, conn = conn } + local scope = assert(parent_scope:child()) + local bus = busmod.new() + local conn = bus:connect({ origin_base = { service = 'test-mcu' } }) + start_fake_mcu(scope, bus, fake) + return { scope = scope, bus = bus, conn = conn } end local function stop_instance(inst, reason) - if inst and inst.scope then - inst.scope:cancel(reason or 'test reboot') - fibers.perform(inst.scope:join_op()) - end + if inst and inst.scope then + inst.scope:cancel(reason or 'test reboot') + fibers.perform(inst.scope:join_op()) + end end local function run_http_upload(scope, port, body) - local driver = assert(http_driver_mod.new({ label = 'mcu-full-path-http-client' })) - assert_true(driver:start(scope), 'HTTP upload client driver should start') - local status, resp_body = fibers.perform(driver:run_op('mcu-full-path-upload', function () - local req = http_request.new_from_uri(('http://127.0.0.1:%d/api/update/upload'):format(port)) - req.headers:upsert(':method', 'POST') - req.headers:upsert('content-type', 'application/octet-stream') - req.headers:upsert('content-length', tostring(#body)) - req:set_body(body) - local headers, stream = assert(req:go(8)) - local status = headers:get(':status') - local response_body, body_err = stream:get_body_as_string(8) - if response_body == nil then - response_body = (''):format(tostring(body_err)) - end - return status, response_body - end)) - driver:terminate('upload complete') - return status, resp_body + local driver = assert(http_driver_mod.new({ label = 'mcu-full-path-http-client' })) + assert_true(driver:start(scope), 'HTTP upload client driver should start') + local status, resp_body = fibers.perform(driver:run_op('mcu-full-path-upload', function () + local req = http_request.new_from_uri(('http://127.0.0.1:%d/api/update/upload'):format(port)) + req.headers:upsert(':method', 'POST') + req.headers:upsert('content-type', 'application/octet-stream') + req.headers:upsert('content-length', tostring(#body)) + req:set_body(body) + local headers, stream = assert(req:go(8)) + local status = headers:get(':status') + local response_body, body_err = stream:get_body_as_string(8) + if response_body == nil then + response_body = (''):format(tostring(body_err)) + end + return status, response_body + end)) + driver:terminate('upload complete') + return status, resp_body end local function run_http_json(scope, port, path, payload, headers) - headers = headers or {} - local driver = assert(http_driver_mod.new({ label = 'mcu-full-path-http-json-client' })) - assert_true(driver:start(scope), 'HTTP JSON client driver should start') - local status, resp_body = fibers.perform(driver:run_op('mcu-full-path-json', function () - local req = http_request.new_from_uri(('http://127.0.0.1:%d%s'):format(port, path)) - req.headers:upsert(':method', 'POST') - req.headers:upsert('content-type', 'application/json') - for k, v in pairs(headers) do - req.headers:upsert(k, tostring(v)) - end - req:set_body(assert(cjson.encode(payload or {}))) - local resp_headers, stream = assert(req:go(8)) - local status = resp_headers:get(':status') - local response_body, body_err = stream:get_body_as_string(8) - if response_body == nil then - response_body = (''):format(tostring(body_err)) - end - return status, response_body - end)) - driver:terminate('json complete') - return status, resp_body, resp_body and cjson.decode(resp_body) or nil + headers = headers or {} + local driver = assert(http_driver_mod.new({ label = 'mcu-full-path-http-json-client' })) + assert_true(driver:start(scope), 'HTTP JSON client driver should start') + local status, resp_body = fibers.perform(driver:run_op('mcu-full-path-json', function () + local req = http_request.new_from_uri(('http://127.0.0.1:%d%s'):format(port, path)) + req.headers:upsert(':method', 'POST') + req.headers:upsert('content-type', 'application/json') + for k, v in pairs(headers) do + req.headers:upsert(k, tostring(v)) + end + req:set_body(assert(cjson.encode(payload or {}))) + local resp_headers, stream = assert(req:go(8)) + local status = resp_headers:get(':status') + local response_body, body_err = stream:get_body_as_string(8) + if response_body == nil then + response_body = (''):format(tostring(body_err)) + end + return status, response_body + end)) + driver:terminate('json complete') + return status, resp_body, resp_body and cjson.decode(resp_body) or nil end function T.ui_http_mcu_update_survives_fake_reboot_and_reconciles() - runfibers.run(function (root_scope) - local roots = temp_roots() - local blob = dcmcu_fixture.make('mcu-image-new') - local port = 30000 + math.random(0, 20000) - local fake = { old_image_id = 'mcu-image-old' } - - log('booting initial CM5 instance') - local cm5 = start_cm5_instance(root_scope, roots, port) - log('booting initial fake MCU instance') - local mcu = start_mcu_instance(root_scope, fake) - log('starting initial Fabric pair') - local pair = start_fabric_pair(root_scope, cm5.bus, mcu.bus, fake) - log('starting CM5 Device/Update/UI services') - cm5.start_services_after_fabric(new_fabric_client(cm5.bus:connect({ origin_base = { service = 'device-fabric-client' } }))) - - log('waiting for initial canonical MCU software state') - wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') - - log('sending real HTTP upload') - local status, body = run_http_upload(root_scope, port, blob) - assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) - local decoded = assert(cjson.decode(body), body) - assert_eq(decoded.status, 'ok') - assert_eq(decoded.job_id, 'job-mcu-full-path') - assert_eq(decoded.job, nil) - - log('waiting for job awaiting_commit') - wait_job(cm5.conn, 'job-mcu-full-path', 'awaiting_commit', 8.0) - assert_true(probe.wait_until(function () - return fake.staged and fake.staged.bytes == blob - end, { timeout = 4.0 }), 'fake MCU should stage transferred artifact') - - log('committing job through real HTTP update commit route') - local commit_status, commit_body, commit_decoded = run_http_json( - root_scope, - port, - '/api/update/commit', - { job_id = 'job-mcu-full-path' }, - nil - ) - assert_eq(commit_status, '200', commit_body) - assert_not_nil(commit_decoded and commit_decoded.value, commit_body) - assert_eq(commit_decoded.value.ok, true) - log('waiting for job awaiting_return') - wait_job(cm5.conn, 'job-mcu-full-path', 'awaiting_return', 6.0) - assert_true(probe.wait_until(function () return fake.commit_seen == true end, { timeout = 2.0 }), 'fake MCU should see commit') - assert_eq(fake.committed_image_id, 'mcu-image-new') - - log('fake reboot: stopping Fabric pair') - stop_fabric_pair(pair, 'fake fabric link reboot') - log('fake reboot: stopping CM5 instance') - stop_instance(cm5, 'fake cm5 reboot') - log('fake reboot: stopping MCU instance') - stop_instance(mcu, 'fake mcu reboot') - - log('reboot: starting fresh CM5 instance') - local cm5b = start_cm5_instance(root_scope, roots, nil) - log('reboot: starting fresh MCU instance') - local mcub = start_mcu_instance(root_scope, fake) - log('reboot: starting fresh Fabric pair') - local pair_b = start_fabric_pair(root_scope, cm5b.bus, mcub.bus, fake) - log('reboot: starting fresh CM5 Device/Update services') - cm5b.start_services_after_fabric(new_fabric_client(cm5b.bus:connect({ origin_base = { service = 'device-fabric-client-boot2' } }))) - - log('reboot: waiting for post-boot canonical MCU software state') - wait_component_software(cm5b.conn, 'mcu-image-new', 'mcu-boot-2') - log('reboot: waiting for job succeeded') - local final_job = wait_job(cm5b.conn, 'job-mcu-full-path', 'succeeded', 8.0) - assert_eq(final_job.component, 'mcu') - assert_eq(final_job.job_id, 'job-mcu-full-path') - assert_not_nil(final_job.commit_attempt, 'job should carry commit attempt details') - if final_job.commit_attempt and final_job.commit_attempt.pre_commit then - assert_eq(final_job.commit_attempt.pre_commit.pre_commit_boot_id, 'mcu-boot-1') - end - - log('cleanup: stopping second Fabric pair') - stop_fabric_pair(pair_b, 'test complete') - log('cleanup: stopping second CM5 instance') - stop_instance(cm5b, 'test complete') - log('cleanup: stopping second MCU instance') - stop_instance(mcub, 'test complete') - log('cleanup: removing temporary roots') - rm_rf(roots.base) - end, { timeout = 30.0 }) + runfibers.run(function (root_scope) + local roots = temp_roots() + local blob = dcmcu_fixture.make('mcu-image-new') + local port = 30000 + math.random(0, 20000) + local fake = { old_image_id = 'mcu-image-old' } + + log('booting initial CM5 instance') + local cm5 = start_cm5_instance(root_scope, roots, port) + log('booting initial fake MCU instance') + local mcu = start_mcu_instance(root_scope, fake) + log('starting initial Fabric pair') + local pair = start_fabric_pair(root_scope, cm5.bus, mcu.bus, fake) + log('starting CM5 Device/Update/UI services') + cm5.start_services_after_fabric(new_fabric_client(cm5.bus:connect({ origin_base = { service = 'device-fabric-client' } }))) + + log('waiting for initial canonical MCU software state') + wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') + + log('sending real HTTP upload') + local status, body = run_http_upload(root_scope, port, blob) + assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) + local decoded = assert(cjson.decode(body), body) + assert_eq(decoded.status, 'ok') + assert_eq(decoded.job_id, 'job-mcu-full-path') + assert_eq(decoded.job, nil) + + log('waiting for job awaiting_commit') + wait_job(cm5.conn, 'job-mcu-full-path', 'awaiting_commit', 8.0) + assert_true(probe.wait_until(function () + return fake.staged and fake.staged.bytes == blob + end, { timeout = 4.0 }), 'fake MCU should stage transferred artifact') + + log('committing job through real HTTP update commit route') + local commit_status, commit_body, commit_decoded = run_http_json( + root_scope, + port, + '/api/update/commit', + { job_id = 'job-mcu-full-path' }, + nil + ) + assert_eq(commit_status, '200', commit_body) + assert_not_nil(commit_decoded and commit_decoded.value, commit_body) + assert_eq(commit_decoded.value.ok, true) + log('waiting for job awaiting_return') + wait_job(cm5.conn, 'job-mcu-full-path', 'awaiting_return', 6.0) + assert_true(probe.wait_until(function () return fake.commit_seen == true end, { timeout = 2.0 }), 'fake MCU should see commit') + assert_eq(fake.committed_image_id, 'mcu-image-new') + + log('fake reboot: stopping Fabric pair') + stop_fabric_pair(pair, 'fake fabric link reboot') + log('fake reboot: stopping CM5 instance') + stop_instance(cm5, 'fake cm5 reboot') + log('fake reboot: stopping MCU instance') + stop_instance(mcu, 'fake mcu reboot') + + log('reboot: starting fresh CM5 instance') + local cm5b = start_cm5_instance(root_scope, roots, nil) + log('reboot: starting fresh MCU instance') + local mcub = start_mcu_instance(root_scope, fake) + log('reboot: starting fresh Fabric pair') + local pair_b = start_fabric_pair(root_scope, cm5b.bus, mcub.bus, fake) + log('reboot: starting fresh CM5 Device/Update services') + cm5b.start_services_after_fabric(new_fabric_client(cm5b.bus:connect({ origin_base = { service = 'device-fabric-client-boot2' } }))) + + log('reboot: waiting for post-boot canonical MCU software state') + wait_component_software(cm5b.conn, 'mcu-image-new', 'mcu-boot-2') + log('reboot: waiting for job succeeded') + local final_job = wait_job(cm5b.conn, 'job-mcu-full-path', 'succeeded', 8.0) + assert_eq(final_job.component, 'mcu') + assert_eq(final_job.job_id, 'job-mcu-full-path') + assert_not_nil(final_job.commit_attempt, 'job should carry commit attempt details') + if final_job.commit_attempt and final_job.commit_attempt.pre_commit then + assert_eq(final_job.commit_attempt.pre_commit.pre_commit_boot_id, 'mcu-boot-1') + end + + log('cleanup: stopping second Fabric pair') + stop_fabric_pair(pair_b, 'test complete') + log('cleanup: stopping second CM5 instance') + stop_instance(cm5b, 'test complete') + log('cleanup: stopping second MCU instance') + stop_instance(mcub, 'test complete') + log('cleanup: removing temporary roots') + rm_rf(roots.base) + end, { timeout = 30.0 }) end return T diff --git a/tests/integration/devhost/mcu_update_http_uart_fault_spec.lua b/tests/integration/devhost/mcu_update_http_uart_fault_spec.lua index 2e5ffd59..201daddd 100644 --- a/tests/integration/devhost/mcu_update_http_uart_fault_spec.lua +++ b/tests/integration/devhost/mcu_update_http_uart_fault_spec.lua @@ -11,59 +11,59 @@ local H = assert(base.__helpers, 'mcu_update_http_uart_spec helpers unavailable' local T = {} local function log(msg) - H.log('[fault-matrix] ' .. tostring(msg)) + H.log('[fault-matrix] ' .. tostring(msg)) end local function run_case(case) - log('case start: ' .. tostring(case.name)) - local result = H.run_go_devhost_pty_cycle({ - label = case.name, - blob_bytes = case.blob_bytes, - uart = case.uart, - reboot_uart = case.reboot_uart, - timeout_s = case.timeout_s, - stage_timeout_s = case.stage_timeout_s, - }) - if result and result.skipped then - log('case skipped: ' .. tostring(result.reason)) - return result - end - assert(result and result.ok, 'case did not return ok: ' .. tostring(case.name)) - if type(case.assert_stats) == 'function' then - case.assert_stats(result.first_uart_stats or {}, result.second_uart_stats or {}) - end - log('case ok: ' .. tostring(case.name)) - return result + log('case start: ' .. tostring(case.name)) + local result = H.run_go_devhost_pty_cycle({ + label = case.name, + blob_bytes = case.blob_bytes, + uart = case.uart, + reboot_uart = case.reboot_uart, + timeout_s = case.timeout_s, + stage_timeout_s = case.stage_timeout_s, + }) + if result and result.skipped then + log('case skipped: ' .. tostring(result.reason)) + return result + end + assert(result and result.ok, 'case did not return ok: ' .. tostring(case.name)) + if type(case.assert_stats) == 'function' then + case.assert_stats(result.first_uart_stats or {}, result.second_uart_stats or {}) + end + log('case ok: ' .. tostring(case.name)) + return result end local function assert_at_least(v, n, what) - if not ((tonumber(v) or 0) >= n) then - error(('expected %s >= %d, got %s'):format(what, n, tostring(v)), 0) - end + if not ((tonumber(v) or 0) >= n) then + error(('expected %s >= %d, got %s'):format(what, n, tostring(v)), 0) + end end local function env_bool(name, default_value) - local raw = os.getenv(name) - if raw == nil or raw == '' then return default_value end - raw = tostring(raw):lower():match('^%s*(.-)%s*$') - if raw == '1' or raw == 'true' or raw == 'yes' or raw == 'on' then return true end - if raw == '0' or raw == 'false' or raw == 'no' or raw == 'off' then return false end - error(('%s must be boolean-like'):format(name), 0) + local raw = os.getenv(name) + if raw == nil or raw == '' then return default_value end + raw = tostring(raw):lower():match('^%s*(.-)%s*$') + if raw == '1' or raw == 'true' or raw == 'yes' or raw == 'on' then return true end + if raw == '0' or raw == 'false' or raw == 'no' or raw == 'off' then return false end + error(('%s must be boolean-like'):format(name), 0) end local function env_size_bytes(name, default_value) - local raw = os.getenv(name) - if raw == nil or raw == '' then return default_value end - raw = tostring(raw):match('^%s*(.-)%s*$') - local number, suffix = raw:match('^(%d+)%s*([kKmMgG]?[iI]?[bB]?)$') - if number == nil then error(('invalid %s=%q'):format(name, raw), 0) end - local n = tonumber(number) - suffix = suffix:lower() - if suffix == 'k' or suffix == 'kb' or suffix == 'kib' then n = n * 1024 - elseif suffix == 'm' or suffix == 'mb' or suffix == 'mib' then n = n * 1024 * 1024 - elseif suffix == 'g' or suffix == 'gb' or suffix == 'gib' then n = n * 1024 * 1024 * 1024 - elseif suffix ~= '' and suffix ~= 'b' then error(('invalid %s=%q'):format(name, raw), 0) end - return n + local raw = os.getenv(name) + if raw == nil or raw == '' then return default_value end + raw = tostring(raw):match('^%s*(.-)%s*$') + local number, suffix = raw:match('^(%d+)%s*([kKmMgG]?[iI]?[bB]?)$') + if number == nil then error(('invalid %s=%q'):format(name, raw), 0) end + local n = tonumber(number) + suffix = suffix:lower() + if suffix == 'k' or suffix == 'kb' or suffix == 'kib' then n = n * 1024 + elseif suffix == 'm' or suffix == 'mb' or suffix == 'mib' then n = n * 1024 * 1024 + elseif suffix == 'g' or suffix == 'gb' or suffix == 'gib' then n = n * 1024 * 1024 * 1024 + elseif suffix ~= '' and suffix ~= 'b' then error(('invalid %s=%q'):format(name, raw), 0) end + return n end local FULL_FAULT_MATRIX = env_bool('MCU_HTTP_UART_FULL_FAULT_MATRIX', false) @@ -71,127 +71,127 @@ local FAULT_BLOB_BYTES = env_size_bytes('MCU_HTTP_UART_FAULT_BLOB_BYTES', 8 * 10 local function cases() - return { - { - name = 'standalone-bad-json-both-directions', - blob_bytes = FAULT_BLOB_BYTES, - uart = { - malformed_line_count = 1, - faults = { - cm5_to_mcu = { malformed_line_count = 2, malformed_line_first_at = 1536, malformed_line_every_bytes = 4096 }, - mcu_to_cm5 = { malformed_line_count = 2, malformed_line_first_at = 512, malformed_line_every_bytes = 4096 }, - }, - }, - assert_stats = function (st) - assert_at_least(st.malformed_lines, 2, 'malformed lines') - end, - }, - { - name = 'single-byte-loss-in-cm5-bulk-frame', - blob_bytes = FAULT_BLOB_BYTES, - uart = { - malformed_line_count = 0, - faults = { - cm5_to_mcu = { disable_malformed = true, drop_byte_after_bytes = 3072 }, - mcu_to_cm5 = { disable_malformed = true }, - }, - }, - assert_stats = function (st) - assert_at_least(st.dropped_bytes, 1, 'dropped bytes') - end, - }, - { - name = 'single-byte-loss-in-mcu-control-frame', - blob_bytes = FAULT_BLOB_BYTES, - uart = { - malformed_line_count = 0, - faults = { - cm5_to_mcu = { disable_malformed = true }, - mcu_to_cm5 = { disable_malformed = true, drop_byte_in_frame_type = 'xfer_need' }, - }, - }, - assert_stats = function (st) - assert_at_least(st.dropped_bytes, 1, 'dropped bytes') - end, - }, - { - name = 'long-pauses-both-directions', - blob_bytes = FAULT_BLOB_BYTES, - uart = { - malformed_line_count = 0, - faults = { - cm5_to_mcu = { disable_malformed = true, pause_once_after_bytes = 4096, pause_s = 0.20 }, - mcu_to_cm5 = { disable_malformed = true, pause_once_after_bytes = 2048, pause_s = 0.20 }, - }, - }, - assert_stats = function (st) - assert_at_least(st.fault_pauses, 2, 'fault pauses') - end, - }, - { - name = 'combined-bad-json-byte-loss-and-pauses', - blob_bytes = FAULT_BLOB_BYTES, - uart = { - malformed_line_count = 1, - faults = { - cm5_to_mcu = { - malformed_line_count = 1, - malformed_line_first_at = 1536, - drop_byte_after_bytes = 8192, - pause_once_after_bytes = 4096, - pause_s = 0.20, - }, - mcu_to_cm5 = { - malformed_line_count = 1, - malformed_line_first_at = 1024, - drop_byte_in_frame_type = 'xfer_need', - pause_once_after_bytes = 1024, - pause_s = 0.20, - }, - }, - }, - assert_stats = function (st) - assert_at_least(st.malformed_lines, 1, 'malformed lines') - assert_at_least(st.dropped_bytes, 1, 'dropped bytes') - assert_at_least(st.fault_pauses, 2, 'fault pauses') - end, - }, - } + return { + { + name = 'standalone-bad-json-both-directions', + blob_bytes = FAULT_BLOB_BYTES, + uart = { + malformed_line_count = 1, + faults = { + cm5_to_mcu = { malformed_line_count = 2, malformed_line_first_at = 1536, malformed_line_every_bytes = 4096 }, + mcu_to_cm5 = { malformed_line_count = 2, malformed_line_first_at = 512, malformed_line_every_bytes = 4096 }, + }, + }, + assert_stats = function (st) + assert_at_least(st.malformed_lines, 2, 'malformed lines') + end, + }, + { + name = 'single-byte-loss-in-cm5-bulk-frame', + blob_bytes = FAULT_BLOB_BYTES, + uart = { + malformed_line_count = 0, + faults = { + cm5_to_mcu = { disable_malformed = true, drop_byte_after_bytes = 3072 }, + mcu_to_cm5 = { disable_malformed = true }, + }, + }, + assert_stats = function (st) + assert_at_least(st.dropped_bytes, 1, 'dropped bytes') + end, + }, + { + name = 'single-byte-loss-in-mcu-control-frame', + blob_bytes = FAULT_BLOB_BYTES, + uart = { + malformed_line_count = 0, + faults = { + cm5_to_mcu = { disable_malformed = true }, + mcu_to_cm5 = { disable_malformed = true, drop_byte_in_frame_type = 'xfer_need' }, + }, + }, + assert_stats = function (st) + assert_at_least(st.dropped_bytes, 1, 'dropped bytes') + end, + }, + { + name = 'long-pauses-both-directions', + blob_bytes = FAULT_BLOB_BYTES, + uart = { + malformed_line_count = 0, + faults = { + cm5_to_mcu = { disable_malformed = true, pause_once_after_bytes = 4096, pause_s = 0.20 }, + mcu_to_cm5 = { disable_malformed = true, pause_once_after_bytes = 2048, pause_s = 0.20 }, + }, + }, + assert_stats = function (st) + assert_at_least(st.fault_pauses, 2, 'fault pauses') + end, + }, + { + name = 'combined-bad-json-byte-loss-and-pauses', + blob_bytes = FAULT_BLOB_BYTES, + uart = { + malformed_line_count = 1, + faults = { + cm5_to_mcu = { + malformed_line_count = 1, + malformed_line_first_at = 1536, + drop_byte_after_bytes = 8192, + pause_once_after_bytes = 4096, + pause_s = 0.20, + }, + mcu_to_cm5 = { + malformed_line_count = 1, + malformed_line_first_at = 1024, + drop_byte_in_frame_type = 'xfer_need', + pause_once_after_bytes = 1024, + pause_s = 0.20, + }, + }, + }, + assert_stats = function (st) + assert_at_least(st.malformed_lines, 1, 'malformed lines') + assert_at_least(st.dropped_bytes, 1, 'dropped bytes') + assert_at_least(st.fault_pauses, 2, 'fault pauses') + end, + }, + } end function T.go_devhost_pty_fiendish_transport_recovery_matrix() - for i, case in ipairs(cases()) do - if FULL_FAULT_MATRIX or i <= 3 then - local result = run_case(case) - if result and result.skipped then return end - else - log('case skipped in normal CI: ' .. tostring(case.name) .. ' (set MCU_HTTP_UART_FULL_FAULT_MATRIX=1)') - end - end + for i, case in ipairs(cases()) do + if FULL_FAULT_MATRIX or i <= 3 then + local result = run_case(case) + if result and result.skipped then return end + else + log('case skipped in normal CI: ' .. tostring(case.name) .. ' (set MCU_HTTP_UART_FULL_FAULT_MATRIX=1)') + end + end end function T.go_devhost_pty_destructive_newline_loss_documents_stop_wait_failure() - local enabled = os.getenv('MCU_HTTP_UART_DESTRUCTIVE_FAULTS') - if enabled ~= '1' and enabled ~= 'true' then - log('skipping destructive newline-loss case; set MCU_HTTP_UART_DESTRUCTIVE_FAULTS=1') - return - end - run_case({ - name = 'destructive-newline-loss-mcu-to-cm5', - blob_bytes = FAULT_BLOB_BYTES, - stage_timeout_s = 25.0, - timeout_s = 80.0, - uart = { - malformed_line_count = 0, - faults = { - cm5_to_mcu = { disable_malformed = true }, - mcu_to_cm5 = { disable_malformed = true, drop_next_newline_after_bytes = 3072 }, - }, - }, - assert_stats = function (st) - assert_at_least(st.dropped_newlines, 1, 'dropped newlines') - end, - }) + local enabled = os.getenv('MCU_HTTP_UART_DESTRUCTIVE_FAULTS') + if enabled ~= '1' and enabled ~= 'true' then + log('skipping destructive newline-loss case; set MCU_HTTP_UART_DESTRUCTIVE_FAULTS=1') + return + end + run_case({ + name = 'destructive-newline-loss-mcu-to-cm5', + blob_bytes = FAULT_BLOB_BYTES, + stage_timeout_s = 25.0, + timeout_s = 80.0, + uart = { + malformed_line_count = 0, + faults = { + cm5_to_mcu = { disable_malformed = true }, + mcu_to_cm5 = { disable_malformed = true, drop_next_newline_after_bytes = 3072 }, + }, + }, + assert_stats = function (st) + assert_at_least(st.dropped_newlines, 1, 'dropped newlines') + end, + }) end return T diff --git a/tests/integration/devhost/mcu_update_http_uart_spec.lua b/tests/integration/devhost/mcu_update_http_uart_spec.lua index c52f8630..0eeb4fca 100644 --- a/tests/integration/devhost/mcu_update_http_uart_spec.lua +++ b/tests/integration/devhost/mcu_update_http_uart_spec.lua @@ -61,49 +61,49 @@ local DEFAULT_CI_MCU_BLOB_BYTES = 6 * 1024 local LARGE_MCU_BLOB_ENV = 'MCU_HTTP_UART_BLOB_BYTES' local function env_size_bytes(name, default_value) - local raw = os.getenv(name) - if raw == nil or raw == '' then return default_value end - raw = tostring(raw):match('^%s*(.-)%s*$') - local number, suffix = raw:match('^(%d+)%s*([kKmMgG]?[iI]?[bB]?)$') - if number == nil then - error(('invalid %s=%q; expected bytes, KiB, MiB or GiB, for example 204800 or 200K'):format(name, raw), 0) - end - local n = tonumber(number) - suffix = suffix:lower() - if suffix == 'k' or suffix == 'kb' or suffix == 'kib' then - n = n * 1024 - elseif suffix == 'm' or suffix == 'mb' or suffix == 'mib' then - n = n * 1024 * 1024 - elseif suffix == 'g' or suffix == 'gb' or suffix == 'gib' then - n = n * 1024 * 1024 * 1024 - elseif suffix ~= '' and suffix ~= 'b' then - error(('invalid %s=%q; expected bytes, KiB, MiB or GiB, for example 204800 or 200K'):format(name, raw), 0) - end - if n <= 0 then - error(('%s must be positive'):format(name), 0) - end - return n + local raw = os.getenv(name) + if raw == nil or raw == '' then return default_value end + raw = tostring(raw):match('^%s*(.-)%s*$') + local number, suffix = raw:match('^(%d+)%s*([kKmMgG]?[iI]?[bB]?)$') + if number == nil then + error(('invalid %s=%q; expected bytes, KiB, MiB or GiB, for example 204800 or 200K'):format(name, raw), 0) + end + local n = tonumber(number) + suffix = suffix:lower() + if suffix == 'k' or suffix == 'kb' or suffix == 'kib' then + n = n * 1024 + elseif suffix == 'm' or suffix == 'mb' or suffix == 'mib' then + n = n * 1024 * 1024 + elseif suffix == 'g' or suffix == 'gb' or suffix == 'gib' then + n = n * 1024 * 1024 * 1024 + elseif suffix ~= '' and suffix ~= 'b' then + error(('invalid %s=%q; expected bytes, KiB, MiB or GiB, for example 204800 or 200K'):format(name, raw), 0) + end + if n <= 0 then + error(('%s must be positive'):format(name), 0) + end + return n end local function env_number(name, default_value) - local raw = os.getenv(name) - if raw == nil or raw == '' then return default_value end - raw = tostring(raw):match('^%s*(.-)%s*$') - local n = tonumber(raw) - if n == nil or n <= 0 then - error(('%s must be a positive number'):format(name), 0) - end - return n + local raw = os.getenv(name) + if raw == nil or raw == '' then return default_value end + raw = tostring(raw):match('^%s*(.-)%s*$') + local n = tonumber(raw) + if n == nil or n <= 0 then + error(('%s must be a positive number'):format(name), 0) + end + return n end local function env_bool(name, default_value) - local raw = os.getenv(name) - if raw == nil or raw == '' then return default_value end - raw = tostring(raw):lower():match('^%s*(.-)%s*$') - if raw == '1' or raw == 'true' or raw == 'yes' or raw == 'on' then return true end - if raw == '0' or raw == 'false' or raw == 'no' or raw == 'off' then return false end - error(('%s must be boolean-like'):format(name), 0) + local raw = os.getenv(name) + if raw == nil or raw == '' then return default_value end + raw = tostring(raw):lower():match('^%s*(.-)%s*$') + if raw == '1' or raw == 'true' or raw == 'yes' or raw == 'on' then return true end + if raw == '0' or raw == 'false' or raw == 'no' or raw == 'off' then return false end + error(('%s must be boolean-like'):format(name), 0) end local UART_BYTES_PER_SEC = env_number('MCU_HTTP_UART_BYTES_PER_SEC', env_number('MCU_HTTP_UART_BAUD', 921600) / 10) @@ -129,1596 +129,1596 @@ local function assert_eq(a, b, msg) if a ~= b then fail(msg or ('expected ' .. t local function assert_true(v, msg) if v ~= true then fail(msg or ('expected true, got ' .. tostring(v))) end end local function assert_not_nil(v, msg) if v == nil then fail(msg or 'expected non-nil') end end local function assert_contains(haystack, needle, msg) - haystack = tostring(haystack or '') - needle = tostring(needle or '') - if haystack:find(needle, 1, true) == nil then - fail(msg or ('expected ' .. haystack .. ' to contain ' .. needle)) - end + haystack = tostring(haystack or '') + needle = tostring(needle or '') + if haystack:find(needle, 1, true) == nil then + fail(msg or ('expected ' .. haystack .. ' to contain ' .. needle)) + end end local function log(msg) - if not MCU_HTTP_UART_VERBOSE then return end - io.stderr:write('[mcu-http-uart] ' .. tostring(msg) .. '\n') + if not MCU_HTTP_UART_VERBOSE then return end + io.stderr:write('[mcu-http-uart] ' .. tostring(msg) .. '\n') end local function shquote(s) - return "'" .. tostring(s):gsub("'", "'\\''") .. "'" + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end local function env_non_empty(name) - local v = os.getenv(name) - if v == nil or v == '' then return nil end - v = tostring(v):match('^%s*(.-)%s*$') - if v == '' then return nil end - return v + local v = os.getenv(name) + if v == nil or v == '' then return nil end + v = tostring(v):match('^%s*(.-)%s*$') + if v == '' then return nil end + return v end local function command_available(name) - local cmd = exec.command('sh', '-c', 'command -v ' .. shquote(name) .. ' >/dev/null 2>&1') - local _out, st, code = fibers.perform(cmd:combined_output_op()) - return st == 'exited' and code == 0 + local cmd = exec.command('sh', '-c', 'command -v ' .. shquote(name) .. ' >/dev/null 2>&1') + local _out, st, code = fibers.perform(cmd:combined_output_op()) + return st == 'exited' and code == 0 end local function mkdir_p(path) - local ok = os.execute('mkdir -p ' .. shquote(path)) - if ok ~= true and ok ~= 0 then error('mkdir failed: ' .. tostring(path), 0) end + local ok = os.execute('mkdir -p ' .. shquote(path)) + if ok ~= true and ok ~= 0 then error('mkdir failed: ' .. tostring(path), 0) end end local function rm_rf(path) - os.execute('rm -rf ' .. shquote(path)) + os.execute('rm -rf ' .. shquote(path)) end local function write_file(path, body) - local f = assert(io.open(path, 'wb')) - assert(f:write(body)) - assert(f:close()) + local f = assert(io.open(path, 'wb')) + assert(f:write(body)) + assert(f:close()) end local function sha256_hex_string(data) - local path = os.tmpname() - write_file(path, data) - local script = table.concat({ - 'set -eu', - 'if command -v sha256sum >/dev/null 2>&1; then', - ' sha256sum ' .. shquote(path) .. " | awk '{ print $1 }'", - 'elif command -v shasum >/dev/null 2>&1; then', - ' shasum -a 256 ' .. shquote(path) .. " | awk '{ print $1 }'", - 'else', - " echo 'sha256sum_or_shasum_required' >&2", - ' exit 127', - 'fi', - }, '\n') - local cmd = exec.command('sh', '-c', script) - local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) - rm_rf(path) - if not (st == 'exited' and code == 0) then - error(('sha256 helper failed: status=%s code=%s signal=%s err=%s output=%s'):format( - tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) - ), 0) - end - local hex = tostring(out or ''):match('([0-9a-fA-F]+)') - if hex == nil or #hex ~= 64 then - error('sha256 helper returned invalid digest: ' .. tostring(out), 0) - end - return hex:lower() + local path = os.tmpname() + write_file(path, data) + local script = table.concat({ + 'set -eu', + 'if command -v sha256sum >/dev/null 2>&1; then', + ' sha256sum ' .. shquote(path) .. " | awk '{ print $1 }'", + 'elif command -v shasum >/dev/null 2>&1; then', + ' shasum -a 256 ' .. shquote(path) .. " | awk '{ print $1 }'", + 'else', + " echo 'sha256sum_or_shasum_required' >&2", + ' exit 127', + 'fi', + }, '\n') + local cmd = exec.command('sh', '-c', script) + local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) + rm_rf(path) + if not (st == 'exited' and code == 0) then + error(('sha256 helper failed: status=%s code=%s signal=%s err=%s output=%s'):format( + tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) + ), 0) + end + local hex = tostring(out or ''):match('([0-9a-fA-F]+)') + if hex == nil or #hex ~= 64 then + error('sha256 helper returned invalid digest: ' .. tostring(out), 0) + end + return hex:lower() end local function temp_roots() - local base = ('/tmp/devicecode-mcu-http-uart-%d-%d'):format(os.time(), math.random(100000, 999999)) - rm_rf(base) - local roots = { - base = base, - config = base .. '/config', - static = base .. '/static', - artifact = { - transient = base .. '/cm5/artifacts/transient', - durable = base .. '/cm5/artifacts/durable', - import = base .. '/cm5/artifacts/import', - }, - control = base .. '/cm5/control/update', - mcu = base .. '/mcu', - } - mkdir_p(roots.config) - mkdir_p(roots.static) - mkdir_p(roots.artifact.transient) - mkdir_p(roots.artifact.durable) - mkdir_p(roots.artifact.import) - mkdir_p(roots.control) - mkdir_p(roots.mcu) - write_file(roots.static .. '/index.html', 'ok') - return roots + local base = ('/tmp/devicecode-mcu-http-uart-%d-%d'):format(os.time(), math.random(100000, 999999)) + rm_rf(base) + local roots = { + base = base, + config = base .. '/config', + static = base .. '/static', + artifact = { + transient = base .. '/cm5/artifacts/transient', + durable = base .. '/cm5/artifacts/durable', + import = base .. '/cm5/artifacts/import', + }, + control = base .. '/cm5/control/update', + mcu = base .. '/mcu', + } + mkdir_p(roots.config) + mkdir_p(roots.static) + mkdir_p(roots.artifact.transient) + mkdir_p(roots.artifact.durable) + mkdir_p(roots.artifact.import) + mkdir_p(roots.control) + mkdir_p(roots.mcu) + write_file(roots.static .. '/index.html', 'ok') + return roots end local function copy_frame(frame) - local out = {} - for k, v in pairs(frame or {}) do - if type(v) == 'table' then - local t = {} - for k2, v2 in pairs(v) do t[k2] = v2 end - out[k] = t - else - out[k] = v - end - end - return out + local out = {} + for k, v in pairs(frame or {}) do + if type(v) == 'table' then + local t = {} + for k2, v2 in pairs(v) do t[k2] = v2 end + out[k] = t + else + out[k] = v + end + end + return out end local function new_line_transport() - local in_tx, in_rx = mailbox.new(128, { full = 'reject_newest' }) - local written = {} - local session = {} - - function session:read_line_op() - return in_rx:recv_op():wrap(function(frame) - if frame == nil then return nil, in_rx:why() or 'closed' end - local line, err = fabric_protocol.encode_line(frame) - if not line then return nil, err end - return line, nil - end) - end - - function session:write_line_op(line) - local frame, err = fabric_protocol.decode_line(line) - if not frame then return fibers.always(nil, err) end - written[#written + 1] = copy_frame(frame) - return fibers.always(true, nil) - end - - function session:flush_op() - return fibers.always(true, nil) - end - - function session:terminate(reason) - in_tx:close(reason or 'transport terminated') - return true, nil - end - - return { session = session, in_tx = in_tx, written = written } + local in_tx, in_rx = mailbox.new(128, { full = 'reject_newest' }) + local written = {} + local session = {} + + function session:read_line_op() + return in_rx:recv_op():wrap(function(frame) + if frame == nil then return nil, in_rx:why() or 'closed' end + local line, err = fabric_protocol.encode_line(frame) + if not line then return nil, err end + return line, nil + end) + end + + function session:write_line_op(line) + local frame, err = fabric_protocol.decode_line(line) + if not frame then return fibers.always(nil, err) end + written[#written + 1] = copy_frame(frame) + return fibers.always(true, nil) + end + + function session:flush_op() + return fibers.always(true, nil) + end + + function session:terminate(reason) + in_tx:close(reason or 'transport terminated') + return true, nil + end + + return { session = session, in_tx = in_tx, written = written } end local function connect_line_transports(a, b) - local write_a = a.session.write_line_op - function a.session:write_line_op(line) - local frame = assert(fabric_protocol.decode_line(line)) - write_a(self, line) - return b.in_tx:send_op(copy_frame(frame)) - end + local write_a = a.session.write_line_op + function a.session:write_line_op(line) + local frame = assert(fabric_protocol.decode_line(line)) + write_a(self, line) + return b.in_tx:send_op(copy_frame(frame)) + end - local write_b = b.session.write_line_op - function b.session:write_line_op(line) - local frame = assert(fabric_protocol.decode_line(line)) - write_b(self, line) - return a.in_tx:send_op(copy_frame(frame)) - end + local write_b = b.session.write_line_op + function b.session:write_line_op(line) + local frame = assert(fabric_protocol.decode_line(line)) + write_b(self, line) + return a.in_tx:send_op(copy_frame(frame)) + end end local function wrap_session_op(session) - local wrapped, err = hal_transport.wrap_transport(session) - return fibers.always(wrapped, err) + local wrapped, err = hal_transport.wrap_transport(session) + return fibers.always(wrapped, err) end local function wait_retained_payload_where(conn, topic, label, pred, opts) - opts = opts or {} - local view = conn:retained_view(topic) - local value = probe.wait_versioned_until(label, function () - return view:version() - end, function (seen) - return view:changed_op(seen) - end, function () - local msg = view:get(topic) - local payload = msg and msg.payload or nil - if pred(payload) then return payload end - return nil - end, opts) - view:close() - return value + opts = opts or {} + local view = conn:retained_view(topic) + local value = probe.wait_versioned_until(label, function () + return view:version() + end, function (seen) + return view:changed_op(seen) + end, function () + local msg = view:get(topic) + local payload = msg and msg.payload or nil + if pred(payload) then return payload end + return nil + end, opts) + view:close() + return value end local function topic_string(topic) - if type(topic) ~= 'table' then return tostring(topic) end - local out = {} - for i = 1, #topic do out[i] = tostring(topic[i]) end - return table.concat(out, '/') + if type(topic) ~= 'table' then return tostring(topic) end + local out = {} + for i = 1, #topic do out[i] = tostring(topic[i]) end + return table.concat(out, '/') end local function fabric_payload_snapshot(payload) - if type(payload) ~= 'table' then return nil end - return type(payload.snapshot) == 'table' and payload.snapshot or payload + if type(payload) ~= 'table' then return nil end + return type(payload.snapshot) == 'table' and payload.snapshot or payload end local function compact_fabric_session(s) - s = type(s) == 'table' and s or {} - return ('phase=%s established=%s local=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s last_wire_error=%s why=%s'):format( - tostring(s.phase), tostring(s.established), tostring(s.local_node), tostring(s.peer_node), - tostring(s.peer_sid), tostring(s.session_generation), tostring(s.wire_errors or 0), - tostring(s.bad_frame_count or 0), tostring(s.last_wire_error), tostring(s.why) - ) + s = type(s) == 'table' and s or {} + return ('phase=%s established=%s local=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s last_wire_error=%s why=%s'):format( + tostring(s.phase), tostring(s.established), tostring(s.local_node), tostring(s.peer_node), + tostring(s.peer_sid), tostring(s.session_generation), tostring(s.wire_errors or 0), + tostring(s.bad_frame_count or 0), tostring(s.last_wire_error), tostring(s.why) + ) end local function compact_fabric_bridge(s) - s = type(s) == 'table' and s or {} - return ('state=%s imported=%s pending=%s inbound=%s frames_sent=%s frames_recv=%s session_peer=%s drop=%s err=%s'):format( - tostring(s.state), tostring(s.imported_topics), tostring(s.pending_calls), tostring(s.inbound_calls), - tostring(s.frames_sent), tostring(s.frames_received), - tostring(type(s.session) == 'table' and s.session.peer_sid or nil), - tostring(s.session_drop_reason), tostring(s.last_err) - ) + s = type(s) == 'table' and s or {} + return ('state=%s imported=%s pending=%s inbound=%s frames_sent=%s frames_recv=%s session_peer=%s drop=%s err=%s'):format( + tostring(s.state), tostring(s.imported_topics), tostring(s.pending_calls), tostring(s.inbound_calls), + tostring(s.frames_sent), tostring(s.frames_received), + tostring(type(s.session) == 'table' and s.session.peer_sid or nil), + tostring(s.session_drop_reason), tostring(s.last_err) + ) end local function compact_fabric_transfer(s) - s = type(s) == 'table' and s or {} - local stats = type(s.stats) == 'table' and s.stats or {} - local active = type(s.active) == 'table' and s.active or nil - local last = type(s.last) == 'table' and s.last or nil - return ('active=%s active_status=%s last_status=%s completed=%s failed=%s cancelled=%s stale=%s'):format( - tostring(active ~= nil), tostring(active and active.status), tostring(last and last.status), - tostring(stats.completed), tostring(stats.failed), tostring(stats.cancelled), tostring(stats.stale) - ) + s = type(s) == 'table' and s or {} + local stats = type(s.stats) == 'table' and s.stats or {} + local active = type(s.active) == 'table' and s.active or nil + local last = type(s.last) == 'table' and s.last or nil + return ('active=%s active_status=%s last_status=%s completed=%s failed=%s cancelled=%s stale=%s'):format( + tostring(active ~= nil), tostring(active and active.status), tostring(last and last.status), + tostring(stats.completed), tostring(stats.failed), tostring(stats.cancelled), tostring(stats.stale) + ) end local function compact_fabric_link(s) - s = type(s) == 'table' and s or {} - local comps = {} - if type(s.components) == 'table' then - for name, rec in pairs(s.components) do - comps[#comps + 1] = tostring(name) .. '=' .. tostring(type(rec) == 'table' and rec.status or rec) - end - table.sort(comps) - end - return ('state=%s completed=%s/%s reason=%s components=[%s]'):format( - tostring(s.state), tostring(s.completed), tostring(s.total), tostring(s.reason), table.concat(comps, ',') - ) + s = type(s) == 'table' and s or {} + local comps = {} + if type(s.components) == 'table' then + for name, rec in pairs(s.components) do + comps[#comps + 1] = tostring(name) .. '=' .. tostring(type(rec) == 'table' and rec.status or rec) + end + table.sort(comps) + end + return ('state=%s completed=%s/%s reason=%s components=[%s]'):format( + tostring(s.state), tostring(s.completed), tostring(s.total), tostring(s.reason), table.concat(comps, ',') + ) end local function describe_fabric_status_payload(payload, component) - local s = fabric_payload_snapshot(payload) - if component == 'session' then return compact_fabric_session(s) end - if component == 'rpc_bridge' then return compact_fabric_bridge(s) end - if component == 'transfer_manager' or component == 'transfer' then return compact_fabric_transfer(s) end - return compact_fabric_link(s) + local s = fabric_payload_snapshot(payload) + if component == 'session' then return compact_fabric_session(s) end + if component == 'rpc_bridge' then return compact_fabric_bridge(s) end + if component == 'transfer_manager' or component == 'transfer' then return compact_fabric_transfer(s) end + return compact_fabric_link(s) end local function retained_payload_now(conn, topic) - local view = conn:retained_view(topic) - local msg = view:get(topic) - view:close() - return msg and msg.payload or nil + local view = conn:retained_view(topic) + local msg = view:get(topic) + view:close() + return msg and msg.payload or nil end local function log_fabric_status(conn, prefix) - prefix = prefix or 'fabric' - local topics = { - { label = 'link', topic = fabric_topics.state_link('link-a') }, - { label = 'session', component = 'session', topic = fabric_topics.state_link_component('link-a', 'session') }, - { label = 'rpc_bridge', component = 'rpc_bridge', topic = fabric_topics.state_link_component('link-a', 'rpc_bridge') }, - { label = 'transfer_manager', component = 'transfer_manager', topic = fabric_topics.state_link_component('link-a', 'transfer_manager') }, - } - for _, item in ipairs(topics) do - local payload = retained_payload_now(conn, item.topic) - log(('%s %s %s -> %s'):format(prefix, item.label, topic_string(item.topic), describe_fabric_status_payload(payload, item.component))) - end + prefix = prefix or 'fabric' + local topics = { + { label = 'link', topic = fabric_topics.state_link('link-a') }, + { label = 'session', component = 'session', topic = fabric_topics.state_link_component('link-a', 'session') }, + { label = 'rpc_bridge', component = 'rpc_bridge', topic = fabric_topics.state_link_component('link-a', 'rpc_bridge') }, + { label = 'transfer_manager', component = 'transfer_manager', topic = fabric_topics.state_link_component('link-a', 'transfer_manager') }, + } + for _, item in ipairs(topics) do + local payload = retained_payload_now(conn, item.topic) + log(('%s %s %s -> %s'):format(prefix, item.label, topic_string(item.topic), describe_fabric_status_payload(payload, item.component))) + end end local function wait_fabric_session_established(conn, label, opts) - opts = opts or {} - local topic = fabric_topics.state_link_component('link-a', 'session') - local payload = wait_retained_payload_where(conn, topic, label or 'fabric session established', function (p) - local s = fabric_payload_snapshot(p) - if type(s) == 'table' and s.established == true and type(s.peer_sid) == 'string' and s.peer_sid ~= '' then - return p - end - return nil - end, { timeout = opts.timeout or 6.0 }) - log((label or 'fabric session established') .. ': ' .. describe_fabric_status_payload(payload, 'session')) - return payload + opts = opts or {} + local topic = fabric_topics.state_link_component('link-a', 'session') + local payload = wait_retained_payload_where(conn, topic, label or 'fabric session established', function (p) + local s = fabric_payload_snapshot(p) + if type(s) == 'table' and s.established == true and type(s.peer_sid) == 'string' and s.peer_sid ~= '' then + return p + end + return nil + end, { timeout = opts.timeout or 6.0 }) + log((label or 'fabric session established') .. ': ' .. describe_fabric_status_payload(payload, 'session')) + return payload end local function fabric_progress_fragment(conn) - if conn == nil then return '' end - local session = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'session')) - local bridge = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'rpc_bridge')) - local transfer = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'transfer_manager')) - return ('fabric_session=(%s) fabric_bridge=(%s) fabric_transfer=(%s)'):format( - describe_fabric_status_payload(session, 'session'), - describe_fabric_status_payload(bridge, 'rpc_bridge'), - describe_fabric_status_payload(transfer, 'transfer_manager') - ) + if conn == nil then return '' end + local session = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'session')) + local bridge = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'rpc_bridge')) + local transfer = retained_payload_now(conn, fabric_topics.state_link_component('link-a', 'transfer_manager')) + return ('fabric_session=(%s) fabric_bridge=(%s) fabric_transfer=(%s)'):format( + describe_fabric_status_payload(session, 'session'), + describe_fabric_status_payload(bridge, 'rpc_bridge'), + describe_fabric_status_payload(transfer, 'transfer_manager') + ) end local function fresh_uart_manager() - -- The UART manager is currently module-singleton state. The filtered test - -- runner still requires every spec module before it runs the selected test, - -- so make this test's direct manager instance explicit and isolated. - package.loaded['services.hal.managers.uart'] = nil - package.loaded['services.hal.drivers.uart'] = nil - return require 'services.hal.managers.uart' + -- The UART manager is currently module-singleton state. The filtered test + -- runner still requires every spec module before it runs the selected test, + -- so make this test's direct manager instance explicit and isolated. + package.loaded['services.hal.managers.uart'] = nil + package.loaded['services.hal.drivers.uart'] = nil + return require 'services.hal.managers.uart' end local function dummy_logger() - local logger = {} - for _, k in ipairs({ 'debug', 'info', 'warn', 'error' }) do - logger[k] = function () end - end - function logger:child() return self end - return logger + local logger = {} + for _, k in ipairs({ 'debug', 'info', 'warn', 'error' }) do + logger[k] = function () end + end + function logger:child() return self end + return logger end local function wait_channel_get(ch, timeout_s, what) - local which, a, b = fibers.perform(op.named_choice({ - item = ch:get_op(), - timeout = sleep.sleep_op(timeout_s or 1.0), - })) - if which == 'timeout' then - error(('timed out waiting for %s'):format(what or 'channel item'), 0) - end - if a == nil then - error(('channel closed while waiting for %s: %s'):format(what or 'channel item', tostring(b)), 0) - end - return a + local which, a, b = fibers.perform(op.named_choice({ + item = ch:get_op(), + timeout = sleep.sleep_op(timeout_s or 1.0), + })) + if which == 'timeout' then + error(('timed out waiting for %s'):format(what or 'channel item'), 0) + end + if a == nil then + error(('channel closed while waiting for %s: %s'):format(what or 'channel item', tostring(b)), 0) + end + return a end local function wait_until_test(label, pred, timeout_s, interval_s) - local deadline = fibers.now() + (timeout_s or 2.0) - while fibers.now() < deadline do - if pred() then return true end - fibers.perform(sleep.sleep_op(interval_s or 0.05)) - end - error('timed out waiting for ' .. tostring(label), 0) + local deadline = fibers.now() + (timeout_s or 2.0) + while fibers.now() < deadline do + if pred() then return true end + fibers.perform(sleep.sleep_op(interval_s or 0.05)) + end + error('timed out waiting for ' .. tostring(label), 0) end local function wait_device_event(dev_ev_ch, event_type, class, id, timeout_s) - local deadline = fibers.now() + (timeout_s or 1.5) - while fibers.now() < deadline do - local ev = wait_channel_get(dev_ev_ch, deadline - fibers.now(), 'UART device event') - if ev.event_type == event_type and ev.class == class and ev.id == id then - return ev - end - end - error(('timed out waiting for UART device event %s %s/%s'):format( - tostring(event_type), tostring(class), tostring(id) - ), 0) + local deadline = fibers.now() + (timeout_s or 1.5) + while fibers.now() < deadline do + local ev = wait_channel_get(dev_ev_ch, deadline - fibers.now(), 'UART device event') + if ev.event_type == event_type and ev.class == class and ev.id == id then + return ev + end + end + error(('timed out waiting for UART device event %s %s/%s'):format( + tostring(event_type), tostring(class), tostring(id) + ), 0) end local function wait_uart_cap(dev_ev_ch) - local added = wait_device_event(dev_ev_ch, 'added', 'uart', 'uart0', 1.5) - assert_true(type(added.capabilities) == 'table' and #added.capabilities == 1, 'UART added event missing capability') - local cap = added.capabilities[1] - assert_eq(cap.class, 'uart') - assert_eq(cap.id, 'uart0') - assert_true(type(cap.control_ch) == 'table', 'UART capability should expose control_ch') - return cap + local added = wait_device_event(dev_ev_ch, 'added', 'uart', 'uart0', 1.5) + assert_true(type(added.capabilities) == 'table' and #added.capabilities == 1, 'UART added event missing capability') + local cap = added.capabilities[1] + assert_eq(cap.class, 'uart') + assert_eq(cap.id, 'uart0') + assert_true(type(cap.control_ch) == 'table', 'UART capability should expose control_ch') + return cap end local function normalise_uart_open_opts(opts) - if opts == nil or getmetatable(opts) ~= cap_args.UARTOpenOpts then - local open_opts, err = cap_args.new.UARTOpenOpts(opts) - assert_not_nil(open_opts, tostring(err)) - return open_opts - end - return opts + if opts == nil or getmetatable(opts) ~= cap_args.UARTOpenOpts then + local open_opts, err = cap_args.new.UARTOpenOpts(opts) + assert_not_nil(open_opts, tostring(err)) + return open_opts + end + return opts end local function call_hal_control(cap, verb, opts) - local reply_ch = channel.new(1) - local req, err = hal_types.new.ControlRequest(verb, opts or {}, reply_ch) - assert_not_nil(req, tostring(err)) + local reply_ch = channel.new(1) + local req, err = hal_types.new.ControlRequest(verb, opts or {}, reply_ch) + assert_not_nil(req, tostring(err)) - fibers.perform(cap.control_ch:put_op(req)) + fibers.perform(cap.control_ch:put_op(req)) - local reply = wait_channel_get(reply_ch, 1.0, 'HAL UART control reply') - assert_true(type(reply) == 'table', 'HAL control reply must be a table') - return reply + local reply = wait_channel_get(reply_ch, 1.0, 'HAL UART control reply') + assert_true(type(reply) == 'table', 'HAL control reply must be a table') + return reply end local function expose_raw_host_uart_open(scope, bus, cap, source) - source = source or 'uart_manager' - local conn = bus:connect({ origin_base = { service = 'hal-uart-test-adapter' } }) - local cap_id = cap.id - local ep = conn:bind({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'rpc', 'open' }, { - queue_len = 8, - }) - - conn:retain({ 'raw', 'host', source, 'status' }, { - state = 'available', - available = true, - source = source, - class = 'uart', - id = cap_id, - }) - conn:retain({ 'raw', 'host', source, 'meta' }, { - source = source, - class = 'uart', - id = cap_id, - }) - conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { - state = 'available', - available = true, - source_kind = 'host', - source = source, - }) - conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }, { - source_kind = 'host', - source = source, - offerings = { open = true }, - }) - - -- Register cleanup before spawning work on this scope. lua-fibers only - -- allows adding finalisers to a started scope from within that same scope. - scope:finally(function () - safe.pcall(function () ep:unbind() end) - safe.pcall(function () conn:unretain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }) end) - safe.pcall(function () conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { - state = 'removed', - available = false, - source_kind = 'host', - source = source, - }) end) - safe.pcall(function () conn:unretain({ 'raw', 'host', source, 'meta' }) end) - safe.pcall(function () conn:retain({ 'raw', 'host', source, 'status' }, { - state = 'removed', - available = false, - source = source, - class = 'uart', - id = cap_id, - }) end) - bus_cleanup.disconnect(conn) - end) - - local ok_spawn, spawn_err = scope:spawn(function () - while true do - local req = ep:recv() - if req == nil then return end - - local open_opts = normalise_uart_open_opts(req.payload) - local reply = call_hal_control(cap, 'open', open_opts) - - local replied = req:reply(reply) - if not replied - and reply.ok == true - and type(reply.reason) == 'table' - and type(reply.reason.session) == 'table' - and type(reply.reason.session.terminate) == 'function' - then - reply.reason.session:terminate('fabric request abandoned') - end - end - end) - assert_true(ok_spawn, tostring(spawn_err)) - - return { source = source, class = 'uart', id = cap_id } + source = source or 'uart_manager' + local conn = bus:connect({ origin_base = { service = 'hal-uart-test-adapter' } }) + local cap_id = cap.id + local ep = conn:bind({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'rpc', 'open' }, { + queue_len = 8, + }) + + conn:retain({ 'raw', 'host', source, 'status' }, { + state = 'available', + available = true, + source = source, + class = 'uart', + id = cap_id, + }) + conn:retain({ 'raw', 'host', source, 'meta' }, { + source = source, + class = 'uart', + id = cap_id, + }) + conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { + state = 'available', + available = true, + source_kind = 'host', + source = source, + }) + conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }, { + source_kind = 'host', + source = source, + offerings = { open = true }, + }) + + -- Register cleanup before spawning work on this scope. lua-fibers only + -- allows adding finalisers to a started scope from within that same scope. + scope:finally(function () + safe.pcall(function () ep:unbind() end) + safe.pcall(function () conn:unretain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }) end) + safe.pcall(function () conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { + state = 'removed', + available = false, + source_kind = 'host', + source = source, + }) end) + safe.pcall(function () conn:unretain({ 'raw', 'host', source, 'meta' }) end) + safe.pcall(function () conn:retain({ 'raw', 'host', source, 'status' }, { + state = 'removed', + available = false, + source = source, + class = 'uart', + id = cap_id, + }) end) + bus_cleanup.disconnect(conn) + end) + + local ok_spawn, spawn_err = scope:spawn(function () + while true do + local req = ep:recv() + if req == nil then return end + + local open_opts = normalise_uart_open_opts(req.payload) + local reply = call_hal_control(cap, 'open', open_opts) + + local replied = req:reply(reply) + if not replied + and reply.ok == true + and type(reply.reason) == 'table' + and type(reply.reason.session) == 'table' + and type(reply.reason.session.terminate) == 'function' + then + reply.reason.session:terminate('fabric request abandoned') + end + end + end) + assert_true(ok_spawn, tostring(spawn_err)) + + return { source = source, class = 'uart', id = cap_id } end local function start_cm5_uart_manager(scope, bus, port) - local uart_mgr = fresh_uart_manager() - local dev_ev_ch = channel.new(16) - local cap_emit_ch = channel.new(32) - - local ok_start, start_err = fibers.perform(uart_mgr.start_op(dummy_logger(), dev_ev_ch, cap_emit_ch)) - assert_true(ok_start, tostring(start_err)) - scope:finally(function () - safe.pcall(function () fibers.perform(uart_mgr.shutdown_op()) end) - end) - - local ok_cfg, cfg_err = fibers.perform(uart_mgr.apply_config_op({ - serial_ports = { - { - id = 'uart0', - path = port.slave_name, - baud = 115200, - mode = '8N1', - }, - }, - })) - assert_true(ok_cfg, tostring(cfg_err)) - - local cap = wait_uart_cap(dev_ev_ch) - local raw_cap = expose_raw_host_uart_open(scope, bus, cap, 'uart_manager') - - local conn = bus:connect({ origin_base = { service = 'hal-uart-test-wait' } }) - wait_retained_payload_where(conn, { 'raw', 'host', raw_cap.source, 'cap', raw_cap.class, raw_cap.id, 'status' }, - 'HAL UART raw capability available', function (p) - return p and p.available == true and p - end, { timeout = 1.0 }) - bus_cleanup.disconnect(conn) - - return raw_cap + local uart_mgr = fresh_uart_manager() + local dev_ev_ch = channel.new(16) + local cap_emit_ch = channel.new(32) + + local ok_start, start_err = fibers.perform(uart_mgr.start_op(dummy_logger(), dev_ev_ch, cap_emit_ch)) + assert_true(ok_start, tostring(start_err)) + scope:finally(function () + safe.pcall(function () fibers.perform(uart_mgr.shutdown_op()) end) + end) + + local ok_cfg, cfg_err = fibers.perform(uart_mgr.apply_config_op({ + serial_ports = { + { + id = 'uart0', + path = port.slave_name, + baud = 115200, + mode = '8N1', + }, + }, + })) + assert_true(ok_cfg, tostring(cfg_err)) + + local cap = wait_uart_cap(dev_ev_ch) + local raw_cap = expose_raw_host_uart_open(scope, bus, cap, 'uart_manager') + + local conn = bus:connect({ origin_base = { service = 'hal-uart-test-wait' } }) + wait_retained_payload_where(conn, { 'raw', 'host', raw_cap.source, 'cap', raw_cap.class, raw_cap.id, 'status' }, + 'HAL UART raw capability available', function (p) + return p and p.available == true and p + end, { timeout = 1.0 }) + bus_cleanup.disconnect(conn) + + return raw_cap end local function wait_job(conn, job_id, state, timeout) - return wait_retained_payload_where(conn, update_topics.update_component('mcu'), 'update job ' .. tostring(job_id) .. ' ' .. tostring(state), function (p) - local job = p and (p.current_job or p.last_job) or nil - if job and job.job_id == job_id and job.state == state then return job end - return nil - end, { timeout = timeout or 4.0 }) + return wait_retained_payload_where(conn, update_topics.update_component('mcu'), 'update job ' .. tostring(job_id) .. ' ' .. tostring(state), function (p) + local job = p and (p.current_job or p.last_job) or nil + if job and job.job_id == job_id and job.state == state then return job end + return nil + end, { timeout = timeout or 4.0 }) end local function describe_job_value(v, depth) - depth = depth or 0 - if type(v) ~= 'table' then return tostring(v) end - if depth >= 2 then return '' end - local parts = {} - for k, vv in pairs(v) do - if type(vv) ~= 'function' then - parts[#parts + 1] = tostring(k) .. '=' .. describe_job_value(vv, depth + 1) - end - end - table.sort(parts) - return '{' .. table.concat(parts, ',') .. '}' + depth = depth or 0 + if type(v) ~= 'table' then return tostring(v) end + if depth >= 2 then return '
' end + local parts = {} + for k, vv in pairs(v) do + if type(vv) ~= 'function' then + parts[#parts + 1] = tostring(k) .. '=' .. describe_job_value(vv, depth + 1) + end + end + table.sort(parts) + return '{' .. table.concat(parts, ',') .. '}' end local function describe_job_progress(p) - if type(p) ~= 'table' then return 'absent' end - local parts = { 'state=' .. tostring(p.state) } - if p.stage_attempt ~= nil then - local st = p.stage_attempt - parts[#parts + 1] = 'stage=' .. tostring(type(st) == 'table' and (st.status or st.state or st.phase) or st) - if type(st) == 'table' and st.err ~= nil then parts[#parts + 1] = 'stage_err=' .. tostring(st.err) end - end - if p.component ~= nil then parts[#parts + 1] = 'component=' .. tostring(p.component) end - if p.err ~= nil then parts[#parts + 1] = 'err=' .. describe_job_value(p.err) end - if p.error ~= nil then parts[#parts + 1] = 'error=' .. describe_job_value(p.error) end - if p.reason ~= nil then parts[#parts + 1] = 'reason=' .. describe_job_value(p.reason) end - return table.concat(parts, ' ') + if type(p) ~= 'table' then return 'absent' end + local parts = { 'state=' .. tostring(p.state) } + if p.stage_attempt ~= nil then + local st = p.stage_attempt + parts[#parts + 1] = 'stage=' .. tostring(type(st) == 'table' and (st.status or st.state or st.phase) or st) + if type(st) == 'table' and st.err ~= nil then parts[#parts + 1] = 'stage_err=' .. tostring(st.err) end + end + if p.component ~= nil then parts[#parts + 1] = 'component=' .. tostring(p.component) end + if p.err ~= nil then parts[#parts + 1] = 'err=' .. describe_job_value(p.err) end + if p.error ~= nil then parts[#parts + 1] = 'error=' .. describe_job_value(p.error) end + if p.reason ~= nil then parts[#parts + 1] = 'reason=' .. describe_job_value(p.reason) end + return table.concat(parts, ' ') end local function wait_job_chatty(conn, job_id, state, timeout, progress_fn) - timeout = timeout or 4.0 - local topic = update_topics.update_component('mcu') - local view = conn:retained_view(topic) - local deadline = fibers.now() + timeout - local last_log = -math.huge - - local function maybe_log(force) - local now = fibers.now() - if not force and (now - last_log) < WAIT_PROGRESS_LOG_S then return end - last_log = now - local msg = view:get(topic) - local payload = msg and msg.payload or nil - local extra = progress_fn and progress_fn() or '' - if extra ~= '' then extra = '; ' .. extra end - log(('waiting for job %s -> %s: %s%s'):format(job_id, state, describe_job_progress(payload), extra)) - end - - local payload = (view:get(topic) or {}).payload - local job = payload and (payload.current_job or payload.last_job) or nil - if job and job.job_id == job_id and job.state == state then - view:close() - return job - end - maybe_log(true) - - local seen = view:version() - while true do - local remaining = deadline - fibers.now() - if remaining <= 0 then - maybe_log(true) - view:close() - error('timed out waiting for update job ' .. tostring(job_id) .. ' ' .. tostring(state), 0) - end - - local which, version, reason = fibers.perform(op.named_choice({ - changed = view:changed_op(seen), - progress = sleep.sleep_op(math.min(WAIT_PROGRESS_LOG_S, remaining)), - })) - - if which == 'changed' then - if version == nil then - view:close() - error('update job ' .. tostring(job_id) .. ' closed: ' .. tostring(reason or 'closed'), 0) - end - seen = version - payload = (view:get(topic) or {}).payload - local job = payload and (payload.current_job or payload.last_job) or nil - if job and job.job_id == job_id and job.state == state then - maybe_log(true) - view:close() - return job - end - end - - maybe_log(which == 'changed') - end + timeout = timeout or 4.0 + local topic = update_topics.update_component('mcu') + local view = conn:retained_view(topic) + local deadline = fibers.now() + timeout + local last_log = -math.huge + + local function maybe_log(force) + local now = fibers.now() + if not force and (now - last_log) < WAIT_PROGRESS_LOG_S then return end + last_log = now + local msg = view:get(topic) + local payload = msg and msg.payload or nil + local extra = progress_fn and progress_fn() or '' + if extra ~= '' then extra = '; ' .. extra end + log(('waiting for job %s -> %s: %s%s'):format(job_id, state, describe_job_progress(payload), extra)) + end + + local payload = (view:get(topic) or {}).payload + local job = payload and (payload.current_job or payload.last_job) or nil + if job and job.job_id == job_id and job.state == state then + view:close() + return job + end + maybe_log(true) + + local seen = view:version() + while true do + local remaining = deadline - fibers.now() + if remaining <= 0 then + maybe_log(true) + view:close() + error('timed out waiting for update job ' .. tostring(job_id) .. ' ' .. tostring(state), 0) + end + + local which, version, reason = fibers.perform(op.named_choice({ + changed = view:changed_op(seen), + progress = sleep.sleep_op(math.min(WAIT_PROGRESS_LOG_S, remaining)), + })) + + if which == 'changed' then + if version == nil then + view:close() + error('update job ' .. tostring(job_id) .. ' closed: ' .. tostring(reason or 'closed'), 0) + end + seen = version + payload = (view:get(topic) or {}).payload + local job = payload and (payload.current_job or payload.last_job) or nil + if job and job.job_id == job_id and job.state == state then + maybe_log(true) + view:close() + return job + end + end + + maybe_log(which == 'changed') + end end local function wait_component_software(conn, image_id, boot_id) - return wait_retained_payload_where(conn, device_topics.component_software('mcu'), 'mcu software canonical state', function (p) - if p and (image_id == nil or p.image_id == image_id) and (boot_id == nil or p.boot_id == boot_id) then return p end - return nil - end, { timeout = 4.0 }) + return wait_retained_payload_where(conn, device_topics.component_software('mcu'), 'mcu software canonical state', function (p) + if p and (image_id == nil or p.image_id == image_id) and (boot_id == nil or p.boot_id == boot_id) then return p end + return nil + end, { timeout = 4.0 }) end local function start_public_fabric(scope, conn, cfg, transport, opts) - opts = opts or {} - local link_overrides = opts.link_overrides - if link_overrides == nil and transport ~= nil then - link_overrides = { - ['link-a'] = { - open_transport_op = function () return wrap_session_op(transport.session) end, - transfer = opts.transfer, - }, - } - end - local ok, err = scope:spawn(function () - fabric.start(conn, { - name = opts.name or 'fabric', - env = 'test', - config = cfg, - link_overrides = link_overrides, - }) - end) - assert_true(ok, tostring(err)) + opts = opts or {} + local link_overrides = opts.link_overrides + if link_overrides == nil and transport ~= nil then + link_overrides = { + ['link-a'] = { + open_transport_op = function () return wrap_session_op(transport.session) end, + transfer = opts.transfer, + }, + } + end + local ok, err = scope:spawn(function () + fabric.start(conn, { + name = opts.name or 'fabric', + env = 'test', + config = cfg, + link_overrides = link_overrides, + }) + end) + assert_true(ok, tostring(err)) end local function fabric_config(local_node, peer_node, bridge, transfer) - return { - schema = fabric.config.SCHEMA, - local_node = local_node, - links = { - { - id = 'link-a', - peer_id = peer_node, - transport = { source = 'test', class = 'jsonl', id = 'link-a' }, - session = { hello_interval_s = 5.0, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, - bridge = bridge or {}, - transfer = transfer or { chunk_size = 2048, timeout_s = 3.0 }, - }, - }, - } + return { + schema = fabric.config.SCHEMA, + local_node = local_node, + links = { + { + id = 'link-a', + peer_id = peer_node, + transport = { source = 'test', class = 'jsonl', id = 'link-a' }, + session = { hello_interval_s = 5.0, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, + bridge = bridge or {}, + transfer = transfer or { chunk_size = 2048, timeout_s = 3.0 }, + }, + }, + } end local function cm5_fabric_config() - return fabric_config('cm5', 'mcu', { - imports = { - { id = 'mcu-state', remote = { 'state', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'state' } }, - { id = 'mcu-event', remote = { 'event', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap', 'telemetry', 'main', 'event' } }, - { id = 'mcu-cap', remote = { 'cap', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap' } }, - }, - rpc = { - outbound = { - { - id = 'mcu-prepare', - ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'prepare-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - timeout_s = 2.0, - }, - { - id = 'mcu-commit', - ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'commit-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - timeout_s = 2.0, - }, - }, - }, - }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) + return fabric_config('cm5', 'mcu', { + imports = { + { id = 'mcu-state', remote = { 'state', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'state' } }, + { id = 'mcu-event', remote = { 'event', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap', 'telemetry', 'main', 'event' } }, + { id = 'mcu-cap', remote = { 'cap', 'self' }, ['local'] = { 'raw', 'member', 'mcu', 'cap' } }, + }, + rpc = { + outbound = { + { + id = 'mcu-prepare', + ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'prepare-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + timeout_s = 2.0, + }, + { + id = 'mcu-commit', + ['local'] = { 'raw', 'member', 'mcu', 'cap', 'updater', 'main', 'rpc', 'commit-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + timeout_s = 2.0, + }, + }, + }, + }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) end local function mcu_fabric_config() - return fabric_config('mcu', 'cm5', { - exports = { - { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, - { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, - }, - rpc = { - inbound = { - { - id = 'mcu-prepare-in', - ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, - timeout_s = 2.0, - }, - { - id = 'mcu-commit-in', - ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, - timeout_s = 2.0, - }, - }, - }, - }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) + return fabric_config('mcu', 'cm5', { + exports = { + { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, + { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, + }, + rpc = { + inbound = { + { + id = 'mcu-prepare-in', + ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, + timeout_s = 2.0, + }, + { + id = 'mcu-commit-in', + ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, + timeout_s = 2.0, + }, + }, + }, + }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) end local function publish_mcu_facts(conn, fake) - local image = fake.committed_image_id or fake.old_image_id - local boot = 'mcu-boot-' .. tostring(fake.boot_seq) - conn:retain({ 'state', 'self', 'software' }, { - image_id = image, - boot_id = boot, - version = image, - }) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'ready', - last_error = nil, - staged_image_id = fake.staged and fake.staged.image_id or nil, - pending_image_id = fake.committed_image_id, - job_id = fake.job_id, - }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { - class = 'updater', - id = 'main', - methods = { 'prepare-update', 'commit-update' }, - }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { - available = true, - state = 'available', - }) + local image = fake.committed_image_id or fake.old_image_id + local boot = 'mcu-boot-' .. tostring(fake.boot_seq) + conn:retain({ 'state', 'self', 'software' }, { + image_id = image, + boot_id = boot, + version = image, + }) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'ready', + last_error = nil, + staged_image_id = fake.staged and fake.staged.image_id or nil, + pending_image_id = fake.committed_image_id, + job_id = fake.job_id, + }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { + class = 'updater', + id = 'main', + methods = { 'prepare-update', 'commit-update' }, + }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { + available = true, + state = 'available', + }) end local function new_mcu_receive_target(fake) - local target = {} - function target:open_sink_op(req) - fake.transfer_begin = req - fake.receive_started_at = fibers.now() - fake.receive_bytes = 0 - fake.receive_chunks = 0 - fake.receive_next_log = MCU_PROGRESS_LOG_BYTES - assert_eq(req.target, 'updater/main') - log(('MCU receive target opened: target=%s size=%s job_id=%s image_id=%s'):format( - tostring(req.target), - tostring(req.size), - tostring(req.meta and req.meta.job_id), - tostring(req.meta and (req.meta.image_id or req.meta.expected_image_id)) - )) - local chunks = {} - local sink = {} - function sink:append_op(chunk) - return fibers.run_scope_op(function () - -- Simulate a small flash/programming delay on the MCU side. - -- This keeps the receiver honest without changing the transfer - -- protocol: it still only asks for the current offset. - fibers.perform(sleep.sleep_op(MCU_FLASH_WRITE_DELAY_S)) - chunks[#chunks + 1] = chunk - fake.receive_bytes = (fake.receive_bytes or 0) + #(chunk or '') - fake.receive_chunks = (fake.receive_chunks or 0) + 1 - if fake.receive_bytes >= (fake.receive_next_log or MCU_PROGRESS_LOG_BYTES) then - local elapsed = fibers.now() - (fake.receive_started_at or fibers.now()) - log(('MCU received %d bytes in %d chunks after %.1fs'):format( - fake.receive_bytes, fake.receive_chunks or 0, elapsed - )) - fake.receive_next_log = fake.receive_bytes + MCU_PROGRESS_LOG_BYTES - end - return true, nil - end):wrap(function (status, _report, ok, err) - if status ~= 'ok' then return nil, err or status end - return ok, err - end) - end - function sink:commit_op(req2) - local bytes = table.concat(chunks) - log(('MCU transfer commit: %d bytes in %d chunks; digest=%s'):format( - #bytes, fake.receive_chunks or 0, tostring(req2.digest) - )) - fake.staged = { - bytes = bytes, - digest = req2.digest, - size = req2.size, - image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), - job_id = req.meta and req.meta.job_id, - } - fake.staged_signal = (fake.staged_signal or 0) + 1 - return fibers.always({ staged = true, digest = req2.digest }, nil) - end - function sink:abort(reason) - fake.abort_reason = reason - fake.abort_count = (fake.abort_count or 0) + 1 - log(('MCU transfer abort: reason=%s after %d bytes in %d chunks'):format( - tostring(reason), fake.receive_bytes or 0, fake.receive_chunks or 0 - )) - return true, nil - end - return fibers.always(sink, nil) - end - return target + local target = {} + function target:open_sink_op(req) + fake.transfer_begin = req + fake.receive_started_at = fibers.now() + fake.receive_bytes = 0 + fake.receive_chunks = 0 + fake.receive_next_log = MCU_PROGRESS_LOG_BYTES + assert_eq(req.target, 'updater/main') + log(('MCU receive target opened: target=%s size=%s job_id=%s image_id=%s'):format( + tostring(req.target), + tostring(req.size), + tostring(req.meta and req.meta.job_id), + tostring(req.meta and (req.meta.image_id or req.meta.expected_image_id)) + )) + local chunks = {} + local sink = {} + function sink:append_op(chunk) + return fibers.run_scope_op(function () + -- Simulate a small flash/programming delay on the MCU side. + -- This keeps the receiver honest without changing the transfer + -- protocol: it still only asks for the current offset. + fibers.perform(sleep.sleep_op(MCU_FLASH_WRITE_DELAY_S)) + chunks[#chunks + 1] = chunk + fake.receive_bytes = (fake.receive_bytes or 0) + #(chunk or '') + fake.receive_chunks = (fake.receive_chunks or 0) + 1 + if fake.receive_bytes >= (fake.receive_next_log or MCU_PROGRESS_LOG_BYTES) then + local elapsed = fibers.now() - (fake.receive_started_at or fibers.now()) + log(('MCU received %d bytes in %d chunks after %.1fs'):format( + fake.receive_bytes, fake.receive_chunks or 0, elapsed + )) + fake.receive_next_log = fake.receive_bytes + MCU_PROGRESS_LOG_BYTES + end + return true, nil + end):wrap(function (status, _report, ok, err) + if status ~= 'ok' then return nil, err or status end + return ok, err + end) + end + function sink:commit_op(req2) + local bytes = table.concat(chunks) + log(('MCU transfer commit: %d bytes in %d chunks; digest=%s'):format( + #bytes, fake.receive_chunks or 0, tostring(req2.digest) + )) + fake.staged = { + bytes = bytes, + digest = req2.digest, + size = req2.size, + image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), + job_id = req.meta and req.meta.job_id, + } + fake.staged_signal = (fake.staged_signal or 0) + 1 + return fibers.always({ staged = true, digest = req2.digest }, nil) + end + function sink:abort(reason) + fake.abort_reason = reason + fake.abort_count = (fake.abort_count or 0) + 1 + log(('MCU transfer abort: reason=%s after %d bytes in %d chunks'):format( + tostring(reason), fake.receive_bytes or 0, fake.receive_chunks or 0 + )) + return true, nil + end + return fibers.always(sink, nil) + end + return target end local function start_fake_mcu(scope, bus, fake) - local conn = bus:connect({ origin_base = { service = 'fake-mcu' } }) - fake.boot_seq = (fake.boot_seq or 0) + 1 - publish_mcu_facts(conn, fake) - - local eps = {} - local function bind(topic) - local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) - assert_not_nil(ep, err) - eps[#eps + 1] = ep - return ep - end - - local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) - local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) - - scope:finally(function () - for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end - bus_cleanup.disconnect(conn) - end) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(prepare_ep:recv_op()) - if req == nil then return end - fake.prepare_payload = req.payload - assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') - fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil - conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) - req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) - end - end)) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(commit_ep:recv_op()) - if req == nil then return end - fake.commit_payload = req.payload - assert_eq(type(req.payload), 'table') - assert_eq(req.payload.job_id, fake.job_id) - assert_eq(req.payload.metadata, nil) - fake.commit_seen = true - fake.committed_image_id = (fake.staged and fake.staged.image_id) - or (type(req.payload) == 'table' and req.payload.expected_image_id) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'rebooting', - last_error = nil, - pending_image_id = fake.committed_image_id, - staged_image_id = fake.staged and fake.staged.image_id or nil, - job_id = fake.job_id, - }) - req:reply({ accepted = true, reboot_required = true }) - end - end)) - - return conn + local conn = bus:connect({ origin_base = { service = 'fake-mcu' } }) + fake.boot_seq = (fake.boot_seq or 0) + 1 + publish_mcu_facts(conn, fake) + + local eps = {} + local function bind(topic) + local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) + assert_not_nil(ep, err) + eps[#eps + 1] = ep + return ep + end + + local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) + local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) + + scope:finally(function () + for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end + bus_cleanup.disconnect(conn) + end) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(prepare_ep:recv_op()) + if req == nil then return end + fake.prepare_payload = req.payload + assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') + fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil + conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) + req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) + end + end)) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(commit_ep:recv_op()) + if req == nil then return end + fake.commit_payload = req.payload + assert_eq(type(req.payload), 'table') + assert_eq(req.payload.job_id, fake.job_id) + assert_eq(req.payload.metadata, nil) + fake.commit_seen = true + fake.committed_image_id = (fake.staged and fake.staged.image_id) + or (type(req.payload) == 'table' and req.payload.expected_image_id) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'rebooting', + last_error = nil, + pending_image_id = fake.committed_image_id, + staged_image_id = fake.staged and fake.staged.image_id or nil, + job_id = fake.job_id, + }) + req:reply({ accepted = true, reboot_required = true }) + end + end)) + + return conn end local function new_fabric_client(conn) - local client = {} - function client:send_blob_op(params, opts) - params = params or {} - opts = opts or {} - assert(params.source_owner, 'fabric client source_owner required') - return conn:call_op(fabric_topics.transfer_manager_rpc('send-blob'), { - link_id = params.link_id or 'link-a', - request_id = params.request_id or ('device-stage-' .. tostring(params.job_id or os.clock())), - xfer_id = params.xfer_id, - target = assert(params.target, 'fabric client params.target required'), - source_owner = params.source_owner, - size = params.size, - digest_alg = params.digest_alg, - digest = params.digest, - chunk_size = params.chunk_size or 2048, - meta = params.meta, - timeout_s = opts.timeout or params.timeout or REALISTIC_TRANSFER_TIMEOUT_S, - }, { timeout = opts.timeout or params.timeout or REALISTIC_TRANSFER_TIMEOUT_S }):wrap(function (reply, err) - if reply == nil then return nil, err end - return { - ok = reply.ok, - committed = reply.committed, - transfer = reply, - }, nil - end) - end - return client + local client = {} + function client:send_blob_op(params, opts) + params = params or {} + opts = opts or {} + assert(params.source_owner, 'fabric client source_owner required') + return conn:call_op(fabric_topics.transfer_manager_rpc('send-blob'), { + link_id = params.link_id or 'link-a', + request_id = params.request_id or ('device-stage-' .. tostring(params.job_id or os.clock())), + xfer_id = params.xfer_id, + target = assert(params.target, 'fabric client params.target required'), + source_owner = params.source_owner, + size = params.size, + digest_alg = params.digest_alg, + digest = params.digest, + chunk_size = params.chunk_size or 2048, + meta = params.meta, + timeout_s = opts.timeout or params.timeout or REALISTIC_TRANSFER_TIMEOUT_S, + }, { timeout = opts.timeout or params.timeout or REALISTIC_TRANSFER_TIMEOUT_S }):wrap(function (reply, err) + if reply == nil then return nil, err end + return { + ok = reply.ok, + committed = reply.committed, + transfer = reply, + }, nil + end) + end + return client end local function update_config(opts) - opts = opts or {} - return { - schema = 'devicecode.update/1', - components = { - { - component = 'mcu', - -- Active update owns the phase budget. The Update backend - -- opens the Device bus call without lua-bus' default timeout; - -- active_job.stage composes the backend Op with this deadline. - stage_timeout_s = opts.stage_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S, - }, - }, - } + opts = opts or {} + return { + schema = 'devicecode.update/1', + components = { + { + component = 'mcu', + -- Active update owns the phase budget. The Update backend + -- opens the Device bus call without lua-bus' default timeout; + -- active_job.stage composes the backend Op with this deadline. + stage_timeout_s = opts.stage_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S, + }, + }, + } end local function device_config(opts) - opts = opts or {} - local stage_timeout_s = opts.stage_action_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S - return { - schema = 'devicecode.config/device/1', - components = { - mcu = { - class = 'member', - subtype = 'mcu', - role = 'controller', - member = 'mcu', - required_facts = { 'software', 'updater' }, - facts = mcu_schema.member_fact_topics('mcu'), - events = mcu_schema.member_event_topics('mcu'), - actions = { - ['restart'] = { kind = 'rpc', call_topic = device_topics.raw_member_cap_rpc('mcu', 'control', 'main', 'restart') }, - ['prepare-update'] = { - kind = 'rpc', - call_topic = device_topics.raw_member_cap_rpc('mcu', 'updater', 'main', 'prepare-update'), - timeout_s = 2.0, - }, - ['stage-update'] = { - kind = 'fabric_stage', - target = 'updater/main', - chunk_size = 2048, - artifact_store = 'main', - -- This is Device action policy, not a hidden bus timeout. - -- Keep it in the component config so the test does not - -- change Device's global default action timeout. - timeout_s = stage_timeout_s, - }, - ['commit-update'] = { - kind = 'rpc', - call_topic = device_topics.raw_member_cap_rpc('mcu', 'updater', 'main', 'commit-update'), - timeout_s = 2.0, - }, - }, - }, - }, - } + opts = opts or {} + local stage_timeout_s = opts.stage_action_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S + return { + schema = 'devicecode.config/device/1', + components = { + mcu = { + class = 'member', + subtype = 'mcu', + role = 'controller', + member = 'mcu', + required_facts = { 'software', 'updater' }, + facts = mcu_schema.member_fact_topics('mcu'), + events = mcu_schema.member_event_topics('mcu'), + actions = { + ['restart'] = { kind = 'rpc', call_topic = device_topics.raw_member_cap_rpc('mcu', 'control', 'main', 'restart') }, + ['prepare-update'] = { + kind = 'rpc', + call_topic = device_topics.raw_member_cap_rpc('mcu', 'updater', 'main', 'prepare-update'), + timeout_s = 2.0, + }, + ['stage-update'] = { + kind = 'fabric_stage', + target = 'updater/main', + chunk_size = 2048, + artifact_store = 'main', + -- This is Device action policy, not a hidden bus timeout. + -- Keep it in the component config so the test does not + -- change Device's global default action timeout. + timeout_s = stage_timeout_s, + }, + ['commit-update'] = { + kind = 'rpc', + call_topic = device_topics.raw_member_cap_rpc('mcu', 'updater', 'main', 'commit-update'), + timeout_s = 2.0, + }, + }, + }, + }, + } end local function hal_config(roots) - return { - schema = 'devicecode.config/hal/1', - artifact_store = { - stores = { - { - id = 'main', - transient_root = roots.artifact.transient, - durable_root = roots.artifact.durable, - import_root = roots.artifact.import, - durable_enabled = true, - }, - }, - }, - control_store = { - { name = 'update', root = roots.control }, - }, - } + return { + schema = 'devicecode.config/hal/1', + artifact_store = { + stores = { + { + id = 'main', + transient_root = roots.artifact.transient, + durable_root = roots.artifact.durable, + import_root = roots.artifact.import, + durable_enabled = true, + }, + }, + }, + control_store = { + { name = 'update', root = roots.control }, + }, + } end local function start_hal(scope, bus, roots) - local conn = bus:connect({ origin_base = { service = 'hal' } }) - if posix_ok and stdlib and stdlib.setenv then - stdlib.setenv('DEVICECODE_CONFIG_DIR', roots.config, true) - end - assert_true(scope:spawn(function () - hal_service.start(conn, { name = 'hal', env = 'test', heartbeat_s = false }) - end)) - conn:retain({ 'cfg', 'hal' }, { data = hal_config(roots) }) - wait_retained_payload_where(conn, { 'cap', 'artifact-store', 'main', 'status' }, 'artifact-store available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - wait_retained_payload_where(conn, { 'cap', 'control-store', 'update', 'status' }, 'control-store available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'hal' } }) + if posix_ok and stdlib and stdlib.setenv then + stdlib.setenv('DEVICECODE_CONFIG_DIR', roots.config, true) + end + assert_true(scope:spawn(function () + hal_service.start(conn, { name = 'hal', env = 'test', heartbeat_s = false }) + end)) + conn:retain({ 'cfg', 'hal' }, { data = hal_config(roots) }) + wait_retained_payload_where(conn, { 'cap', 'artifact-store', 'main', 'status' }, 'artifact-store available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + wait_retained_payload_where(conn, { 'cap', 'control-store', 'update', 'status' }, 'control-store available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_http(scope, bus) - local conn = bus:connect({ origin_base = { service = 'http' } }) - assert_true(scope:spawn(function (s) - http_service.run(s, { - conn = conn, - id = 'main', - config = { - schema = 'devicecode.config/http/1', - id = 'main', - policy = { allow_loopback = true, max_request_body = 16 * 1024 * 1024, max_response_body = 16 * 1024 * 1024 }, - }, - backend_timeout = 3.0, - connection_setup_timeout = 3.0, - intra_stream_timeout = 3.0, - }) - end)) - wait_retained_payload_where(conn, { 'cap', 'http', 'main', 'status' }, 'http service available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'http' } }) + assert_true(scope:spawn(function (s) + http_service.run(s, { + conn = conn, + id = 'main', + config = { + schema = 'devicecode.config/http/1', + id = 'main', + policy = { allow_loopback = true, max_request_body = 16 * 1024 * 1024, max_response_body = 16 * 1024 * 1024 }, + }, + backend_timeout = 3.0, + connection_setup_timeout = 3.0, + intra_stream_timeout = 3.0, + }) + end)) + wait_retained_payload_where(conn, { 'cap', 'http', 'main', 'status' }, 'http service available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_device(scope, bus, fabric_client, opts) - opts = opts or {} - local conn = bus:connect({ origin_base = { service = 'device' } }) - assert_true(scope:spawn(function (s) - device_service.run(s, { - conn = conn, - initial_config = device_config(opts), - fabric_client = fabric_client, - watch_config = false, - }) - end)) - wait_retained_payload_where(conn, device_topics.component_cap_status('mcu'), 'mcu component cap available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + opts = opts or {} + local conn = bus:connect({ origin_base = { service = 'device' } }) + assert_true(scope:spawn(function (s) + device_service.run(s, { + conn = conn, + initial_config = device_config(opts), + fabric_client = fabric_client, + watch_config = false, + }) + end)) + wait_retained_payload_where(conn, device_topics.component_cap_status('mcu'), 'mcu component cap available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_update(scope, bus, opts) - opts = opts or {} - local conn = bus:connect({ origin_base = { service = 'update' } }) - assert_true(scope:spawn(function (s) - update_service.run(s, { - conn = conn, - service_id = 'update', - watch_config = false, - config = update_config(opts), - job_store_call_opts = { timeout = 3.0 }, - }) - end)) - wait_retained_payload_where(conn, update_topics.update_manager_status(), 'update manager available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - wait_retained_payload_where(conn, update_topics.artifact_ingest_status(), 'artifact ingest available', function (p) - return p and p.available == true and p - end, { timeout = 4.0 }) - return conn + opts = opts or {} + local conn = bus:connect({ origin_base = { service = 'update' } }) + assert_true(scope:spawn(function (s) + update_service.run(s, { + conn = conn, + service_id = 'update', + watch_config = false, + config = update_config(opts), + job_store_call_opts = { timeout = 3.0 }, + }) + end)) + wait_retained_payload_where(conn, update_topics.update_manager_status(), 'update manager available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + wait_retained_payload_where(conn, update_topics.artifact_ingest_status(), 'artifact ingest available', function (p) + return p and p.available == true and p + end, { timeout = 4.0 }) + return conn end local function start_ui(scope, bus, port, roots) - local conn = bus:connect({ origin_base = { service = 'ui' } }) - assert_true(scope:spawn(function (s) - ui_service.run(s, { - conn = conn, - service_id = 'ui', - auth_opts = { - users = { - tester = { password = 'test-password', principal = { kind = 'user', id = 'tester' } }, - }, - }, - bus = bus, - connect = function (principal) - return bus:connect({ principal = principal or { kind = 'ui-test' } }) - end, - encode_json = function (v) return assert(cjson.encode(v)) end, - update = { - bus = bus, - component = 'mcu', - ingest_id = 'ing-mcu-http-uart', - job_id = 'job-mcu-http-uart', - create_job = true, - start_job = true, - timeout = 8.0, - chunk_size = 4096, - metadata = { - source = 'browser', - format = 'dcmcu-v1', - }, - }, - updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, - }) - end)) - conn:retain({ 'cfg', 'ui' }, { data = { - schema = 'devicecode.config/ui/1', - enabled = true, - http = { enabled = true, cap_id = 'main', host = '127.0.0.1', port = port, max_active_requests = 8 }, - static = { root = roots.static, index = 'index.html' }, - updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, - sse = { enabled = false }, - sessions = { prune_interval = false }, - } }) - wait_retained_payload_where(conn, http_topics.state('main', 'stats'), 'ui listener active', function (p) - return p and type(p.active_listeners) == 'number' and p.active_listeners > 0 and p - end, { timeout = 4.0 }) - return conn + local conn = bus:connect({ origin_base = { service = 'ui' } }) + assert_true(scope:spawn(function (s) + ui_service.run(s, { + conn = conn, + service_id = 'ui', + auth_opts = { + users = { + tester = { password = 'test-password', principal = { kind = 'user', id = 'tester' } }, + }, + }, + bus = bus, + connect = function (principal) + return bus:connect({ principal = principal or { kind = 'ui-test' } }) + end, + encode_json = function (v) return assert(cjson.encode(v)) end, + update = { + bus = bus, + component = 'mcu', + ingest_id = 'ing-mcu-http-uart', + job_id = 'job-mcu-http-uart', + create_job = true, + start_job = true, + timeout = 8.0, + chunk_size = 4096, + metadata = { + source = 'browser', + format = 'dcmcu-v1', + }, + }, + updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, + }) + end)) + conn:retain({ 'cfg', 'ui' }, { data = { + schema = 'devicecode.config/ui/1', + enabled = true, + http = { enabled = true, cap_id = 'main', host = '127.0.0.1', port = port, max_active_requests = 8 }, + static = { root = roots.static, index = 'index.html' }, + updates = { upload = { enabled = true, max_bytes = 1024 * 1024, require_auth = false, component = 'mcu', create_job = true, start_job = true }, commit = { require_auth = false } }, + sse = { enabled = false }, + sessions = { prune_interval = false }, + } }) + wait_retained_payload_where(conn, http_topics.state('main', 'stats'), 'ui listener active', function (p) + return p and type(p.active_listeners) == 'number' and p.active_listeners > 0 and p + end, { timeout = 4.0 }) + return conn end local function make_realistic_mcu_blob(image_id, target_bytes) - target_bytes = target_bytes or REALISTIC_MCU_BLOB_BYTES - local payload = string.rep('payload-0123456789abcdef-', math.ceil(target_bytes / 24)):sub(1, target_bytes) - return dcmcu_fixture.make(image_id or 'mcu-image-new', payload, { - payload_sha256 = sha256_hex_string(payload), - }) + target_bytes = target_bytes or REALISTIC_MCU_BLOB_BYTES + local payload = string.rep('payload-0123456789abcdef-', math.ceil(target_bytes / 24)):sub(1, target_bytes) + return dcmcu_fixture.make(image_id or 'mcu-image-new', payload, { + payload_sha256 = sha256_hex_string(payload), + }) end local function write_all(stream, data) - local off = 1 - while off <= #data do - local n, err = fibers.perform(stream:write_op(data:sub(off))) - if n == nil then return nil, err or 'write_failed' end - if n <= 0 then return nil, 'zero_length_write' end - off = off + n - end - return true, nil + local off = 1 + while off <= #data do + local n, err = fibers.perform(stream:write_op(data:sub(off))) + if n == nil then return nil, err or 'write_failed' end + if n <= 0 then return nil, 'zero_length_write' end + off = off + n + end + return true, nil end local function fault_for_direction(stats, direction) - local faults = type(stats.faults) == 'table' and stats.faults or {} - return type(faults[direction]) == 'table' and faults[direction] or {} + local faults = type(stats.faults) == 'table' and stats.faults or {} + return type(faults[direction]) == 'table' and faults[direction] or {} end local function maybe_inject_malformed_jsonl(direction, output, stats) - local f = fault_for_direction(stats, direction) - local default_direction = stats.default_malformed_direction or 'cm5_to_mcu' - if f.disable_malformed == true then return true, nil end - if f.malformed_line_count == nil and direction ~= default_direction then return true, nil end - local max_lines = f.malformed_line_count or stats.malformed_line_count or UART_MALFORMED_LINE_COUNT - local key = 'malformed_lines_' .. direction - if (stats[key] or 0) >= max_lines then return true, nil end - local next_key = 'next_malformed_at_' .. direction - local next_at = stats[next_key] or f.malformed_line_first_at or stats.malformed_line_first_at or UART_MALFORMED_LINE_FIRST_AT - if (stats.bytes or 0) < next_at then return true, nil end - - stats.malformed_lines = (stats.malformed_lines or 0) + 1 - stats[key] = (stats[key] or 0) + 1 - stats[next_key] = next_at + (f.malformed_line_every_bytes or stats.malformed_line_every_bytes or UART_MALFORMED_LINE_EVERY_BYTES) - local line = ('{ malformed json from mcu_http_uart test #%d on %s }\n'):format(stats.malformed_lines, direction) - local ok, err = write_all(output.master, line) - if ok ~= true then return nil, err or 'malformed_jsonl_inject_failed' end - - stats.bytes = (stats.bytes or 0) + #line - stats.fragments = (stats.fragments or 0) + 1 - stats.malformed_bytes = (stats.malformed_bytes or 0) + #line - log(('UART middleware injected standalone malformed JSONL line #%d on %s at %d bytes'):format( - stats.malformed_lines, direction, stats.bytes - )) - return true, nil + local f = fault_for_direction(stats, direction) + local default_direction = stats.default_malformed_direction or 'cm5_to_mcu' + if f.disable_malformed == true then return true, nil end + if f.malformed_line_count == nil and direction ~= default_direction then return true, nil end + local max_lines = f.malformed_line_count or stats.malformed_line_count or UART_MALFORMED_LINE_COUNT + local key = 'malformed_lines_' .. direction + if (stats[key] or 0) >= max_lines then return true, nil end + local next_key = 'next_malformed_at_' .. direction + local next_at = stats[next_key] or f.malformed_line_first_at or stats.malformed_line_first_at or UART_MALFORMED_LINE_FIRST_AT + if (stats.bytes or 0) < next_at then return true, nil end + + stats.malformed_lines = (stats.malformed_lines or 0) + 1 + stats[key] = (stats[key] or 0) + 1 + stats[next_key] = next_at + (f.malformed_line_every_bytes or stats.malformed_line_every_bytes or UART_MALFORMED_LINE_EVERY_BYTES) + local line = ('{ malformed json from mcu_http_uart test #%d on %s }\n'):format(stats.malformed_lines, direction) + local ok, err = write_all(output.master, line) + if ok ~= true then return nil, err or 'malformed_jsonl_inject_failed' end + + stats.bytes = (stats.bytes or 0) + #line + stats.fragments = (stats.fragments or 0) + 1 + stats.malformed_bytes = (stats.malformed_bytes or 0) + #line + log(('UART middleware injected standalone malformed JSONL line #%d on %s at %d bytes'):format( + stats.malformed_lines, direction, stats.bytes + )) + return true, nil end local function fault_drop_line_pattern(f) - if type(f) ~= 'table' then return nil end - if type(f.drop_byte_when_line_contains) == 'string' and f.drop_byte_when_line_contains ~= '' then - return f.drop_byte_when_line_contains - end - if type(f.drop_byte_in_frame_type) == 'string' and f.drop_byte_in_frame_type ~= '' then - -- Fabric JSONL encoders in both Go and Lua emit compact JSON. Match - -- the type field rather than a byte-count so the fault remains - -- deterministic as retained telemetry volume changes. - return '"type":"' .. f.drop_byte_in_frame_type .. '"' - end - return nil + if type(f) ~= 'table' then return nil end + if type(f.drop_byte_when_line_contains) == 'string' and f.drop_byte_when_line_contains ~= '' then + return f.drop_byte_when_line_contains + end + if type(f.drop_byte_in_frame_type) == 'string' and f.drop_byte_in_frame_type ~= '' then + -- Fabric JSONL encoders in both Go and Lua emit compact JSON. Match + -- the type field rather than a byte-count so the fault remains + -- deterministic as retained telemetry volume changes. + return '"type":"' .. f.drop_byte_in_frame_type .. '"' + end + return nil end local function remember_fault_line_tail(f, pattern, combined, piece) - if type(piece) ~= 'string' or piece == '' then return end - if piece:find('\n', 1, true) then - f.drop_line_buf = '' - return - end - local keep = math.max(1, math.min(#combined, #pattern - 1)) - f.drop_line_buf = combined:sub(#combined - keep + 1) + if type(piece) ~= 'string' or piece == '' then return end + if piece:find('\n', 1, true) then + f.drop_line_buf = '' + return + end + local keep = math.max(1, math.min(#combined, #pattern - 1)) + f.drop_line_buf = combined:sub(#combined - keep + 1) end local function maybe_drop_targeted_uart_byte(direction, stats, piece) - local f = fault_for_direction(stats, direction) - if f.drop_byte_done == true then return piece end - local pattern = fault_drop_line_pattern(f) - if not pattern then return piece end - - local prior = f.drop_line_buf or '' - local combined = prior .. piece - local _, match_end = combined:find(pattern, 1, true) - if not match_end then - remember_fault_line_tail(f, pattern, combined, piece) - return piece - end - - local rel = match_end - #prior - if rel < 1 then rel = 1 end - if rel > #piece then rel = #piece end - - f.drop_byte_done = true - f.drop_line_buf = nil - stats.dropped_bytes = (stats.dropped_bytes or 0) + 1 - log(('UART middleware dropped byte #%d on %s matching %q rel=%d'):format( - stats.dropped_bytes, direction, pattern, rel - )) - return piece:sub(1, rel - 1) .. piece:sub(rel + 1) + local f = fault_for_direction(stats, direction) + if f.drop_byte_done == true then return piece end + local pattern = fault_drop_line_pattern(f) + if not pattern then return piece end + + local prior = f.drop_line_buf or '' + local combined = prior .. piece + local _, match_end = combined:find(pattern, 1, true) + if not match_end then + remember_fault_line_tail(f, pattern, combined, piece) + return piece + end + + local rel = match_end - #prior + if rel < 1 then rel = 1 end + if rel > #piece then rel = #piece end + + f.drop_byte_done = true + f.drop_line_buf = nil + stats.dropped_bytes = (stats.dropped_bytes or 0) + 1 + log(('UART middleware dropped byte #%d on %s matching %q rel=%d'):format( + stats.dropped_bytes, direction, pattern, rel + )) + return piece:sub(1, rel - 1) .. piece:sub(rel + 1) end local function apply_uart_faults(direction, stats, piece) - local f = fault_for_direction(stats, direction) - if type(piece) ~= 'string' or piece == '' then return piece end - - local dir_bytes = type(stats.bytes_by_direction) == 'table' and (stats.bytes_by_direction[direction] or 0) or 0 - local function crosses(after) - return type(after) == 'number' and dir_bytes < after and (dir_bytes + #piece) >= after - end - - if f.pause_once_after_bytes and not f.pause_once_done and crosses(f.pause_once_after_bytes) then - f.pause_once_done = true - local pause_s = f.pause_s or 0.75 - stats.fault_pauses = (stats.fault_pauses or 0) + 1 - log(('UART middleware fault pause #%d on %s for %.3fs at %d direction-bytes'):format( - stats.fault_pauses, direction, pause_s, dir_bytes - )) - fibers.perform(sleep.sleep_op(pause_s)) - end - - if f.drop_byte_after_bytes and not f.drop_byte_done and crosses(f.drop_byte_after_bytes) then - local rel = math.max(1, math.min(#piece, f.drop_byte_after_bytes - dir_bytes)) - f.drop_byte_done = true - stats.dropped_bytes = (stats.dropped_bytes or 0) + 1 - log(('UART middleware dropped byte #%d on %s at stream byte %d rel=%d'):format( - stats.dropped_bytes, direction, f.drop_byte_after_bytes, rel - )) - piece = piece:sub(1, rel - 1) .. piece:sub(rel + 1) - end - - piece = maybe_drop_targeted_uart_byte(direction, stats, piece) - - local newline_after = f.drop_next_newline_after_bytes or f.drop_newline_after_bytes - if newline_after and not f.drop_newline_done then - if not f.drop_newline_armed and ((type(newline_after) ~= 'number') or (dir_bytes + #piece) >= newline_after) then - f.drop_newline_armed = true - log(('UART middleware armed next-newline drop on %s after %s direction-bytes'):format( - direction, tostring(newline_after) - )) - end - if f.drop_newline_armed then - local nl = piece:find('\n', 1, true) - if nl ~= nil then - f.drop_newline_done = true - stats.dropped_newlines = (stats.dropped_newlines or 0) + 1 - log(('UART middleware dropped newline #%d on %s after threshold %s rel=%d'):format( - stats.dropped_newlines, direction, tostring(newline_after), nl - )) - piece = piece:sub(1, nl - 1) .. piece:sub(nl + 1) - end - end - end - - return piece + local f = fault_for_direction(stats, direction) + if type(piece) ~= 'string' or piece == '' then return piece end + + local dir_bytes = type(stats.bytes_by_direction) == 'table' and (stats.bytes_by_direction[direction] or 0) or 0 + local function crosses(after) + return type(after) == 'number' and dir_bytes < after and (dir_bytes + #piece) >= after + end + + if f.pause_once_after_bytes and not f.pause_once_done and crosses(f.pause_once_after_bytes) then + f.pause_once_done = true + local pause_s = f.pause_s or 0.75 + stats.fault_pauses = (stats.fault_pauses or 0) + 1 + log(('UART middleware fault pause #%d on %s for %.3fs at %d direction-bytes'):format( + stats.fault_pauses, direction, pause_s, dir_bytes + )) + fibers.perform(sleep.sleep_op(pause_s)) + end + + if f.drop_byte_after_bytes and not f.drop_byte_done and crosses(f.drop_byte_after_bytes) then + local rel = math.max(1, math.min(#piece, f.drop_byte_after_bytes - dir_bytes)) + f.drop_byte_done = true + stats.dropped_bytes = (stats.dropped_bytes or 0) + 1 + log(('UART middleware dropped byte #%d on %s at stream byte %d rel=%d'):format( + stats.dropped_bytes, direction, f.drop_byte_after_bytes, rel + )) + piece = piece:sub(1, rel - 1) .. piece:sub(rel + 1) + end + + piece = maybe_drop_targeted_uart_byte(direction, stats, piece) + + local newline_after = f.drop_next_newline_after_bytes or f.drop_newline_after_bytes + if newline_after and not f.drop_newline_done then + if not f.drop_newline_armed and ((type(newline_after) ~= 'number') or (dir_bytes + #piece) >= newline_after) then + f.drop_newline_armed = true + log(('UART middleware armed next-newline drop on %s after %s direction-bytes'):format( + direction, tostring(newline_after) + )) + end + if f.drop_newline_armed then + local nl = piece:find('\n', 1, true) + if nl ~= nil then + f.drop_newline_done = true + stats.dropped_newlines = (stats.dropped_newlines or 0) + 1 + log(('UART middleware dropped newline #%d on %s after threshold %s rel=%d'):format( + stats.dropped_newlines, direction, tostring(newline_after), nl + )) + piece = piece:sub(1, nl - 1) .. piece:sub(nl + 1) + end + end + end + + return piece end local function write_uart_fragment_piece(direction, output, stats, piece) - if piece == '' then return true, nil end - piece = apply_uart_faults(direction, stats, piece) - if piece == '' then return true, nil end - local ok, werr = write_all(output.master, piece) - if ok ~= true then - return nil, werr or 'write_failed' - end - stats.bytes = (stats.bytes or 0) + #piece - stats.bytes_by_direction = stats.bytes_by_direction or {} - stats.bytes_by_direction[direction] = (stats.bytes_by_direction[direction] or 0) + #piece - stats.fragments = (stats.fragments or 0) + 1 - - if piece:sub(-1) == '\n' then - local mok, merr = maybe_inject_malformed_jsonl(direction, output, stats) - if mok ~= true then return nil, merr end - end - - return true, nil + if piece == '' then return true, nil end + piece = apply_uart_faults(direction, stats, piece) + if piece == '' then return true, nil end + local ok, werr = write_all(output.master, piece) + if ok ~= true then + return nil, werr or 'write_failed' + end + stats.bytes = (stats.bytes or 0) + #piece + stats.bytes_by_direction = stats.bytes_by_direction or {} + stats.bytes_by_direction[direction] = (stats.bytes_by_direction[direction] or 0) + #piece + stats.fragments = (stats.fragments or 0) + 1 + + if piece:sub(-1) == '\n' then + local mok, merr = maybe_inject_malformed_jsonl(direction, output, stats) + if mok ~= true then return nil, merr end + end + + return true, nil end local function relay_fragmented_uart(direction, input, output, stats) - while true do - local want = math.random(1, UART_MAX_READ_BYTES) - local chunk, err = fibers.perform(input.master:read_some_op(want)) - if chunk == nil then - return { direction = direction, reason = err or 'closed' } - end - - local off = 1 - while off <= #chunk do - local frag_len = math.random(1, UART_MAX_FRAGMENT_BYTES) - local frag = chunk:sub(off, off + frag_len - 1) - local frag_off = 1 - while frag_off <= #frag do - local nl = frag:find('\n', frag_off, true) - local piece - if nl ~= nil then - piece = frag:sub(frag_off, nl) - frag_off = nl + 1 - else - piece = frag:sub(frag_off) - frag_off = #frag + 1 - end - local ok, werr = write_uart_fragment_piece(direction, output, stats, piece) - if ok ~= true then - return { direction = direction, reason = werr or 'write_failed' } - end - end - off = off + #frag - - if stats.bytes >= (stats.next_long_pause_at or UART_LONG_PAUSE_EVERY_BYTES) then - stats.pauses = (stats.pauses or 0) + 1 - stats.next_long_pause_at = (stats.next_long_pause_at or UART_LONG_PAUSE_EVERY_BYTES) + UART_LONG_PAUSE_EVERY_BYTES - local pause_s = UART_LONG_PAUSE_BASE_S + (math.random() * UART_LONG_PAUSE_JITTER_S) - log(('UART middleware pause #%d on %s for %.3fs at %d bytes'):format( - stats.pauses, direction, pause_s, stats.bytes - )) - fibers.perform(sleep.sleep_op(pause_s)) - end - - -- Approximate 115200 8N1 UART payload rate, with modest scheduling - -- jitter and short fragments. This tests the release protocol under - -- realistic pacing without injecting corrupt or reordered bytes. - fibers.perform(sleep.sleep_op((#frag / UART_BYTES_PER_SEC) + (math.random(0, 4) / 1000))) - end - end + while true do + local want = math.random(1, UART_MAX_READ_BYTES) + local chunk, err = fibers.perform(input.master:read_some_op(want)) + if chunk == nil then + return { direction = direction, reason = err or 'closed' } + end + + local off = 1 + while off <= #chunk do + local frag_len = math.random(1, UART_MAX_FRAGMENT_BYTES) + local frag = chunk:sub(off, off + frag_len - 1) + local frag_off = 1 + while frag_off <= #frag do + local nl = frag:find('\n', frag_off, true) + local piece + if nl ~= nil then + piece = frag:sub(frag_off, nl) + frag_off = nl + 1 + else + piece = frag:sub(frag_off) + frag_off = #frag + 1 + end + local ok, werr = write_uart_fragment_piece(direction, output, stats, piece) + if ok ~= true then + return { direction = direction, reason = werr or 'write_failed' } + end + end + off = off + #frag + + if stats.bytes >= (stats.next_long_pause_at or UART_LONG_PAUSE_EVERY_BYTES) then + stats.pauses = (stats.pauses or 0) + 1 + stats.next_long_pause_at = (stats.next_long_pause_at or UART_LONG_PAUSE_EVERY_BYTES) + UART_LONG_PAUSE_EVERY_BYTES + local pause_s = UART_LONG_PAUSE_BASE_S + (math.random() * UART_LONG_PAUSE_JITTER_S) + log(('UART middleware pause #%d on %s for %.3fs at %d bytes'):format( + stats.pauses, direction, pause_s, stats.bytes + )) + fibers.perform(sleep.sleep_op(pause_s)) + end + + -- Approximate 115200 8N1 UART payload rate, with modest scheduling + -- jitter and short fragments. This tests the release protocol under + -- realistic pacing without injecting corrupt or reordered bytes. + fibers.perform(sleep.sleep_op((#frag / UART_BYTES_PER_SEC) + (math.random(0, 4) / 1000))) + end + end end local function start_noisy_uart_middleware(scope, cm5_port, mcu_port, opts) - opts = opts or {} - local stats = { - bytes = 0, - fragments = 0, - pauses = 0, - malformed_lines = 0, - malformed_bytes = 0, - dropped_bytes = 0, - dropped_newlines = 0, - fault_pauses = 0, - next_long_pause_at = opts.long_pause_every_bytes or UART_LONG_PAUSE_EVERY_BYTES, - malformed_line_count = opts.malformed_line_count, - malformed_line_first_at = opts.malformed_line_first_at, - malformed_line_every_bytes = opts.malformed_line_every_bytes, - default_malformed_direction = opts.default_malformed_direction, - faults = opts.faults or {}, - bytes_by_direction = {}, - } - local ok_a, err_a = scope:spawn(function () - return relay_fragmented_uart('cm5_to_mcu', cm5_port, mcu_port, stats) - end) - assert_true(ok_a, tostring(err_a)) - local ok_b, err_b = scope:spawn(function () - return relay_fragmented_uart('mcu_to_cm5', mcu_port, cm5_port, stats) - end) - assert_true(ok_b, tostring(err_b)) - return stats + opts = opts or {} + local stats = { + bytes = 0, + fragments = 0, + pauses = 0, + malformed_lines = 0, + malformed_bytes = 0, + dropped_bytes = 0, + dropped_newlines = 0, + fault_pauses = 0, + next_long_pause_at = opts.long_pause_every_bytes or UART_LONG_PAUSE_EVERY_BYTES, + malformed_line_count = opts.malformed_line_count, + malformed_line_first_at = opts.malformed_line_first_at, + malformed_line_every_bytes = opts.malformed_line_every_bytes, + default_malformed_direction = opts.default_malformed_direction, + faults = opts.faults or {}, + bytes_by_direction = {}, + } + local ok_a, err_a = scope:spawn(function () + return relay_fragmented_uart('cm5_to_mcu', cm5_port, mcu_port, stats) + end) + assert_true(ok_a, tostring(err_a)) + local ok_b, err_b = scope:spawn(function () + return relay_fragmented_uart('mcu_to_cm5', mcu_port, cm5_port, stats) + end) + assert_true(ok_b, tostring(err_b)) + return stats end local function open_pty_transport_op(slave_name) - return op.guard(function () - local stream, err = pty.open_slave_stream(slave_name) - if not stream then return fibers.always(nil, err or 'pty_slave_open_failed') end - local transport, terr = hal_transport.wrap_transport(stream, { terminator = '\n' }) - if not transport then - if type(stream.terminate) == 'function' then stream:terminate(terr or 'transport_wrap_failed') end - return fibers.always(nil, terr or 'transport_wrap_failed') - end - return fibers.always(transport, nil) - end) + return op.guard(function () + local stream, err = pty.open_slave_stream(slave_name) + if not stream then return fibers.always(nil, err or 'pty_slave_open_failed') end + local transport, terr = hal_transport.wrap_transport(stream, { terminator = '\n' }) + if not transport then + if type(stream.terminate) == 'function' then stream:terminate(terr or 'transport_wrap_failed') end + return fibers.always(nil, terr or 'transport_wrap_failed') + end + return fibers.always(transport, nil) + end) end local function cm5_uart_fabric_config() - local cfg = cm5_fabric_config() - cfg.links[1].transport = { - source = 'uart_manager', - class = 'uart', - id = 'uart0', - terminator = '\n', - } - cfg.links[1].session.hello_interval_s = 0.20 - return cfg + local cfg = cm5_fabric_config() + cfg.links[1].transport = { + source = 'uart_manager', + class = 'uart', + id = 'uart0', + terminator = '\n', + } + cfg.links[1].session.hello_interval_s = 0.20 + return cfg end local function mcu_pty_fabric_config() - local cfg = mcu_fabric_config() - cfg.links[1].session.hello_interval_s = 0.20 - return cfg + local cfg = mcu_fabric_config() + cfg.links[1].session.hello_interval_s = 0.20 + return cfg end local function start_fabric_pair(parent_scope, cm5_bus, fake, uart, opts) - opts = opts or {} - local pair_scope = assert(parent_scope:child()) - local stats = start_noisy_uart_middleware(pair_scope, assert(uart and uart.cm5, 'cm5 PTY required'), assert(uart and uart.mcu, 'mcu PTY required'), opts.uart) - - local cm5_conn = cm5_bus:connect({ origin_base = { service = 'fabric-cm5' } }) - - -- CM5 uses the real HAL raw-host UART capability path. The MCU side is a - -- separate Lua process with its own bus, scheduler and HAL UART manager. - start_public_fabric(pair_scope, cm5_conn, cm5_uart_fabric_config(), nil, { name = 'fabric-cm5' }) - - wait_retained_payload_where(cm5_conn, fabric_topics.transfer_manager_status(), 'cm5 fabric transfer manager available', function (p) - return p and p.available == true and p - end, { timeout = 6.0 }) - - local cm5_session = wait_fabric_session_established(cm5_conn, 'CM5 fabric hello/hello_ack session established', { timeout = 6.0 }) - local cm5_snapshot = fabric_payload_snapshot(cm5_session) or {} - assert_eq(cm5_snapshot.peer_node, 'mcu', 'CM5 fabric session should identify MCU peer') - if not (fake and fake.expect_mcu_fabric_event == false) then - wait_until_test('MCU child fabric hello/hello_ack session established', function () - return fake and fake.mcu_fabric_session_seen == true - end, 6.0, 0.05) - assert_eq(fake.mcu_fabric_session and fake.mcu_fabric_session.peer_node, 'cm5', 'MCU fabric session should identify CM5 peer') - end - log_fabric_status(cm5_conn, 'CM5 fabric established status') - - return { - scope = pair_scope, - cm5_conn = cm5_conn, - uart_stats = stats, - } + opts = opts or {} + local pair_scope = assert(parent_scope:child()) + local stats = start_noisy_uart_middleware(pair_scope, assert(uart and uart.cm5, 'cm5 PTY required'), assert(uart and uart.mcu, 'mcu PTY required'), opts.uart) + + local cm5_conn = cm5_bus:connect({ origin_base = { service = 'fabric-cm5' } }) + + -- CM5 uses the real HAL raw-host UART capability path. The MCU side is a + -- separate Lua process with its own bus, scheduler and HAL UART manager. + start_public_fabric(pair_scope, cm5_conn, cm5_uart_fabric_config(), nil, { name = 'fabric-cm5' }) + + wait_retained_payload_where(cm5_conn, fabric_topics.transfer_manager_status(), 'cm5 fabric transfer manager available', function (p) + return p and p.available == true and p + end, { timeout = 6.0 }) + + local cm5_session = wait_fabric_session_established(cm5_conn, 'CM5 fabric hello/hello_ack session established', { timeout = 6.0 }) + local cm5_snapshot = fabric_payload_snapshot(cm5_session) or {} + assert_eq(cm5_snapshot.peer_node, 'mcu', 'CM5 fabric session should identify MCU peer') + if not (fake and fake.expect_mcu_fabric_event == false) then + wait_until_test('MCU child fabric hello/hello_ack session established', function () + return fake and fake.mcu_fabric_session_seen == true + end, 6.0, 0.05) + assert_eq(fake.mcu_fabric_session and fake.mcu_fabric_session.peer_node, 'cm5', 'MCU fabric session should identify CM5 peer') + end + log_fabric_status(cm5_conn, 'CM5 fabric established status') + + return { + scope = pair_scope, + cm5_conn = cm5_conn, + uart_stats = stats, + } end local function stop_fabric_pair(pair, reason) - if not pair then return end - if pair.scope then - pair.scope:cancel(reason or 'fabric pair stop') - fibers.perform(pair.scope:join_op()) - end - if pair.cm5_conn then bus_cleanup.disconnect(pair.cm5_conn) end - if pair.mcu_conn then bus_cleanup.disconnect(pair.mcu_conn) end + if not pair then return end + if pair.scope then + pair.scope:cancel(reason or 'fabric pair stop') + fibers.perform(pair.scope:join_op()) + end + if pair.cm5_conn then bus_cleanup.disconnect(pair.cm5_conn) end + if pair.mcu_conn then bus_cleanup.disconnect(pair.mcu_conn) end end local function start_cm5_instance(parent_scope, roots, http_port, uart_port, opts) - opts = opts or {} - local scope = assert(parent_scope:child()) - local bus = busmod.new() - local conn = bus:connect({ origin_base = { service = 'test-cm5' } }) - - -- The test UART adapter registers finalisers on the instance scope. - -- Do this before starting services that spawn onto the same scope; after a - -- scope has started, lua-fibers requires finalisers to be added from within - -- that target scope. - if uart_port ~= nil then - start_cm5_uart_manager(scope, bus, uart_port) - end - start_hal(scope, bus, roots) - start_http(scope, bus) - - return { - scope = scope, - bus = bus, - conn = conn, - start_services_after_fabric = function (fabric_client) - start_device(scope, bus, fabric_client, opts.device or opts) - start_update(scope, bus, opts.update or opts) - if http_port then start_ui(scope, bus, http_port, roots) end - end, - } + opts = opts or {} + local scope = assert(parent_scope:child()) + local bus = busmod.new() + local conn = bus:connect({ origin_base = { service = 'test-cm5' } }) + + -- The test UART adapter registers finalisers on the instance scope. + -- Do this before starting services that spawn onto the same scope; after a + -- scope has started, lua-fibers requires finalisers to be added from within + -- that target scope. + if uart_port ~= nil then + start_cm5_uart_manager(scope, bus, uart_port) + end + start_hal(scope, bus, roots) + start_http(scope, bus) + + return { + scope = scope, + bus = bus, + conn = conn, + start_services_after_fabric = function (fabric_client) + start_device(scope, bus, fabric_client, opts.device or opts) + start_update(scope, bus, opts.update or opts) + if http_port then start_ui(scope, bus, http_port, roots) end + end, + } end local function update_fake_from_mcu_child(fake, ev) - if type(ev) ~= 'table' then return end - local event = ev.event - if event == 'ready' then - fake.boot_seq = ev.boot_seq or fake.boot_seq - fake.child_ready = true - fake.child_pid = ev.pid - log(('MCU child ready: pid=%s boot_seq=%s image=%s'):format( - tostring(ev.pid), tostring(ev.boot_seq), tostring(ev.image_id) - )) - elseif event == 'receive_opened' then - fake.transfer_begin = ev - fake.receive_started_at = fibers.now() - fake.receive_bytes = 0 - fake.receive_chunks = 0 - log(('MCU receive target opened: target=%s size=%s job_id=%s image_id=%s'):format( - tostring(ev.target), tostring(ev.size), tostring(ev.job_id), tostring(ev.image_id) - )) - elseif event == 'receive_tick' then - fake.receive_bytes = ev.bytes or fake.receive_bytes - fake.receive_chunks = ev.chunks or fake.receive_chunks - elseif event == 'receive_progress' then - fake.receive_bytes = ev.bytes or fake.receive_bytes - fake.receive_chunks = ev.chunks or fake.receive_chunks - log(('MCU received %d bytes in %d chunks after %.1fs'):format( - fake.receive_bytes or 0, fake.receive_chunks or 0, tonumber(ev.elapsed_s) or 0 - )) - elseif event == 'transfer_commit' then - fake.receive_bytes = ev.size or fake.receive_bytes - fake.receive_chunks = ev.chunks or fake.receive_chunks - fake.staged = { - size = ev.size, - digest = ev.digest, - payload_digest = ev.payload_digest, - image_id = ev.image_id, - job_id = ev.job_id, - } - fake.staged_signal = (fake.staged_signal or 0) + 1 - log(('MCU transfer commit: %d bytes in %d chunks; digest=%s payload_digest=%s'):format( - ev.size or 0, ev.chunks or 0, tostring(ev.digest), tostring(ev.payload_digest) - )) - elseif event == 'transfer_abort' then - fake.abort_reason = ev.reason - fake.abort_count = ev.abort_count or ((fake.abort_count or 0) + 1) - fake.receive_bytes = ev.bytes or fake.receive_bytes - fake.receive_chunks = ev.chunks or fake.receive_chunks - log(('MCU transfer abort: reason=%s after %d bytes in %d chunks'):format( - tostring(ev.reason), fake.receive_bytes or 0, fake.receive_chunks or 0 - )) - elseif event == 'prepare' then - fake.prepare_payload = ev.payload - fake.job_id = ev.job_id - elseif event == 'commit' then - fake.commit_payload = ev.payload - fake.commit_seen = true - fake.committed_image_id = ev.committed_image_id - elseif event == 'rebooting' then - fake.commit_seen = true - fake.rebooting_seen = true - fake.committed_image_id = ev.image_id or fake.committed_image_id - log(('MCU child rebooting: image=%s version=%s length=%s'):format( - tostring(ev.image_id), tostring(ev.version), tostring(ev.length) - )) - elseif event == 'fabric_session' then - fake.mcu_fabric_session = ev - fake.mcu_fabric_session_seen = true - log(('MCU child fabric session: phase=%s established=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s'):format( - tostring(ev.phase), tostring(ev.established), tostring(ev.peer_node), tostring(ev.peer_sid), - tostring(ev.session_generation), tostring(ev.wire_errors or 0), tostring(ev.bad_frame_count or 0) - )) - elseif event == 'fabric_status' then - log(('MCU child fabric %s: %s'):format(tostring(ev.component or ev.label or 'status'), tostring(ev.summary))) - elseif event == 'log' then - log('MCU child: ' .. tostring(ev.message)) - end + if type(ev) ~= 'table' then return end + local event = ev.event + if event == 'ready' then + fake.boot_seq = ev.boot_seq or fake.boot_seq + fake.child_ready = true + fake.child_pid = ev.pid + log(('MCU child ready: pid=%s boot_seq=%s image=%s'):format( + tostring(ev.pid), tostring(ev.boot_seq), tostring(ev.image_id) + )) + elseif event == 'receive_opened' then + fake.transfer_begin = ev + fake.receive_started_at = fibers.now() + fake.receive_bytes = 0 + fake.receive_chunks = 0 + log(('MCU receive target opened: target=%s size=%s job_id=%s image_id=%s'):format( + tostring(ev.target), tostring(ev.size), tostring(ev.job_id), tostring(ev.image_id) + )) + elseif event == 'receive_tick' then + fake.receive_bytes = ev.bytes or fake.receive_bytes + fake.receive_chunks = ev.chunks or fake.receive_chunks + elseif event == 'receive_progress' then + fake.receive_bytes = ev.bytes or fake.receive_bytes + fake.receive_chunks = ev.chunks or fake.receive_chunks + log(('MCU received %d bytes in %d chunks after %.1fs'):format( + fake.receive_bytes or 0, fake.receive_chunks or 0, tonumber(ev.elapsed_s) or 0 + )) + elseif event == 'transfer_commit' then + fake.receive_bytes = ev.size or fake.receive_bytes + fake.receive_chunks = ev.chunks or fake.receive_chunks + fake.staged = { + size = ev.size, + digest = ev.digest, + payload_digest = ev.payload_digest, + image_id = ev.image_id, + job_id = ev.job_id, + } + fake.staged_signal = (fake.staged_signal or 0) + 1 + log(('MCU transfer commit: %d bytes in %d chunks; digest=%s payload_digest=%s'):format( + ev.size or 0, ev.chunks or 0, tostring(ev.digest), tostring(ev.payload_digest) + )) + elseif event == 'transfer_abort' then + fake.abort_reason = ev.reason + fake.abort_count = ev.abort_count or ((fake.abort_count or 0) + 1) + fake.receive_bytes = ev.bytes or fake.receive_bytes + fake.receive_chunks = ev.chunks or fake.receive_chunks + log(('MCU transfer abort: reason=%s after %d bytes in %d chunks'):format( + tostring(ev.reason), fake.receive_bytes or 0, fake.receive_chunks or 0 + )) + elseif event == 'prepare' then + fake.prepare_payload = ev.payload + fake.job_id = ev.job_id + elseif event == 'commit' then + fake.commit_payload = ev.payload + fake.commit_seen = true + fake.committed_image_id = ev.committed_image_id + elseif event == 'rebooting' then + fake.commit_seen = true + fake.rebooting_seen = true + fake.committed_image_id = ev.image_id or fake.committed_image_id + log(('MCU child rebooting: image=%s version=%s length=%s'):format( + tostring(ev.image_id), tostring(ev.version), tostring(ev.length) + )) + elseif event == 'fabric_session' then + fake.mcu_fabric_session = ev + fake.mcu_fabric_session_seen = true + log(('MCU child fabric session: phase=%s established=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s'):format( + tostring(ev.phase), tostring(ev.established), tostring(ev.peer_node), tostring(ev.peer_sid), + tostring(ev.session_generation), tostring(ev.wire_errors or 0), tostring(ev.bad_frame_count or 0) + )) + elseif event == 'fabric_status' then + log(('MCU child fabric %s: %s'):format(tostring(ev.component or ev.label or 'status'), tostring(ev.summary))) + elseif event == 'log' then + log('MCU child: ' .. tostring(ev.message)) + end end local function start_mcu_instance(parent_scope, fake, uart_port) - assert(uart_port and uart_port.slave_name, 'MCU PTY slave required') - fake.mcu_fabric_session_seen = false - fake.mcu_fabric_session = nil - local scope = assert(parent_scope:child()) - local ipc_path = ('/tmp/devicecode-mcu-child-%d-%d.sock'):format(os.time(), math.random(100000, 999999)) - os.remove(ipc_path) - - local server, serr = socket.listen_unix(ipc_path, { ephemeral = true }) - assert_not_nil(server, serr or 'listen_unix failed') - local ready_ch = channel.new(1) - - scope:finally(function () - safe.pcall(function () server:close() end) - os.remove(ipc_path) - end) - - assert_true(scope:spawn(function () - local stream, aerr = server:accept() - if not stream then - ready_ch:put({ ok = false, err = aerr or 'mcu child ipc accept failed' }) - return - end - while true do - local line, rerr = fibers.perform(stream:read_line_op()) - if line == nil then - if fake.child_ready ~= true then - ready_ch:put({ ok = false, err = rerr or 'mcu child ipc closed before ready' }) - end - return - end - local ev, derr = cjson.decode(line) - if not ev then - log('MCU child sent invalid IPC JSON: ' .. tostring(derr)) - else - update_fake_from_mcu_child(fake, ev) - if ev.event == 'ready' then - ready_ch:put({ ok = true }) - end - end - end - end)) - - local child_script = './integration/devhost/support/mcu_http_uart_child.lua' - local cmd = exec.command({ - 'lua', child_script, - '--ipc', ipc_path, - '--uart', uart_port.slave_name, - '--old-image', fake.old_image_id or 'mcu-image-old', - '--committed-image', fake.committed_image_id or '', - '--boot-seq', tostring((fake.boot_seq or 0) + 1), - cwd = '.', - stdin = 'null', - stdout = 'inherit', - stderr = 'inherit', - shutdown_grace = 1.0, - env = { - MCU_HTTP_UART_TRANSFER_TIMEOUT_S = tostring(REALISTIC_TRANSFER_TIMEOUT_S), - MCU_HTTP_UART_FLASH_DELAY_S = tostring(MCU_FLASH_WRITE_DELAY_S), - }, - }) - - assert_true(scope:spawn(function () - local status, code, signal, err = fibers.perform(cmd:run_op()) - fake.child_exit = { status = status, code = code, signal = signal, err = err } - if status ~= 'signalled' and not (status == 'exited' and code == 0) then - log(('MCU child exited: status=%s code=%s signal=%s err=%s'):format( - tostring(status), tostring(code), tostring(signal), tostring(err) - )) - end - end)) - - local ready = wait_channel_get(ready_ch, 6.0, 'MCU child ready') - if not ready.ok then error(ready.err or 'MCU child failed to start', 0) end - - return { scope = scope, ipc_path = ipc_path, command = cmd } + assert(uart_port and uart_port.slave_name, 'MCU PTY slave required') + fake.mcu_fabric_session_seen = false + fake.mcu_fabric_session = nil + local scope = assert(parent_scope:child()) + local ipc_path = ('/tmp/devicecode-mcu-child-%d-%d.sock'):format(os.time(), math.random(100000, 999999)) + os.remove(ipc_path) + + local server, serr = socket.listen_unix(ipc_path, { ephemeral = true }) + assert_not_nil(server, serr or 'listen_unix failed') + local ready_ch = channel.new(1) + + scope:finally(function () + safe.pcall(function () server:close() end) + os.remove(ipc_path) + end) + + assert_true(scope:spawn(function () + local stream, aerr = server:accept() + if not stream then + ready_ch:put({ ok = false, err = aerr or 'mcu child ipc accept failed' }) + return + end + while true do + local line, rerr = fibers.perform(stream:read_line_op()) + if line == nil then + if fake.child_ready ~= true then + ready_ch:put({ ok = false, err = rerr or 'mcu child ipc closed before ready' }) + end + return + end + local ev, derr = cjson.decode(line) + if not ev then + log('MCU child sent invalid IPC JSON: ' .. tostring(derr)) + else + update_fake_from_mcu_child(fake, ev) + if ev.event == 'ready' then + ready_ch:put({ ok = true }) + end + end + end + end)) + + local child_script = './integration/devhost/support/mcu_http_uart_child.lua' + local cmd = exec.command({ + 'lua', child_script, + '--ipc', ipc_path, + '--uart', uart_port.slave_name, + '--old-image', fake.old_image_id or 'mcu-image-old', + '--committed-image', fake.committed_image_id or '', + '--boot-seq', tostring((fake.boot_seq or 0) + 1), + cwd = '.', + stdin = 'null', + stdout = 'inherit', + stderr = 'inherit', + shutdown_grace = 1.0, + env = { + MCU_HTTP_UART_TRANSFER_TIMEOUT_S = tostring(REALISTIC_TRANSFER_TIMEOUT_S), + MCU_HTTP_UART_FLASH_DELAY_S = tostring(MCU_FLASH_WRITE_DELAY_S), + }, + }) + + assert_true(scope:spawn(function () + local status, code, signal, err = fibers.perform(cmd:run_op()) + fake.child_exit = { status = status, code = code, signal = signal, err = err } + if status ~= 'signalled' and not (status == 'exited' and code == 0) then + log(('MCU child exited: status=%s code=%s signal=%s err=%s'):format( + tostring(status), tostring(code), tostring(signal), tostring(err) + )) + end + end)) + + local ready = wait_channel_get(ready_ch, 6.0, 'MCU child ready') + if not ready.ok then error(ready.err or 'MCU child failed to start', 0) end + + return { scope = scope, ipc_path = ipc_path, command = cmd } end @@ -1729,605 +1729,605 @@ local PICO2_AB_DEFAULT_REPO = 'https://github.com/jangala-dev/pico2-a-b.git' local PICO2_AB_DEFAULT_REF = 'fabric' local function go_devhost_download_enabled() - local v = env_non_empty('DEVICECODE_GO_DOWNLOAD') - return v == nil or v == '1' or v == 'true' or v == 'yes' + local v = env_non_empty('DEVICECODE_GO_DOWNLOAD') + return v == nil or v == '1' or v == 'true' or v == 'yes' end local function go_devhost_configured() - return env_non_empty('MCU_DEVHOST_PTY_BIN') ~= nil - or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') ~= nil - or env_non_empty('DEVICECODE_GO_ROOT') ~= nil - or go_devhost_download_enabled() + return env_non_empty('MCU_DEVHOST_PTY_BIN') ~= nil + or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') ~= nil + or env_non_empty('DEVICECODE_GO_ROOT') ~= nil + or go_devhost_download_enabled() end local function safe_path_token(s) - s = tostring(s or '') - s = s:gsub('[^%w_.-]', '_') - if s == '' then s = 'default' end - return s + s = tostring(s or '') + s = s:gsub('[^%w_.-]', '_') + if s == '' then s = 'default' end + return s end local function go_devhost_checkout_dir() - local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE - local ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF - return cache .. '/devicecode-go-' .. safe_path_token(ref) + local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE + local ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF + return cache .. '/devicecode-go-' .. safe_path_token(ref) end local function pico2_ab_checkout_dir() - local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE - return cache .. '/pico2-a-b' + local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE + return cache .. '/pico2-a-b' end local function go_devhost_binary_path() - local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE - local ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF - return cache .. '/bin/mcu-devhost-pty-' .. safe_path_token(ref) + local cache = env_non_empty('DEVICECODE_GO_CACHE') or GO_DEVHOST_DEFAULT_CACHE + local ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF + return cache .. '/bin/mcu-devhost-pty-' .. safe_path_token(ref) end local function ensure_git_checkout(label, repo, ref, dir) - local script = table.concat({ - 'set -eu', - 'mkdir -p ' .. shquote(dir), - 'if [ ! -d ' .. shquote(dir .. '/.git') .. ' ]; then', - ' rm -rf ' .. shquote(dir), - ' mkdir -p ' .. shquote(dir), - ' git -C ' .. shquote(dir) .. ' init -q', - ' git -C ' .. shquote(dir) .. ' remote add origin ' .. shquote(repo), - 'fi', - 'git -C ' .. shquote(dir) .. ' fetch --depth 1 origin ' .. shquote(ref), - 'git -C ' .. shquote(dir) .. ' checkout -q --detach FETCH_HEAD', - 'git -C ' .. shquote(dir) .. ' reset -q --hard FETCH_HEAD', - 'git -C ' .. shquote(dir) .. ' clean -q -fdx', - }, '\n') - - log(('go-pty: ensuring %s checkout repo=%s ref=%s dir=%s'):format(label, repo, ref, dir)) - local cmd = exec.command('sh', '-c', script) - local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) - if not (st == 'exited' and code == 0) then - return nil, ('%s checkout failed: status=%s code=%s signal=%s err=%s output=%s'):format( - label, tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) - ) - end - return dir, nil + local script = table.concat({ + 'set -eu', + 'mkdir -p ' .. shquote(dir), + 'if [ ! -d ' .. shquote(dir .. '/.git') .. ' ]; then', + ' rm -rf ' .. shquote(dir), + ' mkdir -p ' .. shquote(dir), + ' git -C ' .. shquote(dir) .. ' init -q', + ' git -C ' .. shquote(dir) .. ' remote add origin ' .. shquote(repo), + 'fi', + 'git -C ' .. shquote(dir) .. ' fetch --depth 1 origin ' .. shquote(ref), + 'git -C ' .. shquote(dir) .. ' checkout -q --detach FETCH_HEAD', + 'git -C ' .. shquote(dir) .. ' reset -q --hard FETCH_HEAD', + 'git -C ' .. shquote(dir) .. ' clean -q -fdx', + }, '\n') + + log(('go-pty: ensuring %s checkout repo=%s ref=%s dir=%s'):format(label, repo, ref, dir)) + local cmd = exec.command('sh', '-c', script) + local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) + if not (st == 'exited' and code == 0) then + return nil, ('%s checkout failed: status=%s code=%s signal=%s err=%s output=%s'):format( + label, tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) + ) + end + return dir, nil end local function build_go_devhost_binary(go_dir) - local bin = go_devhost_binary_path() - local script = table.concat({ - 'set -eu', - 'mkdir -p ' .. shquote((bin:gsub('/[^/]+$', ''))), - 'cd ' .. shquote(go_dir), - 'GOMAXPROCS=1 go build -o ' .. shquote(bin) .. ' ./cmd/mcu-devhost-pty', - }, '\n') - log(('go-pty: building Go MCU devhost binary path=%s'):format(bin)) - local cmd = exec.command('sh', '-c', script) - local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) - if not (st == 'exited' and code == 0) then - return nil, ('Go devhost build failed: status=%s code=%s signal=%s err=%s output=%s'):format( - tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) - ) - end - return bin, nil + local bin = go_devhost_binary_path() + local script = table.concat({ + 'set -eu', + 'mkdir -p ' .. shquote((bin:gsub('/[^/]+$', ''))), + 'cd ' .. shquote(go_dir), + 'GOMAXPROCS=1 go build -o ' .. shquote(bin) .. ' ./cmd/mcu-devhost-pty', + }, '\n') + log(('go-pty: building Go MCU devhost binary path=%s'):format(bin)) + local cmd = exec.command('sh', '-c', script) + local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) + if not (st == 'exited' and code == 0) then + return nil, ('Go devhost build failed: status=%s code=%s signal=%s err=%s output=%s'):format( + tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) + ) + end + return bin, nil end local function ensure_go_devhost_checkout() - local root = env_non_empty('DEVICECODE_GO_ROOT') - if root ~= nil then - local bin, berr = build_go_devhost_binary(root) - if not bin then return nil, berr end - return root, bin, nil - end - if not go_devhost_download_enabled() then - return nil, nil, 'set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or DEVICECODE_GO_DOWNLOAD=1 to run the Go devhost MCU PTY rig' - end + local root = env_non_empty('DEVICECODE_GO_ROOT') + if root ~= nil then + local bin, berr = build_go_devhost_binary(root) + if not bin then return nil, berr end + return root, bin, nil + end + if not go_devhost_download_enabled() then + return nil, nil, 'set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or DEVICECODE_GO_DOWNLOAD=1 to run the Go devhost MCU PTY rig' + end - local go_repo = env_non_empty('DEVICECODE_GO_REPO') or GO_DEVHOST_DEFAULT_REPO - local go_ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF - local go_dir = go_devhost_checkout_dir() + local go_repo = env_non_empty('DEVICECODE_GO_REPO') or GO_DEVHOST_DEFAULT_REPO + local go_ref = env_non_empty('DEVICECODE_GO_REF') or GO_DEVHOST_DEFAULT_REF + local go_dir = go_devhost_checkout_dir() - local pico_repo = env_non_empty('DEVICECODE_PICO2_AB_REPO') or PICO2_AB_DEFAULT_REPO - local pico_ref = env_non_empty('DEVICECODE_PICO2_AB_REF') or PICO2_AB_DEFAULT_REF - local pico_dir = pico2_ab_checkout_dir() + local pico_repo = env_non_empty('DEVICECODE_PICO2_AB_REPO') or PICO2_AB_DEFAULT_REPO + local pico_ref = env_non_empty('DEVICECODE_PICO2_AB_REF') or PICO2_AB_DEFAULT_REF + local pico_dir = pico2_ab_checkout_dir() - local dir, err = ensure_git_checkout('Go devhost', go_repo, go_ref, go_dir) - if not dir then return nil, nil, err end + local dir, err = ensure_git_checkout('Go devhost', go_repo, go_ref, go_dir) + if not dir then return nil, nil, err end - -- devicecode-go's go.mod uses a local replacement for pico2-a-b at - -- ../pico2-a-b. Keep that sibling checkout in the same cache root so - -- the Lua repo does not need direct access to a monorepo workspace. - local pdir, perr = ensure_git_checkout('pico2-a-b', pico_repo, pico_ref, pico_dir) - if not pdir then return nil, nil, perr end + -- devicecode-go's go.mod uses a local replacement for pico2-a-b at + -- ../pico2-a-b. Keep that sibling checkout in the same cache root so + -- the Lua repo does not need direct access to a monorepo workspace. + local pdir, perr = ensure_git_checkout('pico2-a-b', pico_repo, pico_ref, pico_dir) + if not pdir then return nil, nil, perr end - local bin, berr = build_go_devhost_binary(go_dir) - if not bin then return nil, nil, berr end + local bin, berr = build_go_devhost_binary(go_dir) + if not bin then return nil, nil, berr end - return go_dir, bin, nil + return go_dir, bin, nil end local function resolve_go_devhost_provider() - local bin = env_non_empty('MCU_DEVHOST_PTY_BIN') or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') - if bin ~= nil then - return { bin = bin }, nil - end - if not command_available('go') then - return nil, 'go is not installed' - end - local root = env_non_empty('DEVICECODE_GO_ROOT') - if root ~= nil then - local built_bin, berr = build_go_devhost_binary(root) - if not built_bin then return nil, berr end - return { bin = built_bin, cwd = root }, nil - end - if not go_devhost_download_enabled() then - return nil, 'set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or DEVICECODE_GO_DOWNLOAD=1 to run the Go devhost MCU PTY rig' - end - if not command_available('git') then - return nil, 'git is not installed' - end - local go_dir, built_bin, err = ensure_go_devhost_checkout() - if not go_dir or not built_bin then - return nil, err or 'Go devhost checkout/build unavailable' - end - return { bin = built_bin, cwd = go_dir }, nil + local bin = env_non_empty('MCU_DEVHOST_PTY_BIN') or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') + if bin ~= nil then + return { bin = bin }, nil + end + if not command_available('go') then + return nil, 'go is not installed' + end + local root = env_non_empty('DEVICECODE_GO_ROOT') + if root ~= nil then + local built_bin, berr = build_go_devhost_binary(root) + if not built_bin then return nil, berr end + return { bin = built_bin, cwd = root }, nil + end + if not go_devhost_download_enabled() then + return nil, 'set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or DEVICECODE_GO_DOWNLOAD=1 to run the Go devhost MCU PTY rig' + end + if not command_available('git') then + return nil, 'git is not installed' + end + local go_dir, built_bin, err = ensure_go_devhost_checkout() + if not go_dir or not built_bin then + return nil, err or 'Go devhost checkout/build unavailable' + end + return { bin = built_bin, cwd = go_dir }, nil end local function go_devhost_command_spec(uart_slave, state_dir, fake, opts) - opts = opts or {} - local provider = opts.provider - local argv - local cwd - if provider ~= nil then - argv = { provider.bin } - cwd = provider.cwd - else - local bin = env_non_empty('MCU_DEVHOST_PTY_BIN') or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') - local root - local root_err - if bin ~= nil then - argv = { bin } - else - local built_bin - root, built_bin, root_err = ensure_go_devhost_checkout() - if root == nil or built_bin == nil then return nil, root_err end - argv = { built_bin } - cwd = root - end - end - argv[#argv + 1] = '--uart'; argv[#argv + 1] = uart_slave - argv[#argv + 1] = '--state-dir'; argv[#argv + 1] = state_dir - argv[#argv + 1] = '--node'; argv[#argv + 1] = opts.node or 'mcu' - -- The devhost CM5 Fabric config in this test uses local_node='cm5'. - -- Keep the Go peer expectation aligned with that test-local node name. - argv[#argv + 1] = '--peer'; argv[#argv + 1] = opts.peer or 'cm5' - argv[#argv + 1] = '--initial-image-id'; argv[#argv + 1] = fake.old_image_id or 'mcu-image-old' - argv[#argv + 1] = '--initial-version'; argv[#argv + 1] = fake.old_version or '10.0' - argv[#argv + 1] = '--initial-build-id'; argv[#argv + 1] = fake.old_build_id or 'devhost-initial' - argv[#argv + 1] = '--reboot-exit-code'; argv[#argv + 1] = tostring(opts.reboot_exit_code or 42) - argv.cwd = cwd - argv.stdin = 'null' - argv.stdout = 'pipe' - argv.stderr = 'inherit' - argv.shutdown_grace = 1.0 - argv.env = { GOMAXPROCS = tostring(opts.gomaxprocs or 1) } - return argv, nil + opts = opts or {} + local provider = opts.provider + local argv + local cwd + if provider ~= nil then + argv = { provider.bin } + cwd = provider.cwd + else + local bin = env_non_empty('MCU_DEVHOST_PTY_BIN') or env_non_empty('DEVICECODE_GO_MCU_DEVHOST_PTY_BIN') + local root + local root_err + if bin ~= nil then + argv = { bin } + else + local built_bin + root, built_bin, root_err = ensure_go_devhost_checkout() + if root == nil or built_bin == nil then return nil, root_err end + argv = { built_bin } + cwd = root + end + end + argv[#argv + 1] = '--uart'; argv[#argv + 1] = uart_slave + argv[#argv + 1] = '--state-dir'; argv[#argv + 1] = state_dir + argv[#argv + 1] = '--node'; argv[#argv + 1] = opts.node or 'mcu' + -- The devhost CM5 Fabric config in this test uses local_node='cm5'. + -- Keep the Go peer expectation aligned with that test-local node name. + argv[#argv + 1] = '--peer'; argv[#argv + 1] = opts.peer or 'cm5' + argv[#argv + 1] = '--initial-image-id'; argv[#argv + 1] = fake.old_image_id or 'mcu-image-old' + argv[#argv + 1] = '--initial-version'; argv[#argv + 1] = fake.old_version or '10.0' + argv[#argv + 1] = '--initial-build-id'; argv[#argv + 1] = fake.old_build_id or 'devhost-initial' + argv[#argv + 1] = '--reboot-exit-code'; argv[#argv + 1] = tostring(opts.reboot_exit_code or 42) + argv.cwd = cwd + argv.stdin = 'null' + argv.stdout = 'pipe' + argv.stderr = 'inherit' + argv.shutdown_grace = 1.0 + argv.env = { GOMAXPROCS = tostring(opts.gomaxprocs or 1) } + return argv, nil end local function start_go_mcu_instance(parent_scope, fake, uart_port, state_dir, opts) - assert(uart_port and uart_port.slave_name, 'MCU PTY slave required') - assert(state_dir and state_dir ~= '', 'Go MCU state dir required') - fake.expect_mcu_fabric_event = false - fake.mcu_fabric_session_seen = false - fake.mcu_fabric_session = nil - local scope = assert(parent_scope:child()) - mkdir_p(state_dir) - - local spec, spec_err = go_devhost_command_spec(uart_port.slave_name, state_dir, fake, opts) - assert_not_nil(spec, spec_err) - local cmd = exec.command(spec) - local stdout, sout_err = cmd:stdout_stream() - assert_not_nil(stdout, sout_err or 'Go MCU child stdout pipe unavailable') - - local ready_ch = channel.new(1) - - assert_true(scope:spawn(function () - while true do - local line, rerr = fibers.perform(stdout:read_line_op()) - if line == nil then - if fake.child_ready ~= true then - ready_ch:put({ ok = false, err = rerr or 'go mcu child stdout closed before ready' }) - end - return - end - local ev, derr = cjson.decode(line) - if not ev then - log('Go MCU child sent invalid JSON: ' .. tostring(derr) .. ' line=' .. tostring(line)) - else - update_fake_from_mcu_child(fake, ev) - if ev.event == 'ready' then - ready_ch:put({ ok = true }) - end - end - end - end)) - - assert_true(scope:spawn(function () - local status, code, signal, err = fibers.perform(cmd:run_op()) - fake.child_exit = { status = status, code = code, signal = signal, err = err } - if status == 'exited' and code == (opts and opts.reboot_exit_code or 42) then - fake.reboot_exit_seen = true - log(('Go MCU child exited for simulated reboot: code=%s'):format(tostring(code))) - elseif status ~= 'signalled' and not (status == 'exited' and code == 0) then - log(('Go MCU child exited: status=%s code=%s signal=%s err=%s'):format( - tostring(status), tostring(code), tostring(signal), tostring(err) - )) - end - end)) - - local ready = wait_channel_get(ready_ch, 20.0, 'Go MCU child ready') - if not ready.ok then error(ready.err or 'Go MCU child failed to start', 0) end - - return { scope = scope, command = cmd, state_dir = state_dir } + assert(uart_port and uart_port.slave_name, 'MCU PTY slave required') + assert(state_dir and state_dir ~= '', 'Go MCU state dir required') + fake.expect_mcu_fabric_event = false + fake.mcu_fabric_session_seen = false + fake.mcu_fabric_session = nil + local scope = assert(parent_scope:child()) + mkdir_p(state_dir) + + local spec, spec_err = go_devhost_command_spec(uart_port.slave_name, state_dir, fake, opts) + assert_not_nil(spec, spec_err) + local cmd = exec.command(spec) + local stdout, sout_err = cmd:stdout_stream() + assert_not_nil(stdout, sout_err or 'Go MCU child stdout pipe unavailable') + + local ready_ch = channel.new(1) + + assert_true(scope:spawn(function () + while true do + local line, rerr = fibers.perform(stdout:read_line_op()) + if line == nil then + if fake.child_ready ~= true then + ready_ch:put({ ok = false, err = rerr or 'go mcu child stdout closed before ready' }) + end + return + end + local ev, derr = cjson.decode(line) + if not ev then + log('Go MCU child sent invalid JSON: ' .. tostring(derr) .. ' line=' .. tostring(line)) + else + update_fake_from_mcu_child(fake, ev) + if ev.event == 'ready' then + ready_ch:put({ ok = true }) + end + end + end + end)) + + assert_true(scope:spawn(function () + local status, code, signal, err = fibers.perform(cmd:run_op()) + fake.child_exit = { status = status, code = code, signal = signal, err = err } + if status == 'exited' and code == (opts and opts.reboot_exit_code or 42) then + fake.reboot_exit_seen = true + log(('Go MCU child exited for simulated reboot: code=%s'):format(tostring(code))) + elseif status ~= 'signalled' and not (status == 'exited' and code == 0) then + log(('Go MCU child exited: status=%s code=%s signal=%s err=%s'):format( + tostring(status), tostring(code), tostring(signal), tostring(err) + )) + end + end)) + + local ready = wait_channel_get(ready_ch, 20.0, 'Go MCU child ready') + if not ready.ok then error(ready.err or 'Go MCU child failed to start', 0) end + + return { scope = scope, command = cmd, state_dir = state_dir } end local function stop_instance(inst, reason) - if inst and inst.scope then - inst.scope:cancel(reason or 'test reboot') - fibers.perform(inst.scope:join_op()) - end + if inst and inst.scope then + inst.scope:cancel(reason or 'test reboot') + fibers.perform(inst.scope:join_op()) + end end local function parse_curl_output(out) - out = tostring(out or '') - local status = out:match('\n__HTTP_STATUS__:(%d+)%s*$') - local body = out:gsub('\n__HTTP_STATUS__:%d+%s*$', '') - return status, body + out = tostring(out or '') + local status = out:match('\n__HTTP_STATUS__:(%d+)%s*$') + local body = out:gsub('\n__HTTP_STATUS__:%d+%s*$', '') + return status, body end local function run_curl(args) - local cmd = exec.command('curl', unpack(args)) - local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) - if not (st == 'exited' and code == 0) then - error(('curl failed: status=%s code=%s signal=%s err=%s output=%s'):format( - tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) - ), 0) - end - return parse_curl_output(out) + local cmd = exec.command('curl', unpack(args)) + local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) + if not (st == 'exited' and code == 0) then + error(('curl failed: status=%s code=%s signal=%s err=%s output=%s'):format( + tostring(st), tostring(code), tostring(sig), tostring(err), tostring(out) + ), 0) + end + return parse_curl_output(out) end local function append_headers(args, headers) - for k, v in pairs(headers or {}) do - args[#args + 1] = '--header' - args[#args + 1] = tostring(k) .. ': ' .. tostring(v) - end + for k, v in pairs(headers or {}) do + args[#args + 1] = '--header' + args[#args + 1] = tostring(k) .. ': ' .. tostring(v) + end end local function run_http_upload(_scope, port, body, headers) - local path = ('/tmp/devicecode-mcu-http-uart-upload-%d-%d.bin'):format(os.time(), math.random(100000, 999999)) - write_file(path, body) - local args = { - '--silent', '--show-error', - '--request', 'POST', - '--header', 'content-type: application/octet-stream', - '--data-binary', '@' .. path, - '--write-out', '\n__HTTP_STATUS__:%{http_code}\n', - } - append_headers(args, headers) - args[#args + 1] = ('http://127.0.0.1:%d/api/update/upload'):format(port) - local status, resp_body = run_curl(args) - os.remove(path) - return status, resp_body + local path = ('/tmp/devicecode-mcu-http-uart-upload-%d-%d.bin'):format(os.time(), math.random(100000, 999999)) + write_file(path, body) + local args = { + '--silent', '--show-error', + '--request', 'POST', + '--header', 'content-type: application/octet-stream', + '--data-binary', '@' .. path, + '--write-out', '\n__HTTP_STATUS__:%{http_code}\n', + } + append_headers(args, headers) + args[#args + 1] = ('http://127.0.0.1:%d/api/update/upload'):format(port) + local status, resp_body = run_curl(args) + os.remove(path) + return status, resp_body end local function run_http_json(_scope, port, path, payload, headers) - local args = { - '--silent', '--show-error', - '--request', 'POST', - '--header', 'content-type: application/json', - '--data', assert(cjson.encode(payload or {})), - '--write-out', '\n__HTTP_STATUS__:%{http_code}\n', - } - append_headers(args, headers) - args[#args + 1] = ('http://127.0.0.1:%d%s'):format(port, path) - local status, resp_body = run_curl(args) - return status, resp_body, resp_body and cjson.decode(resp_body) or nil + local args = { + '--silent', '--show-error', + '--request', 'POST', + '--header', 'content-type: application/json', + '--data', assert(cjson.encode(payload or {})), + '--write-out', '\n__HTTP_STATUS__:%{http_code}\n', + } + append_headers(args, headers) + args[#args + 1] = ('http://127.0.0.1:%d%s'):format(port, path) + local status, resp_body = run_curl(args) + return status, resp_body, resp_body and cjson.decode(resp_body) or nil end function T.ui_http_mcu_update_short_stage_timeout_fails_via_outer_choice() - runfibers.run(function (root_scope) - local roots = temp_roots() - local blob = make_realistic_mcu_blob('mcu-image-new') - local port = 30000 + math.random(0, 20000) - local fake = { old_image_id = 'mcu-image-old' } - local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } - - log('short-timeout: booting CM5 instance') - local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5, { - stage_timeout_s = SHORT_STAGE_TIMEOUT_S, - stage_action_timeout_s = REALISTIC_TRANSFER_TIMEOUT_S, - }) - log('short-timeout: booting fake MCU instance') - local mcu = start_mcu_instance(root_scope, fake, uart.mcu) - log('short-timeout: starting Fabric pair over PTY UART middleware') - local pair = start_fabric_pair(root_scope, cm5.bus, fake, uart) - log('short-timeout: starting CM5 Device/Update/UI services') - cm5.start_services_after_fabric() - - wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') - - log(('short-timeout: sending unauthenticated upload through curl with %.1fs update stage deadline'):format(SHORT_STAGE_TIMEOUT_S)) - local status, body = run_http_upload(root_scope, port, blob, nil) - assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) - - log('short-timeout: waiting for job to fail due to the outer stage deadline') - local failed = wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'failed', 20.0, function () - return (('mcu_received=%d chunks=%d uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d; %s'):format( - fake.receive_bytes or 0, - fake.receive_chunks or 0, - pair.uart_stats and pair.uart_stats.bytes or 0, - pair.uart_stats and pair.uart_stats.fragments or 0, - pair.uart_stats and pair.uart_stats.pauses or 0, - pair.uart_stats and pair.uart_stats.malformed_lines or 0, - fabric_progress_fragment(pair.cm5_conn) - )) - end) - assert_contains(failed.error, 'stage_op_timeout', 'job should fail from the active stage deadline') - assert_true((fake.receive_bytes or 0) < #blob, 'short timeout should not stage the full artifact') - assert_true(fake.staged == nil, 'short timeout must not commit a staged artifact') - assert_true(fake.commit_seen ~= true, 'short timeout must not reach the MCU commit RPC') - fibers.perform(sleep.sleep_op(0.25)) - log(('short-timeout: cancellation observed abort_reason=%s abort_count=%d received=%d chunks=%d'):format( - tostring(fake.abort_reason), fake.abort_count or 0, fake.receive_bytes or 0, fake.receive_chunks or 0 - )) - - log('short-timeout: cleanup') - stop_fabric_pair(pair, 'short timeout complete') - stop_instance(cm5, 'short timeout complete') - stop_instance(mcu, 'short timeout complete') - rm_rf(roots.base) - end, { timeout = 60.0 }) + runfibers.run(function (root_scope) + local roots = temp_roots() + local blob = make_realistic_mcu_blob('mcu-image-new') + local port = 30000 + math.random(0, 20000) + local fake = { old_image_id = 'mcu-image-old' } + local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } + + log('short-timeout: booting CM5 instance') + local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5, { + stage_timeout_s = SHORT_STAGE_TIMEOUT_S, + stage_action_timeout_s = REALISTIC_TRANSFER_TIMEOUT_S, + }) + log('short-timeout: booting fake MCU instance') + local mcu = start_mcu_instance(root_scope, fake, uart.mcu) + log('short-timeout: starting Fabric pair over PTY UART middleware') + local pair = start_fabric_pair(root_scope, cm5.bus, fake, uart) + log('short-timeout: starting CM5 Device/Update/UI services') + cm5.start_services_after_fabric() + + wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') + + log(('short-timeout: sending unauthenticated upload through curl with %.1fs update stage deadline'):format(SHORT_STAGE_TIMEOUT_S)) + local status, body = run_http_upload(root_scope, port, blob, nil) + assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) + + log('short-timeout: waiting for job to fail due to the outer stage deadline') + local failed = wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'failed', 20.0, function () + return (('mcu_received=%d chunks=%d uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d; %s'):format( + fake.receive_bytes or 0, + fake.receive_chunks or 0, + pair.uart_stats and pair.uart_stats.bytes or 0, + pair.uart_stats and pair.uart_stats.fragments or 0, + pair.uart_stats and pair.uart_stats.pauses or 0, + pair.uart_stats and pair.uart_stats.malformed_lines or 0, + fabric_progress_fragment(pair.cm5_conn) + )) + end) + assert_contains(failed.error, 'stage_op_timeout', 'job should fail from the active stage deadline') + assert_true((fake.receive_bytes or 0) < #blob, 'short timeout should not stage the full artifact') + assert_true(fake.staged == nil, 'short timeout must not commit a staged artifact') + assert_true(fake.commit_seen ~= true, 'short timeout must not reach the MCU commit RPC') + fibers.perform(sleep.sleep_op(0.25)) + log(('short-timeout: cancellation observed abort_reason=%s abort_count=%d received=%d chunks=%d'):format( + tostring(fake.abort_reason), fake.abort_count or 0, fake.receive_bytes or 0, fake.receive_chunks or 0 + )) + + log('short-timeout: cleanup') + stop_fabric_pair(pair, 'short timeout complete') + stop_instance(cm5, 'short timeout complete') + stop_instance(mcu, 'short timeout complete') + rm_rf(roots.base) + end, { timeout = 60.0 }) end function T.ui_http_mcu_update_survives_fake_reboot_and_reconciles() - runfibers.run(function (root_scope) - local roots = temp_roots() - local blob = make_realistic_mcu_blob('mcu-image-new') - local port = 30000 + math.random(0, 20000) - local fake = { old_image_id = 'mcu-image-old' } - - local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } - - log('booting initial CM5 instance') - local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5) - log('booting initial fake MCU instance') - local mcu = start_mcu_instance(root_scope, fake, uart.mcu) - log('starting initial Fabric pair over PTY UART middleware') - local pair = start_fabric_pair(root_scope, cm5.bus, fake, uart) - log('starting CM5 Device/Update/UI services') - cm5.start_services_after_fabric() - - log('waiting for initial canonical MCU software state') - wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') - - log(('sending real HTTP upload through curl (%d byte artifact)'):format(#blob)) - local stage_started = fibers.now() - local status, body = run_http_upload(root_scope, port, blob, nil) - assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) - local decoded = assert(cjson.decode(body), body) - assert_eq(decoded.status, 'ok') - assert_eq(decoded.job_id, 'job-mcu-http-uart') - assert_eq(decoded.job, nil) - - log('waiting for job awaiting_commit') - wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'awaiting_commit', REALISTIC_TRANSFER_TIMEOUT_S, function () - return (('mcu_received=%d chunks=%d uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d; %s'):format( - fake.receive_bytes or 0, - fake.receive_chunks or 0, - pair.uart_stats and pair.uart_stats.bytes or 0, - pair.uart_stats and pair.uart_stats.fragments or 0, - pair.uart_stats and pair.uart_stats.pauses or 0, - pair.uart_stats and pair.uart_stats.malformed_lines or 0, - fabric_progress_fragment(pair.cm5_conn) - )) - end) - local expected_payload_digest = xxhash32.digest_hex(blob) - assert_true(probe.wait_until(function () - return fake.staged - and fake.staged.size == #blob - and fake.staged.payload_digest == expected_payload_digest - end, { timeout = 10.0 }), 'fake MCU should stage transferred artifact with matching digest') - local stage_elapsed = fibers.now() - stage_started - local uart_bytes = pair.uart_stats and pair.uart_stats.bytes or 0 - local uart_fragments = pair.uart_stats and pair.uart_stats.fragments or 0 - local uart_pauses = pair.uart_stats and pair.uart_stats.pauses or 0 - local uart_malformed_lines = pair.uart_stats and pair.uart_stats.malformed_lines or 0 - log(('artifact staged in %.1fs; middleware relayed %d bytes in %d fragments with %d long pauses and %d malformed JSONL lines'):format( - stage_elapsed, uart_bytes, uart_fragments, uart_pauses, uart_malformed_lines - )) - assert_true(uart_bytes >= #blob, 'UART middleware should relay at least the artifact payload size') - assert_true(uart_malformed_lines >= 1, 'UART middleware should inject at least one standalone malformed JSONL line') - assert_true( - uart_fragments >= math.floor(#blob / UART_MAX_FRAGMENT_BYTES), - 'UART middleware should fragment the byte stream heavily' - ) - if #blob >= (2 * UART_LONG_PAUSE_EVERY_BYTES) then - assert_true(uart_pauses >= 2, 'large UART test should inject at least two long scheduling pauses') - end - assert_true( - stage_elapsed >= ((#blob / UART_BYTES_PER_SEC) * 0.75), - 'artifact should take UART-paced time to stage' - ) - log_fabric_status(cm5.conn, 'CM5 fabric post-stage status') - - log('committing job through curl HTTP update commit route') - local commit_status, commit_body, commit_decoded = run_http_json( - root_scope, - port, - '/api/update/commit', - { job_id = 'job-mcu-http-uart' }, - nil - ) - assert_eq(commit_status, '200', commit_body) - assert_not_nil(commit_decoded and commit_decoded.value, commit_body) - assert_eq(commit_decoded.value.ok, true) - log('waiting for job awaiting_return') - wait_job(cm5.conn, 'job-mcu-http-uart', 'awaiting_return', 6.0) - assert_true(probe.wait_until(function () return fake.commit_seen == true end, { timeout = 2.0 }), 'fake MCU should see commit') - assert_eq(fake.committed_image_id, 'mcu-image-new') - - log('fake reboot: stopping Fabric pair') - stop_fabric_pair(pair, 'fake fabric link reboot') - log('fake reboot: stopping CM5 instance') - stop_instance(cm5, 'fake cm5 reboot') - log('fake reboot: stopping MCU instance') - stop_instance(mcu, 'fake mcu reboot') - - local uart_b = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } - log('reboot: starting fresh CM5 instance') - local cm5b = start_cm5_instance(root_scope, roots, nil, uart_b.cm5) - log('reboot: starting fresh MCU instance') - local mcub = start_mcu_instance(root_scope, fake, uart_b.mcu) - log('reboot: starting fresh Fabric pair over PTY UART middleware') - local pair_b = start_fabric_pair(root_scope, cm5b.bus, fake, uart_b) - log_fabric_status(cm5b.conn, 'CM5 fabric reboot-established status') - log('reboot: starting fresh CM5 Device/Update services') - cm5b.start_services_after_fabric() - - log('reboot: waiting for post-boot canonical MCU software state') - wait_component_software(cm5b.conn, 'mcu-image-new', 'mcu-boot-2') - log('reboot: waiting for job succeeded') - local final_job = wait_job(cm5b.conn, 'job-mcu-http-uart', 'succeeded', 8.0) - assert_eq(final_job.component, 'mcu') - assert_eq(final_job.job_id, 'job-mcu-http-uart') - assert_not_nil(final_job.commit_attempt, 'job should carry commit attempt details') - if final_job.commit_attempt and final_job.commit_attempt.pre_commit then - assert_eq(final_job.commit_attempt.pre_commit.pre_commit_boot_id, 'mcu-boot-1') - end - - log('cleanup: stopping second Fabric pair') - stop_fabric_pair(pair_b, 'test complete') - log('cleanup: stopping second CM5 instance') - stop_instance(cm5b, 'test complete') - log('cleanup: stopping second MCU instance') - stop_instance(mcub, 'test complete') - log('cleanup: removing temporary roots') - rm_rf(roots.base) - end, { timeout = REALISTIC_TEST_TIMEOUT_S }) + runfibers.run(function (root_scope) + local roots = temp_roots() + local blob = make_realistic_mcu_blob('mcu-image-new') + local port = 30000 + math.random(0, 20000) + local fake = { old_image_id = 'mcu-image-old' } + + local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } + + log('booting initial CM5 instance') + local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5) + log('booting initial fake MCU instance') + local mcu = start_mcu_instance(root_scope, fake, uart.mcu) + log('starting initial Fabric pair over PTY UART middleware') + local pair = start_fabric_pair(root_scope, cm5.bus, fake, uart) + log('starting CM5 Device/Update/UI services') + cm5.start_services_after_fabric() + + log('waiting for initial canonical MCU software state') + wait_component_software(cm5.conn, 'mcu-image-old', 'mcu-boot-1') + + log(('sending real HTTP upload through curl (%d byte artifact)'):format(#blob)) + local stage_started = fibers.now() + local status, body = run_http_upload(root_scope, port, blob, nil) + assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) + local decoded = assert(cjson.decode(body), body) + assert_eq(decoded.status, 'ok') + assert_eq(decoded.job_id, 'job-mcu-http-uart') + assert_eq(decoded.job, nil) + + log('waiting for job awaiting_commit') + wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'awaiting_commit', REALISTIC_TRANSFER_TIMEOUT_S, function () + return (('mcu_received=%d chunks=%d uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d; %s'):format( + fake.receive_bytes or 0, + fake.receive_chunks or 0, + pair.uart_stats and pair.uart_stats.bytes or 0, + pair.uart_stats and pair.uart_stats.fragments or 0, + pair.uart_stats and pair.uart_stats.pauses or 0, + pair.uart_stats and pair.uart_stats.malformed_lines or 0, + fabric_progress_fragment(pair.cm5_conn) + )) + end) + local expected_payload_digest = xxhash32.digest_hex(blob) + assert_true(probe.wait_until(function () + return fake.staged + and fake.staged.size == #blob + and fake.staged.payload_digest == expected_payload_digest + end, { timeout = 10.0 }), 'fake MCU should stage transferred artifact with matching digest') + local stage_elapsed = fibers.now() - stage_started + local uart_bytes = pair.uart_stats and pair.uart_stats.bytes or 0 + local uart_fragments = pair.uart_stats and pair.uart_stats.fragments or 0 + local uart_pauses = pair.uart_stats and pair.uart_stats.pauses or 0 + local uart_malformed_lines = pair.uart_stats and pair.uart_stats.malformed_lines or 0 + log(('artifact staged in %.1fs; middleware relayed %d bytes in %d fragments with %d long pauses and %d malformed JSONL lines'):format( + stage_elapsed, uart_bytes, uart_fragments, uart_pauses, uart_malformed_lines + )) + assert_true(uart_bytes >= #blob, 'UART middleware should relay at least the artifact payload size') + assert_true(uart_malformed_lines >= 1, 'UART middleware should inject at least one standalone malformed JSONL line') + assert_true( + uart_fragments >= math.floor(#blob / UART_MAX_FRAGMENT_BYTES), + 'UART middleware should fragment the byte stream heavily' + ) + if #blob >= (2 * UART_LONG_PAUSE_EVERY_BYTES) then + assert_true(uart_pauses >= 2, 'large UART test should inject at least two long scheduling pauses') + end + assert_true( + stage_elapsed >= ((#blob / UART_BYTES_PER_SEC) * 0.75), + 'artifact should take UART-paced time to stage' + ) + log_fabric_status(cm5.conn, 'CM5 fabric post-stage status') + + log('committing job through curl HTTP update commit route') + local commit_status, commit_body, commit_decoded = run_http_json( + root_scope, + port, + '/api/update/commit', + { job_id = 'job-mcu-http-uart' }, + nil + ) + assert_eq(commit_status, '200', commit_body) + assert_not_nil(commit_decoded and commit_decoded.value, commit_body) + assert_eq(commit_decoded.value.ok, true) + log('waiting for job awaiting_return') + wait_job(cm5.conn, 'job-mcu-http-uart', 'awaiting_return', 6.0) + assert_true(probe.wait_until(function () return fake.commit_seen == true end, { timeout = 2.0 }), 'fake MCU should see commit') + assert_eq(fake.committed_image_id, 'mcu-image-new') + + log('fake reboot: stopping Fabric pair') + stop_fabric_pair(pair, 'fake fabric link reboot') + log('fake reboot: stopping CM5 instance') + stop_instance(cm5, 'fake cm5 reboot') + log('fake reboot: stopping MCU instance') + stop_instance(mcu, 'fake mcu reboot') + + local uart_b = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } + log('reboot: starting fresh CM5 instance') + local cm5b = start_cm5_instance(root_scope, roots, nil, uart_b.cm5) + log('reboot: starting fresh MCU instance') + local mcub = start_mcu_instance(root_scope, fake, uart_b.mcu) + log('reboot: starting fresh Fabric pair over PTY UART middleware') + local pair_b = start_fabric_pair(root_scope, cm5b.bus, fake, uart_b) + log_fabric_status(cm5b.conn, 'CM5 fabric reboot-established status') + log('reboot: starting fresh CM5 Device/Update services') + cm5b.start_services_after_fabric() + + log('reboot: waiting for post-boot canonical MCU software state') + wait_component_software(cm5b.conn, 'mcu-image-new', 'mcu-boot-2') + log('reboot: waiting for job succeeded') + local final_job = wait_job(cm5b.conn, 'job-mcu-http-uart', 'succeeded', 8.0) + assert_eq(final_job.component, 'mcu') + assert_eq(final_job.job_id, 'job-mcu-http-uart') + assert_not_nil(final_job.commit_attempt, 'job should carry commit attempt details') + if final_job.commit_attempt and final_job.commit_attempt.pre_commit then + assert_eq(final_job.commit_attempt.pre_commit.pre_commit_boot_id, 'mcu-boot-1') + end + + log('cleanup: stopping second Fabric pair') + stop_fabric_pair(pair_b, 'test complete') + log('cleanup: stopping second CM5 instance') + stop_instance(cm5b, 'test complete') + log('cleanup: stopping second MCU instance') + stop_instance(mcub, 'test complete') + log('cleanup: removing temporary roots') + rm_rf(roots.base) + end, { timeout = REALISTIC_TEST_TIMEOUT_S }) end local function run_go_devhost_pty_cycle(opts) - opts = opts or {} - if not go_devhost_configured() then - log('skipping Go devhost PTY rig: set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or leave DEVICECODE_GO_DOWNLOAD enabled') - return { skipped = true, reason = 'go_devhost_not_configured' } - end - local result - runfibers.run(function (root_scope) - local provider, skip_reason = resolve_go_devhost_provider() - if provider == nil then - log('skipping Go devhost PTY rig: ' .. tostring(skip_reason)) - result = { skipped = true, reason = skip_reason } - return - end - local roots = temp_roots() - local state_dir = roots.base .. '/go-mcu-state' - local blob = make_realistic_mcu_blob(opts.image_id or 'mcu-image-go-new', opts.blob_bytes) - local port = 30000 + math.random(0, 20000) - local fake = { old_image_id = opts.old_image_id or 'mcu-image-go-old', old_version = opts.old_version or '10.0' } - local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } - local pair, pair_b - - log(('go-pty[%s]: booting initial CM5 instance'):format(opts.label or 'case')) - local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5) - log(('go-pty[%s]: booting Go MCU devhost instance'):format(opts.label or 'case')) - local mcu = start_go_mcu_instance(root_scope, fake, uart.mcu, state_dir, { provider = provider }) - log(('go-pty[%s]: starting Fabric link over PTY UART middleware'):format(opts.label or 'case')) - pair = start_fabric_pair(root_scope, cm5.bus, fake, uart, { uart = opts.uart }) - log(('go-pty[%s]: starting CM5 Device/Update/UI services'):format(opts.label or 'case')) - cm5.start_services_after_fabric() - - wait_component_software(cm5.conn, fake.old_image_id, nil) - - log(('go-pty[%s]: sending HTTP upload through CM5 UI (%d byte artifact)'):format(opts.label or 'case', #blob)) - local status, body = run_http_upload(root_scope, port, blob, nil) - assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) - local decoded = assert(cjson.decode(body), body) - assert_eq(decoded.status, 'ok') - assert_eq(decoded.job_id, 'job-mcu-http-uart') - assert_eq(decoded.job, nil) - - log(('go-pty[%s]: waiting for Go MCU-backed job awaiting_commit'):format(opts.label or 'case')) - wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'awaiting_commit', opts.stage_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S, function () - return (('uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d uart_drop_bytes=%d uart_drop_nl=%d fault_pauses=%d; %s'):format( - pair.uart_stats and pair.uart_stats.bytes or 0, - pair.uart_stats and pair.uart_stats.fragments or 0, - pair.uart_stats and pair.uart_stats.pauses or 0, - pair.uart_stats and pair.uart_stats.malformed_lines or 0, - pair.uart_stats and pair.uart_stats.dropped_bytes or 0, - pair.uart_stats and pair.uart_stats.dropped_newlines or 0, - pair.uart_stats and pair.uart_stats.fault_pauses or 0, - fabric_progress_fragment(pair.cm5_conn) - )) - end) - - log(('go-pty[%s]: committing job through public HTTP update commit route'):format(opts.label or 'case')) - local commit_status, commit_body, commit_decoded = run_http_json( - root_scope, - port, - '/api/update/commit', - { job_id = 'job-mcu-http-uart' }, - nil - ) - assert_eq(commit_status, '200', commit_body) - assert_not_nil(commit_decoded and commit_decoded.value, commit_body) - assert_eq(commit_decoded.value.ok, true) - wait_job(cm5.conn, 'job-mcu-http-uart', 'awaiting_return', 8.0) - assert_true(probe.wait_until(function () return fake.rebooting_seen == true end, { timeout = 4.0 }), 'Go MCU child should emit rebooting event') - assert_true(probe.wait_until(function () return fake.reboot_exit_seen == true end, { timeout = 4.0 }), 'Go MCU child should exit with simulated reboot code') - - stop_fabric_pair(pair, 'go devhost fabric link reboot') - stop_instance(cm5, 'go devhost cm5 reboot') - stop_instance(mcu, 'go devhost mcu reboot') - - local uart_b = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } - local cm5b = start_cm5_instance(root_scope, roots, nil, uart_b.cm5) - local mcub = start_go_mcu_instance(root_scope, fake, uart_b.mcu, state_dir, { provider = provider }) - pair_b = start_fabric_pair(root_scope, cm5b.bus, fake, uart_b, { uart = opts.reboot_uart }) - log_fabric_status(cm5b.conn, 'go-pty CM5 fabric reboot-established status') - cm5b.start_services_after_fabric() - - wait_component_software(cm5b.conn, opts.image_id or 'mcu-image-go-new', nil) - local final_job = wait_job(cm5b.conn, 'job-mcu-http-uart', 'succeeded', 10.0) - assert_eq(final_job.component, 'mcu') - assert_eq(final_job.job_id, 'job-mcu-http-uart') - assert_eq(final_job.expected_image_id, opts.image_id or 'mcu-image-go-new') - - result = { - ok = true, - final_job = final_job, - first_uart_stats = pair and pair.uart_stats or nil, - second_uart_stats = pair_b and pair_b.uart_stats or nil, - } - - stop_fabric_pair(pair_b, 'go devhost test complete') - stop_instance(cm5b, 'go devhost test complete') - stop_instance(mcub, 'go devhost test complete') - rm_rf(roots.base) - end, { timeout = opts.timeout_s or REALISTIC_TEST_TIMEOUT_S }) - return result + opts = opts or {} + if not go_devhost_configured() then + log('skipping Go devhost PTY rig: set MCU_DEVHOST_PTY_BIN, DEVICECODE_GO_ROOT, or leave DEVICECODE_GO_DOWNLOAD enabled') + return { skipped = true, reason = 'go_devhost_not_configured' } + end + local result + runfibers.run(function (root_scope) + local provider, skip_reason = resolve_go_devhost_provider() + if provider == nil then + log('skipping Go devhost PTY rig: ' .. tostring(skip_reason)) + result = { skipped = true, reason = skip_reason } + return + end + local roots = temp_roots() + local state_dir = roots.base .. '/go-mcu-state' + local blob = make_realistic_mcu_blob(opts.image_id or 'mcu-image-go-new', opts.blob_bytes) + local port = 30000 + math.random(0, 20000) + local fake = { old_image_id = opts.old_image_id or 'mcu-image-go-old', old_version = opts.old_version or '10.0' } + local uart = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } + local pair, pair_b + + log(('go-pty[%s]: booting initial CM5 instance'):format(opts.label or 'case')) + local cm5 = start_cm5_instance(root_scope, roots, port, uart.cm5) + log(('go-pty[%s]: booting Go MCU devhost instance'):format(opts.label or 'case')) + local mcu = start_go_mcu_instance(root_scope, fake, uart.mcu, state_dir, { provider = provider }) + log(('go-pty[%s]: starting Fabric link over PTY UART middleware'):format(opts.label or 'case')) + pair = start_fabric_pair(root_scope, cm5.bus, fake, uart, { uart = opts.uart }) + log(('go-pty[%s]: starting CM5 Device/Update/UI services'):format(opts.label or 'case')) + cm5.start_services_after_fabric() + + wait_component_software(cm5.conn, fake.old_image_id, nil) + + log(('go-pty[%s]: sending HTTP upload through CM5 UI (%d byte artifact)'):format(opts.label or 'case', #blob)) + local status, body = run_http_upload(root_scope, port, blob, nil) + assert_eq(status, '200', 'upload HTTP status ' .. tostring(status) .. ': ' .. tostring(body)) + local decoded = assert(cjson.decode(body), body) + assert_eq(decoded.status, 'ok') + assert_eq(decoded.job_id, 'job-mcu-http-uart') + assert_eq(decoded.job, nil) + + log(('go-pty[%s]: waiting for Go MCU-backed job awaiting_commit'):format(opts.label or 'case')) + wait_job_chatty(cm5.conn, 'job-mcu-http-uart', 'awaiting_commit', opts.stage_timeout_s or REALISTIC_TRANSFER_TIMEOUT_S, function () + return (('uart_bytes=%d uart_fragments=%d uart_pauses=%d uart_bad_json=%d uart_drop_bytes=%d uart_drop_nl=%d fault_pauses=%d; %s'):format( + pair.uart_stats and pair.uart_stats.bytes or 0, + pair.uart_stats and pair.uart_stats.fragments or 0, + pair.uart_stats and pair.uart_stats.pauses or 0, + pair.uart_stats and pair.uart_stats.malformed_lines or 0, + pair.uart_stats and pair.uart_stats.dropped_bytes or 0, + pair.uart_stats and pair.uart_stats.dropped_newlines or 0, + pair.uart_stats and pair.uart_stats.fault_pauses or 0, + fabric_progress_fragment(pair.cm5_conn) + )) + end) + + log(('go-pty[%s]: committing job through public HTTP update commit route'):format(opts.label or 'case')) + local commit_status, commit_body, commit_decoded = run_http_json( + root_scope, + port, + '/api/update/commit', + { job_id = 'job-mcu-http-uart' }, + nil + ) + assert_eq(commit_status, '200', commit_body) + assert_not_nil(commit_decoded and commit_decoded.value, commit_body) + assert_eq(commit_decoded.value.ok, true) + wait_job(cm5.conn, 'job-mcu-http-uart', 'awaiting_return', 8.0) + assert_true(probe.wait_until(function () return fake.rebooting_seen == true end, { timeout = 4.0 }), 'Go MCU child should emit rebooting event') + assert_true(probe.wait_until(function () return fake.reboot_exit_seen == true end, { timeout = 4.0 }), 'Go MCU child should exit with simulated reboot code') + + stop_fabric_pair(pair, 'go devhost fabric link reboot') + stop_instance(cm5, 'go devhost cm5 reboot') + stop_instance(mcu, 'go devhost mcu reboot') + + local uart_b = { cm5 = pty.open(root_scope), mcu = pty.open(root_scope) } + local cm5b = start_cm5_instance(root_scope, roots, nil, uart_b.cm5) + local mcub = start_go_mcu_instance(root_scope, fake, uart_b.mcu, state_dir, { provider = provider }) + pair_b = start_fabric_pair(root_scope, cm5b.bus, fake, uart_b, { uart = opts.reboot_uart }) + log_fabric_status(cm5b.conn, 'go-pty CM5 fabric reboot-established status') + cm5b.start_services_after_fabric() + + wait_component_software(cm5b.conn, opts.image_id or 'mcu-image-go-new', nil) + local final_job = wait_job(cm5b.conn, 'job-mcu-http-uart', 'succeeded', 10.0) + assert_eq(final_job.component, 'mcu') + assert_eq(final_job.job_id, 'job-mcu-http-uart') + assert_eq(final_job.expected_image_id, opts.image_id or 'mcu-image-go-new') + + result = { + ok = true, + final_job = final_job, + first_uart_stats = pair and pair.uart_stats or nil, + second_uart_stats = pair_b and pair_b.uart_stats or nil, + } + + stop_fabric_pair(pair_b, 'go devhost test complete') + stop_instance(cm5b, 'go devhost test complete') + stop_instance(mcub, 'go devhost test complete') + rm_rf(roots.base) + end, { timeout = opts.timeout_s or REALISTIC_TEST_TIMEOUT_S }) + return result end function T.ui_http_mcu_update_go_devhost_pty_reboots_and_reconciles() - run_go_devhost_pty_cycle({ label = 'baseline' }) + run_go_devhost_pty_cycle({ label = 'baseline' }) end T.__helpers = { - log = log, - run_go_devhost_pty_cycle = run_go_devhost_pty_cycle, - go_devhost_configured = go_devhost_configured, + log = log, + run_go_devhost_pty_cycle = run_go_devhost_pty_cycle, + go_devhost_configured = go_devhost_configured, } return T diff --git a/tests/integration/devhost/support/mcu_http_uart_child.lua b/tests/integration/devhost/support/mcu_http_uart_child.lua index 5f650ad7..4eca359f 100644 --- a/tests/integration/devhost/support/mcu_http_uart_child.lua +++ b/tests/integration/devhost/support/mcu_http_uart_child.lua @@ -5,7 +5,7 @@ -- while still using the real Fabric and HAL UART open path on the MCU side. local function add_path(prefix) - package.path = prefix .. '?.lua;' .. prefix .. '?/init.lua;' .. package.path + package.path = prefix .. '?.lua;' .. prefix .. '?/init.lua;' .. package.path end package.path = '../src/?.lua;' .. package.path @@ -17,7 +17,7 @@ add_path('./') local stdlib_ok, stdlib = pcall(require, 'posix.stdlib') if stdlib_ok and stdlib and stdlib.setenv then - stdlib.setenv('CONFIG_TARGET', 'services', true) + stdlib.setenv('CONFIG_TARGET', 'services', true) end local busmod = require 'bus' @@ -44,22 +44,22 @@ local MCU_PROGRESS_LOG_BYTES = 16 * 1024 local MCU_FLASH_WRITE_DELAY_S = tonumber(os.getenv('MCU_HTTP_UART_FLASH_DELAY_S')) or 0.010 local function parse_args(argv) - local out = {} - local i = 1 - while i <= #argv do - local k = argv[i] - if k == '--ipc' then i = i + 1; out.ipc = argv[i] - elseif k == '--uart' then i = i + 1; out.uart = argv[i] - elseif k == '--old-image' then i = i + 1; out.old_image_id = argv[i] - elseif k == '--committed-image' then i = i + 1; out.committed_image_id = argv[i] ~= '' and argv[i] or nil - elseif k == '--boot-seq' then i = i + 1; out.boot_seq = tonumber(argv[i]) or 1 - else error('unknown argument: ' .. tostring(k), 0) end - i = i + 1 - end - assert(out.ipc and out.ipc ~= '', '--ipc required') - assert(out.uart and out.uart ~= '', '--uart required') - out.old_image_id = out.old_image_id or 'mcu-image-old' - return out + local out = {} + local i = 1 + while i <= #argv do + local k = argv[i] + if k == '--ipc' then i = i + 1; out.ipc = argv[i] + elseif k == '--uart' then i = i + 1; out.uart = argv[i] + elseif k == '--old-image' then i = i + 1; out.old_image_id = argv[i] + elseif k == '--committed-image' then i = i + 1; out.committed_image_id = argv[i] ~= '' and argv[i] or nil + elseif k == '--boot-seq' then i = i + 1; out.boot_seq = tonumber(argv[i]) or 1 + else error('unknown argument: ' .. tostring(k), 0) end + i = i + 1 + end + assert(out.ipc and out.ipc ~= '', '--ipc required') + assert(out.uart and out.uart ~= '', '--uart required') + out.old_image_id = out.old_image_id or 'mcu-image-old' + return out end local function assert_true(v, msg) if v ~= true then error(msg or ('expected true, got ' .. tostring(v)), 0) end end @@ -67,466 +67,466 @@ local function assert_not_nil(v, msg) if v == nil then error(msg or 'expected no local function assert_eq(a, b, msg) if a ~= b then error(msg or ('expected ' .. tostring(b) .. ', got ' .. tostring(a)), 0) end end local function dummy_logger() - local logger = {} - for _, k in ipairs({ 'debug', 'info', 'warn', 'error' }) do logger[k] = function () end end - function logger:child() return self end - return logger + local logger = {} + for _, k in ipairs({ 'debug', 'info', 'warn', 'error' }) do logger[k] = function () end end + function logger:child() return self end + return logger end local function wait_channel_get(ch, timeout_s, what) - local which, a, b = fibers.perform(op.named_choice({ - item = ch:get_op(), - timeout = sleep.sleep_op(timeout_s or 1.0), - })) - if which == 'timeout' then error(('timed out waiting for %s'):format(what or 'channel item'), 0) end - if a == nil then error(('channel closed while waiting for %s: %s'):format(what or 'channel item', tostring(b)), 0) end - return a + local which, a, b = fibers.perform(op.named_choice({ + item = ch:get_op(), + timeout = sleep.sleep_op(timeout_s or 1.0), + })) + if which == 'timeout' then error(('timed out waiting for %s'):format(what or 'channel item'), 0) end + if a == nil then error(('channel closed while waiting for %s: %s'):format(what or 'channel item', tostring(b)), 0) end + return a end local function wait_device_event(dev_ev_ch, event_type, class, id, timeout_s) - local deadline = fibers.now() + (timeout_s or 1.5) - while fibers.now() < deadline do - local ev = wait_channel_get(dev_ev_ch, deadline - fibers.now(), 'UART device event') - if ev.event_type == event_type and ev.class == class and ev.id == id then return ev end - end - error(('timed out waiting for UART device event %s %s/%s'):format(tostring(event_type), tostring(class), tostring(id)), 0) + local deadline = fibers.now() + (timeout_s or 1.5) + while fibers.now() < deadline do + local ev = wait_channel_get(dev_ev_ch, deadline - fibers.now(), 'UART device event') + if ev.event_type == event_type and ev.class == class and ev.id == id then return ev end + end + error(('timed out waiting for UART device event %s %s/%s'):format(tostring(event_type), tostring(class), tostring(id)), 0) end local function wait_uart_cap(dev_ev_ch) - local added = wait_device_event(dev_ev_ch, 'added', 'uart', 'uart0', 1.5) - assert_true(type(added.capabilities) == 'table' and #added.capabilities == 1, 'UART added event missing capability') - local cap = added.capabilities[1] - assert_eq(cap.class, 'uart') - assert_eq(cap.id, 'uart0') - assert_true(type(cap.control_ch) == 'table', 'UART capability should expose control_ch') - return cap + local added = wait_device_event(dev_ev_ch, 'added', 'uart', 'uart0', 1.5) + assert_true(type(added.capabilities) == 'table' and #added.capabilities == 1, 'UART added event missing capability') + local cap = added.capabilities[1] + assert_eq(cap.class, 'uart') + assert_eq(cap.id, 'uart0') + assert_true(type(cap.control_ch) == 'table', 'UART capability should expose control_ch') + return cap end local function normalise_uart_open_opts(opts) - if opts == nil or getmetatable(opts) ~= cap_args.UARTOpenOpts then - local open_opts, err = cap_args.new.UARTOpenOpts(opts) - assert_not_nil(open_opts, tostring(err)) - return open_opts - end - return opts + if opts == nil or getmetatable(opts) ~= cap_args.UARTOpenOpts then + local open_opts, err = cap_args.new.UARTOpenOpts(opts) + assert_not_nil(open_opts, tostring(err)) + return open_opts + end + return opts end local function call_hal_control(cap, verb, opts) - local reply_ch = channel.new(1) - local req, err = hal_types.new.ControlRequest(verb, opts or {}, reply_ch) - assert_not_nil(req, tostring(err)) - fibers.perform(cap.control_ch:put_op(req)) - local reply = wait_channel_get(reply_ch, 1.0, 'HAL UART control reply') - assert_true(type(reply) == 'table', 'HAL control reply must be a table') - return reply + local reply_ch = channel.new(1) + local req, err = hal_types.new.ControlRequest(verb, opts or {}, reply_ch) + assert_not_nil(req, tostring(err)) + fibers.perform(cap.control_ch:put_op(req)) + local reply = wait_channel_get(reply_ch, 1.0, 'HAL UART control reply') + assert_true(type(reply) == 'table', 'HAL control reply must be a table') + return reply end local function expose_raw_host_uart_open(scope, bus, cap, source) - source = source or 'uart_manager' - local conn = bus:connect({ origin_base = { service = 'mcu-child-hal-uart-adapter' } }) - local cap_id = cap.id - local ep = conn:bind({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'rpc', 'open' }, { queue_len = 8 }) - - conn:retain({ 'raw', 'host', source, 'status' }, { state = 'available', available = true, source = source, class = 'uart', id = cap_id }) - conn:retain({ 'raw', 'host', source, 'meta' }, { source = source, class = 'uart', id = cap_id }) - conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { state = 'available', available = true, source_kind = 'host', source = source }) - conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }, { source_kind = 'host', source = source, offerings = { open = true } }) - - scope:finally(function () - safe.pcall(function () ep:unbind() end) - bus_cleanup.disconnect(conn) - end) - - assert_true(scope:spawn(function () - while true do - local req = ep:recv() - if req == nil then return end - local open_opts = normalise_uart_open_opts(req.payload) - local reply = call_hal_control(cap, 'open', open_opts) - local replied = req:reply(reply) - if not replied and reply.ok == true and type(reply.reason) == 'table' - and type(reply.reason.session) == 'table' - and type(reply.reason.session.terminate) == 'function' - then - reply.reason.session:terminate('fabric request abandoned') - end - end - end)) - - return { source = source, class = 'uart', id = cap_id } + source = source or 'uart_manager' + local conn = bus:connect({ origin_base = { service = 'mcu-child-hal-uart-adapter' } }) + local cap_id = cap.id + local ep = conn:bind({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'rpc', 'open' }, { queue_len = 8 }) + + conn:retain({ 'raw', 'host', source, 'status' }, { state = 'available', available = true, source = source, class = 'uart', id = cap_id }) + conn:retain({ 'raw', 'host', source, 'meta' }, { source = source, class = 'uart', id = cap_id }) + conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'status' }, { state = 'available', available = true, source_kind = 'host', source = source }) + conn:retain({ 'raw', 'host', source, 'cap', 'uart', cap_id, 'meta' }, { source_kind = 'host', source = source, offerings = { open = true } }) + + scope:finally(function () + safe.pcall(function () ep:unbind() end) + bus_cleanup.disconnect(conn) + end) + + assert_true(scope:spawn(function () + while true do + local req = ep:recv() + if req == nil then return end + local open_opts = normalise_uart_open_opts(req.payload) + local reply = call_hal_control(cap, 'open', open_opts) + local replied = req:reply(reply) + if not replied and reply.ok == true and type(reply.reason) == 'table' + and type(reply.reason.session) == 'table' + and type(reply.reason.session.terminate) == 'function' + then + reply.reason.session:terminate('fabric request abandoned') + end + end + end)) + + return { source = source, class = 'uart', id = cap_id } end local function fresh_uart_manager() - package.loaded['services.hal.managers.uart'] = nil - package.loaded['services.hal.drivers.uart'] = nil - return require 'services.hal.managers.uart' + package.loaded['services.hal.managers.uart'] = nil + package.loaded['services.hal.drivers.uart'] = nil + return require 'services.hal.managers.uart' end local function start_uart_manager(scope, bus, uart_slave) - local uart_mgr = fresh_uart_manager() - local dev_ev_ch = channel.new(16) - local cap_emit_ch = channel.new(32) - local ok_start, start_err = fibers.perform(uart_mgr.start_op(dummy_logger(), dev_ev_ch, cap_emit_ch)) - assert_true(ok_start, tostring(start_err)) - scope:finally(function () safe.pcall(function () fibers.perform(uart_mgr.shutdown_op()) end) end) - - local ok_cfg, cfg_err = fibers.perform(uart_mgr.apply_config_op({ - serial_ports = { - { id = 'uart0', path = uart_slave, baud = 115200, mode = '8N1' }, - }, - })) - assert_true(ok_cfg, tostring(cfg_err)) - local cap = wait_uart_cap(dev_ev_ch) - return expose_raw_host_uart_open(scope, bus, cap, 'uart_manager') + local uart_mgr = fresh_uart_manager() + local dev_ev_ch = channel.new(16) + local cap_emit_ch = channel.new(32) + local ok_start, start_err = fibers.perform(uart_mgr.start_op(dummy_logger(), dev_ev_ch, cap_emit_ch)) + assert_true(ok_start, tostring(start_err)) + scope:finally(function () safe.pcall(function () fibers.perform(uart_mgr.shutdown_op()) end) end) + + local ok_cfg, cfg_err = fibers.perform(uart_mgr.apply_config_op({ + serial_ports = { + { id = 'uart0', path = uart_slave, baud = 115200, mode = '8N1' }, + }, + })) + assert_true(ok_cfg, tostring(cfg_err)) + local cap = wait_uart_cap(dev_ev_ch) + return expose_raw_host_uart_open(scope, bus, cap, 'uart_manager') end local function fabric_payload_snapshot(payload) - if type(payload) ~= 'table' then return nil end - return type(payload.snapshot) == 'table' and payload.snapshot or payload + if type(payload) ~= 'table' then return nil end + return type(payload.snapshot) == 'table' and payload.snapshot or payload end local function compact_fabric_session(s) - s = type(s) == 'table' and s or {} - return ('phase=%s established=%s local=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s last_wire_error=%s why=%s'):format( - tostring(s.phase), tostring(s.established), tostring(s.local_node), tostring(s.peer_node), - tostring(s.peer_sid), tostring(s.session_generation), tostring(s.wire_errors or 0), - tostring(s.bad_frame_count or 0), tostring(s.last_wire_error), tostring(s.why) - ) + s = type(s) == 'table' and s or {} + return ('phase=%s established=%s local=%s peer_node=%s peer_sid=%s gen=%s wire_errors=%s bad_frames=%s last_wire_error=%s why=%s'):format( + tostring(s.phase), tostring(s.established), tostring(s.local_node), tostring(s.peer_node), + tostring(s.peer_sid), tostring(s.session_generation), tostring(s.wire_errors or 0), + tostring(s.bad_frame_count or 0), tostring(s.last_wire_error), tostring(s.why) + ) end local function compact_fabric_bridge(s) - s = type(s) == 'table' and s or {} - return ('state=%s imported=%s pending=%s inbound=%s frames_sent=%s frames_recv=%s session_peer=%s drop=%s err=%s'):format( - tostring(s.state), tostring(s.imported_topics), tostring(s.pending_calls), tostring(s.inbound_calls), - tostring(s.frames_sent), tostring(s.frames_received), - tostring(type(s.session) == 'table' and s.session.peer_sid or nil), - tostring(s.session_drop_reason), tostring(s.last_err) - ) + s = type(s) == 'table' and s or {} + return ('state=%s imported=%s pending=%s inbound=%s frames_sent=%s frames_recv=%s session_peer=%s drop=%s err=%s'):format( + tostring(s.state), tostring(s.imported_topics), tostring(s.pending_calls), tostring(s.inbound_calls), + tostring(s.frames_sent), tostring(s.frames_received), + tostring(type(s.session) == 'table' and s.session.peer_sid or nil), + tostring(s.session_drop_reason), tostring(s.last_err) + ) end local function compact_fabric_transfer(s) - s = type(s) == 'table' and s or {} - local stats = type(s.stats) == 'table' and s.stats or {} - local active = type(s.active) == 'table' and s.active or nil - local last = type(s.last) == 'table' and s.last or nil - return ('active=%s active_status=%s last_status=%s completed=%s failed=%s cancelled=%s stale=%s'):format( - tostring(active ~= nil), tostring(active and active.status), tostring(last and last.status), - tostring(stats.completed), tostring(stats.failed), tostring(stats.cancelled), tostring(stats.stale) - ) + s = type(s) == 'table' and s or {} + local stats = type(s.stats) == 'table' and s.stats or {} + local active = type(s.active) == 'table' and s.active or nil + local last = type(s.last) == 'table' and s.last or nil + return ('active=%s active_status=%s last_status=%s completed=%s failed=%s cancelled=%s stale=%s'):format( + tostring(active ~= nil), tostring(active and active.status), tostring(last and last.status), + tostring(stats.completed), tostring(stats.failed), tostring(stats.cancelled), tostring(stats.stale) + ) end local function compact_fabric_link(s) - s = type(s) == 'table' and s or {} - local comps = {} - if type(s.components) == 'table' then - for name, rec in pairs(s.components) do - comps[#comps + 1] = tostring(name) .. '=' .. tostring(type(rec) == 'table' and rec.status or rec) - end - table.sort(comps) - end - return ('state=%s completed=%s/%s reason=%s components=[%s]'):format( - tostring(s.state), tostring(s.completed), tostring(s.total), tostring(s.reason), table.concat(comps, ',') - ) + s = type(s) == 'table' and s or {} + local comps = {} + if type(s.components) == 'table' then + for name, rec in pairs(s.components) do + comps[#comps + 1] = tostring(name) .. '=' .. tostring(type(rec) == 'table' and rec.status or rec) + end + table.sort(comps) + end + return ('state=%s completed=%s/%s reason=%s components=[%s]'):format( + tostring(s.state), tostring(s.completed), tostring(s.total), tostring(s.reason), table.concat(comps, ',') + ) end local function describe_fabric_status_payload(payload, component) - local s = fabric_payload_snapshot(payload) - if component == 'session' then return compact_fabric_session(s) end - if component == 'rpc_bridge' then return compact_fabric_bridge(s) end - if component == 'transfer_manager' or component == 'transfer' then return compact_fabric_transfer(s) end - return compact_fabric_link(s) + local s = fabric_payload_snapshot(payload) + if component == 'session' then return compact_fabric_session(s) end + if component == 'rpc_bridge' then return compact_fabric_bridge(s) end + if component == 'transfer_manager' or component == 'transfer' then return compact_fabric_transfer(s) end + return compact_fabric_link(s) end local function retained_payload_now(conn, topic) - local view = conn:retained_view(topic) - local msg = view:get(topic) - view:close() - return msg and msg.payload or nil + local view = conn:retained_view(topic) + local msg = view:get(topic) + view:close() + return msg and msg.payload or nil end local function wait_retained_payload_where(conn, topic, label, pred, timeout_s) - local view = conn:retained_view(topic) - local deadline = fibers.now() + (timeout_s or 6.0) - local seen = view:version() - while true do - local msg = view:get(topic) - local payload = msg and msg.payload or nil - local out = pred(payload) - if out then view:close(); return out end - local remaining = deadline - fibers.now() - if remaining <= 0 then view:close(); error('timed out waiting for ' .. tostring(label), 0) end - local which, version, reason = fibers.perform(op.named_choice({ - changed = view:changed_op(seen), - timeout = sleep.sleep_op(math.min(0.50, remaining)), - })) - if which == 'changed' then - if version == nil then view:close(); error(tostring(label) .. ' closed: ' .. tostring(reason or 'closed'), 0) end - seen = version - end - end + local view = conn:retained_view(topic) + local deadline = fibers.now() + (timeout_s or 6.0) + local seen = view:version() + while true do + local msg = view:get(topic) + local payload = msg and msg.payload or nil + local out = pred(payload) + if out then view:close(); return out end + local remaining = deadline - fibers.now() + if remaining <= 0 then view:close(); error('timed out waiting for ' .. tostring(label), 0) end + local which, version, reason = fibers.perform(op.named_choice({ + changed = view:changed_op(seen), + timeout = sleep.sleep_op(math.min(0.50, remaining)), + })) + if which == 'changed' then + if version == nil then view:close(); error(tostring(label) .. ' closed: ' .. tostring(reason or 'closed'), 0) end + seen = version + end + end end local function start_fabric_status_reporter(scope, conn, emit) - assert_true(scope:spawn(function () - local payload = wait_retained_payload_where( - conn, - fabric_topics.state_link_component('link-a', 'session'), - 'MCU fabric session established', - function (p) - local s = fabric_payload_snapshot(p) - if type(s) == 'table' and s.established == true and type(s.peer_sid) == 'string' and s.peer_sid ~= '' then - return p - end - return nil - end, - 6.0 - ) - local s = fabric_payload_snapshot(payload) or {} - emit({ - event = 'fabric_session', - phase = s.phase, - established = s.established, - local_node = s.local_node, - peer_node = s.peer_node, - peer_sid = s.peer_sid, - session_generation = s.session_generation, - wire_errors = s.wire_errors or 0, - bad_frame_count = s.bad_frame_count or 0, - last_wire_error = s.last_wire_error, - summary = describe_fabric_status_payload(payload, 'session'), - }) - local items = { - { label = 'link', topic = fabric_topics.state_link('link-a') }, - { label = 'session', component = 'session', topic = fabric_topics.state_link_component('link-a', 'session') }, - { label = 'rpc_bridge', component = 'rpc_bridge', topic = fabric_topics.state_link_component('link-a', 'rpc_bridge') }, - { label = 'transfer_manager', component = 'transfer_manager', topic = fabric_topics.state_link_component('link-a', 'transfer_manager') }, - } - for _, item in ipairs(items) do - local p = retained_payload_now(conn, item.topic) - emit({ event = 'fabric_status', component = item.label, summary = describe_fabric_status_payload(p, item.component) }) - end - end)) + assert_true(scope:spawn(function () + local payload = wait_retained_payload_where( + conn, + fabric_topics.state_link_component('link-a', 'session'), + 'MCU fabric session established', + function (p) + local s = fabric_payload_snapshot(p) + if type(s) == 'table' and s.established == true and type(s.peer_sid) == 'string' and s.peer_sid ~= '' then + return p + end + return nil + end, + 6.0 + ) + local s = fabric_payload_snapshot(payload) or {} + emit({ + event = 'fabric_session', + phase = s.phase, + established = s.established, + local_node = s.local_node, + peer_node = s.peer_node, + peer_sid = s.peer_sid, + session_generation = s.session_generation, + wire_errors = s.wire_errors or 0, + bad_frame_count = s.bad_frame_count or 0, + last_wire_error = s.last_wire_error, + summary = describe_fabric_status_payload(payload, 'session'), + }) + local items = { + { label = 'link', topic = fabric_topics.state_link('link-a') }, + { label = 'session', component = 'session', topic = fabric_topics.state_link_component('link-a', 'session') }, + { label = 'rpc_bridge', component = 'rpc_bridge', topic = fabric_topics.state_link_component('link-a', 'rpc_bridge') }, + { label = 'transfer_manager', component = 'transfer_manager', topic = fabric_topics.state_link_component('link-a', 'transfer_manager') }, + } + for _, item in ipairs(items) do + local p = retained_payload_now(conn, item.topic) + emit({ event = 'fabric_status', component = item.label, summary = describe_fabric_status_payload(p, item.component) }) + end + end)) end local function fabric_config(local_node, peer_node, bridge, transfer) - return { - schema = fabric.config.SCHEMA, - local_node = local_node, - links = { - { - id = 'link-a', - peer_id = peer_node, - transport = { source = 'uart_manager', class = 'uart', id = 'uart0', terminator = '\n' }, - session = { hello_interval_s = 0.20, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, - bridge = bridge or {}, - transfer = transfer or { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }, - }, - }, - } + return { + schema = fabric.config.SCHEMA, + local_node = local_node, + links = { + { + id = 'link-a', + peer_id = peer_node, + transport = { source = 'uart_manager', class = 'uart', id = 'uart0', terminator = '\n' }, + session = { hello_interval_s = 0.20, ping_interval_s = 5.0, liveness_timeout_s = 5.0 }, + bridge = bridge or {}, + transfer = transfer or { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }, + }, + }, + } end local function mcu_fabric_config() - return fabric_config('mcu', 'cm5', { - exports = { - { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, - { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, - }, - rpc = { - inbound = { - { id = 'mcu-prepare-in', ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, timeout_s = 2.0 }, - { id = 'mcu-commit-in', ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, timeout_s = 2.0 }, - }, - }, - }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) + return fabric_config('mcu', 'cm5', { + exports = { + { id = 'mcu-state-export', ['local'] = { 'state', 'self' }, remote = { 'state', 'self' }, publish = true, retain = true }, + { id = 'mcu-cap-export', ['local'] = { 'cap', 'self' }, remote = { 'cap', 'self' }, publish = true, retain = true }, + }, + rpc = { + inbound = { + { id = 'mcu-prepare-in', ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }, timeout_s = 2.0 }, + { id = 'mcu-commit-in', ['local'] = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, remote = { 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }, timeout_s = 2.0 }, + }, + }, + }, { chunk_size = 2048, timeout_s = REALISTIC_TRANSFER_TIMEOUT_S }) end local function start_public_fabric(scope, conn, cfg, opts) - opts = opts or {} - assert_true(scope:spawn(function () - fabric.start(conn, { - name = opts.name or 'fabric-mcu-child', - env = 'test', - config = cfg, - link_overrides = opts.link_overrides, - }) - end)) + opts = opts or {} + assert_true(scope:spawn(function () + fabric.start(conn, { + name = opts.name or 'fabric-mcu-child', + env = 'test', + config = cfg, + link_overrides = opts.link_overrides, + }) + end)) end local function publish_mcu_facts(conn, fake) - local image = fake.committed_image_id or fake.old_image_id - local boot = 'mcu-boot-' .. tostring(fake.boot_seq) - conn:retain({ 'state', 'self', 'software' }, { image_id = image, boot_id = boot, version = image }) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'ready', - last_error = nil, - staged_image_id = fake.staged and fake.staged.image_id or nil, - pending_image_id = fake.committed_image_id, - job_id = fake.job_id, - }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { class = 'updater', id = 'main', methods = { 'prepare-update', 'commit-update' } }) - conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { available = true, state = 'available' }) + local image = fake.committed_image_id or fake.old_image_id + local boot = 'mcu-boot-' .. tostring(fake.boot_seq) + conn:retain({ 'state', 'self', 'software' }, { image_id = image, boot_id = boot, version = image }) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'ready', + last_error = nil, + staged_image_id = fake.staged and fake.staged.image_id or nil, + pending_image_id = fake.committed_image_id, + job_id = fake.job_id, + }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'meta' }, { class = 'updater', id = 'main', methods = { 'prepare-update', 'commit-update' } }) + conn:retain({ 'cap', 'self', 'updater', 'main', 'status' }, { available = true, state = 'available' }) end local function new_mcu_receive_target(fake, emit) - local target = {} - function target:open_sink_op(req) - fake.transfer_begin = req - fake.receive_started_at = fibers.now() - fake.receive_bytes = 0 - fake.receive_chunks = 0 - fake.receive_next_log = MCU_PROGRESS_LOG_BYTES - assert_eq(req.target, 'updater/main') - emit({ event = 'receive_opened', target = req.target, size = req.size, job_id = req.meta and req.meta.job_id, image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id) }) - local chunks = {} - local sink = {} - function sink:append_op(chunk) - return fibers.run_scope_op(function () - fibers.perform(sleep.sleep_op(MCU_FLASH_WRITE_DELAY_S)) - chunks[#chunks + 1] = chunk - fake.receive_bytes = (fake.receive_bytes or 0) + #(chunk or '') - fake.receive_chunks = (fake.receive_chunks or 0) + 1 - if fake.receive_bytes >= (fake.receive_next_log or MCU_PROGRESS_LOG_BYTES) then - local elapsed = fibers.now() - (fake.receive_started_at or fibers.now()) - emit({ event = 'receive_progress', bytes = fake.receive_bytes, chunks = fake.receive_chunks or 0, elapsed_s = elapsed }) - fake.receive_next_log = fake.receive_bytes + MCU_PROGRESS_LOG_BYTES - else - emit({ event = 'receive_tick', bytes = fake.receive_bytes, chunks = fake.receive_chunks or 0 }) - end - return true, nil - end):wrap(function (status, _report, ok, err) - if status ~= 'ok' then return nil, err or status end - return ok, err - end) - end - function sink:commit_op(req2) - local bytes = table.concat(chunks) - local payload_digest = xxhash32.digest_hex(bytes) - fake.staged = { size = #bytes, digest = req2.digest, payload_digest = payload_digest, image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), job_id = req.meta and req.meta.job_id } - fake.staged_signal = (fake.staged_signal or 0) + 1 - emit({ event = 'transfer_commit', size = #bytes, chunks = fake.receive_chunks or 0, digest = req2.digest, payload_digest = payload_digest, image_id = fake.staged.image_id, job_id = fake.staged.job_id }) - return op.always({ staged = true, digest = req2.digest }, nil) - end - function sink:abort(reason) - fake.abort_reason = reason - fake.abort_count = (fake.abort_count or 0) + 1 - emit({ event = 'transfer_abort', reason = tostring(reason), bytes = fake.receive_bytes or 0, chunks = fake.receive_chunks or 0, abort_count = fake.abort_count }) - return true, nil - end - return op.always(sink, nil) - end - return target + local target = {} + function target:open_sink_op(req) + fake.transfer_begin = req + fake.receive_started_at = fibers.now() + fake.receive_bytes = 0 + fake.receive_chunks = 0 + fake.receive_next_log = MCU_PROGRESS_LOG_BYTES + assert_eq(req.target, 'updater/main') + emit({ event = 'receive_opened', target = req.target, size = req.size, job_id = req.meta and req.meta.job_id, image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id) }) + local chunks = {} + local sink = {} + function sink:append_op(chunk) + return fibers.run_scope_op(function () + fibers.perform(sleep.sleep_op(MCU_FLASH_WRITE_DELAY_S)) + chunks[#chunks + 1] = chunk + fake.receive_bytes = (fake.receive_bytes or 0) + #(chunk or '') + fake.receive_chunks = (fake.receive_chunks or 0) + 1 + if fake.receive_bytes >= (fake.receive_next_log or MCU_PROGRESS_LOG_BYTES) then + local elapsed = fibers.now() - (fake.receive_started_at or fibers.now()) + emit({ event = 'receive_progress', bytes = fake.receive_bytes, chunks = fake.receive_chunks or 0, elapsed_s = elapsed }) + fake.receive_next_log = fake.receive_bytes + MCU_PROGRESS_LOG_BYTES + else + emit({ event = 'receive_tick', bytes = fake.receive_bytes, chunks = fake.receive_chunks or 0 }) + end + return true, nil + end):wrap(function (status, _report, ok, err) + if status ~= 'ok' then return nil, err or status end + return ok, err + end) + end + function sink:commit_op(req2) + local bytes = table.concat(chunks) + local payload_digest = xxhash32.digest_hex(bytes) + fake.staged = { size = #bytes, digest = req2.digest, payload_digest = payload_digest, image_id = req.meta and (req.meta.image_id or req.meta.expected_image_id), job_id = req.meta and req.meta.job_id } + fake.staged_signal = (fake.staged_signal or 0) + 1 + emit({ event = 'transfer_commit', size = #bytes, chunks = fake.receive_chunks or 0, digest = req2.digest, payload_digest = payload_digest, image_id = fake.staged.image_id, job_id = fake.staged.job_id }) + return op.always({ staged = true, digest = req2.digest }, nil) + end + function sink:abort(reason) + fake.abort_reason = reason + fake.abort_count = (fake.abort_count or 0) + 1 + emit({ event = 'transfer_abort', reason = tostring(reason), bytes = fake.receive_bytes or 0, chunks = fake.receive_chunks or 0, abort_count = fake.abort_count }) + return true, nil + end + return op.always(sink, nil) + end + return target end local function start_fake_mcu(scope, bus, fake, emit) - local conn = bus:connect({ origin_base = { service = 'fake-mcu-child' } }) - publish_mcu_facts(conn, fake) - - local eps = {} - local function bind(topic) - local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) - assert_not_nil(ep, err) - eps[#eps + 1] = ep - return ep - end - - local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) - local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) - - scope:finally(function () - for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end - bus_cleanup.disconnect(conn) - end) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(prepare_ep:recv_op()) - if req == nil then return end - fake.prepare_payload = req.payload - assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') - fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil - conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) - emit({ event = 'prepare', payload = req.payload, job_id = fake.job_id }) - req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) - end - end)) - - assert_true(scope:spawn(function () - while true do - local req = fibers.perform(commit_ep:recv_op()) - if req == nil then return end - fake.commit_payload = req.payload - fake.commit_seen = true - fake.committed_image_id = (fake.staged and fake.staged.image_id) or (type(req.payload) == 'table' and req.payload.expected_image_id) - conn:retain({ 'state', 'self', 'updater' }, { - state = 'rebooting', - last_error = nil, - pending_image_id = fake.committed_image_id, - staged_image_id = fake.staged and fake.staged.image_id or nil, - job_id = fake.job_id, - }) - emit({ event = 'commit', payload = req.payload, committed_image_id = fake.committed_image_id }) - req:reply({ accepted = true, reboot_required = true }) - end - end)) - - return conn + local conn = bus:connect({ origin_base = { service = 'fake-mcu-child' } }) + publish_mcu_facts(conn, fake) + + local eps = {} + local function bind(topic) + local ep, err = bus_cleanup.bind(conn, topic, { queue_len = 8 }) + assert_not_nil(ep, err) + eps[#eps + 1] = ep + return ep + end + + local prepare_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'prepare-update' }) + local commit_ep = bind({ 'cap', 'self', 'updater', 'main', 'rpc', 'commit-update' }) + + scope:finally(function () + for _, ep in ipairs(eps) do bus_cleanup.unbind(conn, ep) end + bus_cleanup.disconnect(conn) + end) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(prepare_ep:recv_op()) + if req == nil then return end + fake.prepare_payload = req.payload + assert_eq(type(req.payload) == 'table' and req.payload.target or nil, 'mcu') + fake.job_id = type(req.payload) == 'table' and req.payload.job_id or nil + conn:retain({ 'state', 'self', 'updater' }, { state = 'ready', last_error = nil, job_id = fake.job_id }) + emit({ event = 'prepare', payload = req.payload, job_id = fake.job_id }) + req:reply({ ready = true, target = 'updater/main', max_chunk_size = 2048 }) + end + end)) + + assert_true(scope:spawn(function () + while true do + local req = fibers.perform(commit_ep:recv_op()) + if req == nil then return end + fake.commit_payload = req.payload + fake.commit_seen = true + fake.committed_image_id = (fake.staged and fake.staged.image_id) or (type(req.payload) == 'table' and req.payload.expected_image_id) + conn:retain({ 'state', 'self', 'updater' }, { + state = 'rebooting', + last_error = nil, + pending_image_id = fake.committed_image_id, + staged_image_id = fake.staged and fake.staged.image_id or nil, + job_id = fake.job_id, + }) + emit({ event = 'commit', payload = req.payload, committed_image_id = fake.committed_image_id }) + req:reply({ accepted = true, reboot_required = true }) + end + end)) + + return conn end local unix_ok, unistd = pcall(require, 'posix.unistd') local child_pid = (unix_ok and unistd and unistd.getpid and tostring(unistd.getpid())) or '' local function main(scope) - local args = parse_args(arg or {}) - local ipc, ierr = socket.connect_unix(args.ipc) - assert(ipc, 'IPC connect failed: ' .. tostring(ierr)) - - local events = channel.new(256) - local function emit(ev) - ev = ev or {} - ev.pid = child_pid - local ok, err = fibers.perform(events:put_op(ev)) - return ok, err - end - - scope:spawn(function () - while true do - local ev = fibers.perform(events:get_op()) - if ev == nil then return end - local line = assert(cjson.encode(ev)) .. '\n' - local _, werr = fibers.perform(ipc:write_op(line)) - if werr then return end - end - end) - - local bus = busmod.new() - local fake = { - old_image_id = args.old_image_id, - committed_image_id = args.committed_image_id, - boot_seq = args.boot_seq, - } - - start_uart_manager(scope, bus, args.uart) - start_fake_mcu(scope, bus, fake, emit) - - local conn = bus:connect({ origin_base = { service = 'fabric-mcu-child' } }) - scope:finally(function () bus_cleanup.disconnect(conn) end) - start_public_fabric(scope, conn, mcu_fabric_config(), { - name = 'fabric-mcu-child', - link_overrides = { - ['link-a'] = { - transfer = { - chunk_size = 2048, - timeout_s = REALISTIC_TRANSFER_TIMEOUT_S, - receive_targets = { ['updater/main'] = new_mcu_receive_target(fake, emit) }, - }, - }, - }, - }) - start_fabric_status_reporter(scope, conn, emit) - - emit({ event = 'ready', boot_seq = fake.boot_seq, image_id = fake.committed_image_id or fake.old_image_id, uart = args.uart }) - fibers.perform(op.never()) + local args = parse_args(arg or {}) + local ipc, ierr = socket.connect_unix(args.ipc) + assert(ipc, 'IPC connect failed: ' .. tostring(ierr)) + + local events = channel.new(256) + local function emit(ev) + ev = ev or {} + ev.pid = child_pid + local ok, err = fibers.perform(events:put_op(ev)) + return ok, err + end + + scope:spawn(function () + while true do + local ev = fibers.perform(events:get_op()) + if ev == nil then return end + local line = assert(cjson.encode(ev)) .. '\n' + local _, werr = fibers.perform(ipc:write_op(line)) + if werr then return end + end + end) + + local bus = busmod.new() + local fake = { + old_image_id = args.old_image_id, + committed_image_id = args.committed_image_id, + boot_seq = args.boot_seq, + } + + start_uart_manager(scope, bus, args.uart) + start_fake_mcu(scope, bus, fake, emit) + + local conn = bus:connect({ origin_base = { service = 'fabric-mcu-child' } }) + scope:finally(function () bus_cleanup.disconnect(conn) end) + start_public_fabric(scope, conn, mcu_fabric_config(), { + name = 'fabric-mcu-child', + link_overrides = { + ['link-a'] = { + transfer = { + chunk_size = 2048, + timeout_s = REALISTIC_TRANSFER_TIMEOUT_S, + receive_targets = { ['updater/main'] = new_mcu_receive_target(fake, emit) }, + }, + }, + }, + }) + start_fabric_status_reporter(scope, conn, emit) + + emit({ event = 'ready', boot_seq = fake.boot_seq, image_id = fake.committed_image_id or fake.old_image_id, uart = args.uart }) + fibers.perform(op.never()) end fibers.run(main) diff --git a/tests/integration/openwrt_vm/fixtures/devicecode_vm_mwan_intent.lua b/tests/integration/openwrt_vm/fixtures/devicecode_vm_mwan_intent.lua index c90e6b3d..44e8e289 100644 --- a/tests/integration/openwrt_vm/fixtures/devicecode_vm_mwan_intent.lua +++ b/tests/integration/openwrt_vm/fixtures/devicecode_vm_mwan_intent.lua @@ -5,80 +5,80 @@ local M = {} M.wans = { - { id = 'wan', device = 'eth1', gateway = '172.31.1.2', route_metric = 11, weight = 50 }, - { id = 'wanb', device = 'eth2', gateway = '172.31.2.2', route_metric = 12, weight = 30 }, - { id = 'wanc', device = 'eth3', gateway = '172.31.3.2', route_metric = 13, weight = 20 }, + { id = 'wan', device = 'eth1', gateway = '172.31.1.2', route_metric = 11, weight = 50 }, + { id = 'wanb', device = 'eth2', gateway = '172.31.2.2', route_metric = 12, weight = 30 }, + { id = 'wanc', device = 'eth3', gateway = '172.31.3.2', route_metric = 13, weight = 20 }, } function M.intent() - return { - schema = 'devicecode.net.intent/1', - rev = 24010, - generation = 24010, - segments = { - lan = { - kind = 'lan', - addressing = { ipv4 = { mode = 'static', cidr = '192.168.1.1/24' } }, - dhcp = { enabled = true, start = 100, limit = 150, leasetime = '12h' }, - dns = { local_server = true, domain = 'vm.bigbox.test' }, - firewall = { zone = 'lan' }, - }, - }, - interfaces = { - lan = { - kind = 'bridge', role = 'lan', segment = 'lan', members = { 'eth0' }, - addressing = { ipv4 = { mode = 'static', cidr = '192.168.1.1/24' } }, - firewall = { zone = 'lan' }, - }, - wan = { - kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth1' }, - addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, - dhcp = { enabled = false }, firewall = { zone = 'wan' }, - }, - wanb = { - kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth2' }, - addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, - dhcp = { enabled = false }, firewall = { zone = 'wan' }, - }, - wanc = { - kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth3' }, - addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, - dhcp = { enabled = false }, firewall = { zone = 'wan' }, - }, - }, - dns = { - enabled = true, - domain = 'vm.bigbox.test', - upstreams = { '1.1.1.1', '8.8.8.8' }, - cache = { size = 1000 }, - records = { router = { name = 'config.vm.bigbox.test', address = '192.168.1.1' } }, - }, - dhcp = { defaults = { authoritative = true } }, - firewall = { - defaults = { input = 'REJECT', output = 'ACCEPT', forward = 'REJECT' }, - zones = { - lan = { input = 'ACCEPT', output = 'ACCEPT', forward = 'REJECT' }, - wan = { input = 'REJECT', output = 'ACCEPT', forward = 'REJECT', masq = true, mtu_fix = true }, - }, - policies = { lan_to_wan = { src = 'lan', dest = 'wan' } }, - }, - routing = { routes = {} }, - wan = { - enabled = true, - load_balancing = { policy = 'balanced', speedtests = true }, - last_resort = 'unreachable', - health = { family = 'ipv4', reliability = 1, count = 1, timeout = 1, interval = 2, up = 1, down = 1, initial_state = 'online' }, - rules = { - https = { family = 'ipv4', proto = 'tcp', dest_port = '443', policy = 'balanced', sticky = true }, - }, - members = { - wan = { interface = 'wan', mwan_metric = 1, weight = 50, track_ip = '172.31.1.2' }, - wanb = { interface = 'wanb', mwan_metric = 1, weight = 30, track_ip = '172.31.2.2' }, - wanc = { interface = 'wanc', mwan_metric = 1, weight = 20, track_ip = '172.31.3.2' }, - }, - }, - vpn = {}, diagnostics = {}, - } + return { + schema = 'devicecode.net.intent/1', + rev = 24010, + generation = 24010, + segments = { + lan = { + kind = 'lan', + addressing = { ipv4 = { mode = 'static', cidr = '192.168.1.1/24' } }, + dhcp = { enabled = true, start = 100, limit = 150, leasetime = '12h' }, + dns = { local_server = true, domain = 'vm.bigbox.test' }, + firewall = { zone = 'lan' }, + }, + }, + interfaces = { + lan = { + kind = 'bridge', role = 'lan', segment = 'lan', members = { 'eth0' }, + addressing = { ipv4 = { mode = 'static', cidr = '192.168.1.1/24' } }, + firewall = { zone = 'lan' }, + }, + wan = { + kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth1' }, + addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, + dhcp = { enabled = false }, firewall = { zone = 'wan' }, + }, + wanb = { + kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth2' }, + addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, + dhcp = { enabled = false }, firewall = { zone = 'wan' }, + }, + wanc = { + kind = 'ethernet', role = 'wan', endpoint = { ifname = 'eth3' }, + addressing = { ipv4 = { mode = 'dhcp', peerdns = false } }, + dhcp = { enabled = false }, firewall = { zone = 'wan' }, + }, + }, + dns = { + enabled = true, + domain = 'vm.bigbox.test', + upstreams = { '1.1.1.1', '8.8.8.8' }, + cache = { size = 1000 }, + records = { router = { name = 'config.vm.bigbox.test', address = '192.168.1.1' } }, + }, + dhcp = { defaults = { authoritative = true } }, + firewall = { + defaults = { input = 'REJECT', output = 'ACCEPT', forward = 'REJECT' }, + zones = { + lan = { input = 'ACCEPT', output = 'ACCEPT', forward = 'REJECT' }, + wan = { input = 'REJECT', output = 'ACCEPT', forward = 'REJECT', masq = true, mtu_fix = true }, + }, + policies = { lan_to_wan = { src = 'lan', dest = 'wan' } }, + }, + routing = { routes = {} }, + wan = { + enabled = true, + load_balancing = { policy = 'balanced', speedtests = true }, + last_resort = 'unreachable', + health = { family = 'ipv4', reliability = 1, count = 1, timeout = 1, interval = 2, up = 1, down = 1, initial_state = 'online' }, + rules = { + https = { family = 'ipv4', proto = 'tcp', dest_port = '443', policy = 'balanced', sticky = true }, + }, + members = { + wan = { interface = 'wan', mwan_metric = 1, weight = 50, track_ip = '172.31.1.2' }, + wanb = { interface = 'wanb', mwan_metric = 1, weight = 30, track_ip = '172.31.2.2' }, + wanc = { interface = 'wanc', mwan_metric = 1, weight = 20, track_ip = '172.31.3.2' }, + }, + }, + vpn = {}, diagnostics = {}, + } end return M diff --git a/tests/run.lua b/tests/run.lua index 16b78d90..40835587 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -101,6 +101,7 @@ local files = { 'unit.shared.topic_spec', 'unit.shared.validate_spec', 'unit.shared.hash_xxhash32_spec', + 'unit.tools.lua_indentation_spec', 'integration.devhost.main_failure_spec', 'integration.devhost.config_recovery_spec', 'integration.devhost.monitor_logging_spec', diff --git a/tests/support/device_diag.lua b/tests/support/device_diag.lua index 165e9f22..fc0d0e13 100644 --- a/tests/support/device_diag.lua +++ b/tests/support/device_diag.lua @@ -3,32 +3,32 @@ local cjson = require 'cjson.safe' local M = {} local function enc(v) - local ok, s = pcall(cjson.encode, v) - if ok and s then return s end - return tostring(v) + local ok, s = pcall(cjson.encode, v) + if ok and s then return s end + return tostring(v) end local function add(out, label, fn) - if not fn then return end - local ok, v = pcall(fn) - if ok then - out[#out + 1] = label .. '=' .. enc(v) - else - out[#out + 1] = label .. '=' - end + if not fn then return end + local ok, v = pcall(fn) + if ok then + out[#out + 1] = label .. '=' .. enc(v) + else + out[#out + 1] = label .. '=' + end end function M.render(opts) - opts = opts or {} - local out = { '-- device diag --' } - add(out, 'service', opts.service_fn) - add(out, 'summary', opts.summary_fn) - add(out, 'components', opts.components_fn) - add(out, 'sources', opts.sources_fn) - add(out, 'component_cm5', opts.cm5_fn) - add(out, 'component_mcu', opts.mcu_fn) - add(out, 'extra', opts.extra_fn) - return table.concat(out, '\n') + opts = opts or {} + local out = { '-- device diag --' } + add(out, 'service', opts.service_fn) + add(out, 'summary', opts.summary_fn) + add(out, 'components', opts.components_fn) + add(out, 'sources', opts.sources_fn) + add(out, 'component_cm5', opts.cm5_fn) + add(out, 'component_mcu', opts.mcu_fn) + add(out, 'extra', opts.extra_fn) + return table.concat(out, '\n') end return M diff --git a/tests/support/diag_plugins/config.lua b/tests/support/diag_plugins/config.lua index 46ef888d..43479c21 100644 --- a/tests/support/diag_plugins/config.lua +++ b/tests/support/diag_plugins/config.lua @@ -1,10 +1,10 @@ return { - name = 'config', - topic_groups = { - { label = 'cfg', topic = { 'cfg', '#' } }, - { label = 'ccmd', topic = { 'cmd', 'config', '#' } }, - { label = 'csvc', topic = { 'svc', 'config', '#' } }, + name = 'config', + topic_groups = { + { label = 'cfg', topic = { 'cfg', '#' } }, + { label = 'ccmd', topic = { 'cmd', 'config', '#' } }, + { label = 'csvc', topic = { 'svc', 'config', '#' } }, - }, + }, } diff --git a/tests/support/diag_plugins/device.lua b/tests/support/diag_plugins/device.lua index d5fd6fec..1a2fced8 100644 --- a/tests/support/diag_plugins/device.lua +++ b/tests/support/diag_plugins/device.lua @@ -1,25 +1,25 @@ local device_diag = require 'tests.support.device_diag' return { - name = 'device', - topic_groups = { - { label = 'device', topic = { 'state', 'device', '#' } }, - { label = 'dcmd', topic = { 'cmd', 'device', '#' } }, - }, - section = function(helper, opts) - opts = opts or {} - local conn = opts.conn + name = 'device', + topic_groups = { + { label = 'device', topic = { 'state', 'device', '#' } }, + { label = 'dcmd', topic = { 'cmd', 'device', '#' } }, + }, + section = function(helper, opts) + opts = opts or {} + local conn = opts.conn - return { - render = function() - return device_diag.render(helper.shallow_merge({ - service_fn = opts.service_fn or opts.device_service_fn or (conn and helper.retained_fn(conn, { 'svc', 'device', 'status' }) or nil), - summary_fn = opts.summary_fn or opts.device_summary_fn or (conn and helper.retained_fn(conn, { 'state', 'device' }) or nil), - cm5_fn = opts.cm5_fn or (conn and helper.retained_fn(conn, { 'state', 'device', 'component', opts.cm5_component or 'cm5' }) or nil), - mcu_fn = opts.mcu_fn or (conn and helper.retained_fn(conn, { 'state', 'device', 'component', opts.mcu_component or 'mcu' }) or nil), - extra_fn = opts.extra_fn or opts.device_extra_fn, - }, opts)) - end, - } - end, + return { + render = function() + return device_diag.render(helper.shallow_merge({ + service_fn = opts.service_fn or opts.device_service_fn or (conn and helper.retained_fn(conn, { 'svc', 'device', 'status' }) or nil), + summary_fn = opts.summary_fn or opts.device_summary_fn or (conn and helper.retained_fn(conn, { 'state', 'device' }) or nil), + cm5_fn = opts.cm5_fn or (conn and helper.retained_fn(conn, { 'state', 'device', 'component', opts.cm5_component or 'cm5' }) or nil), + mcu_fn = opts.mcu_fn or (conn and helper.retained_fn(conn, { 'state', 'device', 'component', opts.mcu_component or 'mcu' }) or nil), + extra_fn = opts.extra_fn or opts.device_extra_fn, + }, opts)) + end, + } + end, } diff --git a/tests/support/diag_plugins/obs.lua b/tests/support/diag_plugins/obs.lua index 63a7d2dd..ac9280a5 100644 --- a/tests/support/diag_plugins/obs.lua +++ b/tests/support/diag_plugins/obs.lua @@ -1,9 +1,9 @@ return { - name = 'obs', - topic_groups = { + name = 'obs', + topic_groups = { - { label = 'obs', topic = { 'obs', '#' } }, - { label = 'svc', topic = { 'svc', '#' } }, + { label = 'obs', topic = { 'obs', '#' } }, + { label = 'svc', topic = { 'svc', '#' } }, - }, + }, } diff --git a/tests/support/diag_plugins/rpc.lua b/tests/support/diag_plugins/rpc.lua index c4eab078..08459f12 100644 --- a/tests/support/diag_plugins/rpc.lua +++ b/tests/support/diag_plugins/rpc.lua @@ -1,8 +1,8 @@ return { - name = 'rpc', - topic_groups = { + name = 'rpc', + topic_groups = { - { label = 'rpc', topic = { 'rpc', '#' } }, - }, + { label = 'rpc', topic = { 'rpc', '#' } }, + }, } diff --git a/tests/support/diag_registry.lua b/tests/support/diag_registry.lua index 8e9ae295..60a9e63c 100644 --- a/tests/support/diag_registry.lua +++ b/tests/support/diag_registry.lua @@ -1,10 +1,10 @@ return { - plugins = { - config = require 'tests.support.diag_plugins.config', - device = require 'tests.support.diag_plugins.device', - obs = require 'tests.support.diag_plugins.obs', - rpc = require 'tests.support.diag_plugins.rpc', - }, - profiles = { - }, + plugins = { + config = require 'tests.support.diag_plugins.config', + device = require 'tests.support.diag_plugins.device', + obs = require 'tests.support.diag_plugins.obs', + rpc = require 'tests.support.diag_plugins.rpc', + }, + profiles = { + }, } diff --git a/tests/support/test_diag.lua b/tests/support/test_diag.lua index 6cc6cf73..0854d354 100644 --- a/tests/support/test_diag.lua +++ b/tests/support/test_diag.lua @@ -7,302 +7,302 @@ local M = {} M.__index = M local function encode_one(v) - local ok, s = pcall(cjson.encode, v) - if ok and s then return s end - return tostring(v) + local ok, s = pcall(cjson.encode, v) + if ok and s then return s end + return tostring(v) end local function value_key(v) - return encode_one(v) + return encode_one(v) end function M.shallow_merge(a, b) - local out = {} - if type(a) == 'table' then - for k, v in pairs(a) do out[k] = v end - end - if type(b) == 'table' then - for k, v in pairs(b) do out[k] = v end - end - return out + local out = {} + if type(a) == 'table' then + for k, v in pairs(a) do out[k] = v end + end + if type(b) == 'table' then + for k, v in pairs(b) do out[k] = v end + end + return out end local function render_calls(label, calls, opts) - opts = opts or {} - local max_calls = opts.max_calls or 80 - calls = calls or {} - local start_idx = math.max(1, #calls - max_calls + 1) - local out = {} - out[#out + 1] = ('%s=%d'):format(tostring(label), #calls) - out[#out + 1] = ('-- %s --'):format(tostring(label)) - for i = start_idx, #calls do - local c = calls[i] - if type(c) == 'table' then - out[#out + 1] = ('[%d] %s'):format(i, encode_one(c)) - else - out[#out + 1] = ('[%d] %s'):format(i, tostring(c)) - end - end - return table.concat(out, '\n') + opts = opts or {} + local max_calls = opts.max_calls or 80 + calls = calls or {} + local start_idx = math.max(1, #calls - max_calls + 1) + local out = {} + out[#out + 1] = ('%s=%d'):format(tostring(label), #calls) + out[#out + 1] = ('-- %s --'):format(tostring(label)) + for i = start_idx, #calls do + local c = calls[i] + if type(c) == 'table' then + out[#out + 1] = ('[%d] %s'):format(i, encode_one(c)) + else + out[#out + 1] = ('[%d] %s'):format(i, tostring(c)) + end + end + return table.concat(out, '\n') end local function render_table(label, value) - return ('-- %s --\n%s'):format(tostring(label), encode_one(value)) + return ('-- %s --\n%s'):format(tostring(label), encode_one(value)) end local function plugin_named(name) - local plugin = registry.plugins[name] - assert(type(plugin) == 'table', 'unknown diag plugin: ' .. tostring(name)) - return plugin + local plugin = registry.plugins[name] + assert(type(plugin) == 'table', 'unknown diag plugin: ' .. tostring(name)) + return plugin end local function profile_named(name) - local profile = registry.profiles[name] - assert(type(profile) == 'function', 'unknown stack profile: ' .. tostring(name)) - return profile + local profile = registry.profiles[name] + assert(type(profile) == 'function', 'unknown stack profile: ' .. tostring(name)) + return profile end local function plugin_section(name, opts) - local plugin = plugin_named(name) - assert(type(plugin.section) == 'function', 'diag plugin has no section builder: ' .. tostring(name)) - return plugin.section(M, opts or {}) + local plugin = plugin_named(name) + assert(type(plugin.section) == 'function', 'diag plugin has no section builder: ' .. tostring(name)) + return plugin.section(M, opts or {}) end local function append_plugin_topics(out, name) - local plugin = plugin_named(name) - local groups = plugin.topic_groups or {} - for i = 1, #groups do - out[#out + 1] = groups[i] - end + local plugin = plugin_named(name) + local groups = plugin.topic_groups or {} + for i = 1, #groups do + out[#out + 1] = groups[i] + end end function M.add_section(diag, section) - diag.extra_sections[#diag.extra_sections + 1] = section - return diag + diag.extra_sections[#diag.extra_sections + 1] = section + return diag end function M.add_calls(diag, label, calls, opts) - diag.extra_sections[#diag.extra_sections + 1] = { label = label, calls = calls, opts = opts } - return diag + diag.extra_sections[#diag.extra_sections + 1] = { label = label, calls = calls, opts = opts } + return diag end function M.add_render(diag, label, fn) - diag.extra_sections[#diag.extra_sections + 1] = { - render = function() - return ('-- %s --\n%s'):format(tostring(label), tostring(fn())) - end, - } - return diag + diag.extra_sections[#diag.extra_sections + 1] = { + render = function() + return ('-- %s --\n%s'):format(tostring(label), tostring(fn())) + end, + } + return diag end function M.add_table(diag, label, value_fn) - diag.extra_sections[#diag.extra_sections + 1] = { - render = function() - local value = (type(value_fn) == 'function') and value_fn() or value_fn - return render_table(label, value) - end, - } - return diag + diag.extra_sections[#diag.extra_sections + 1] = { + render = function() + local value = (type(value_fn) == 'function') and value_fn() or value_fn + return render_table(label, value) + end, + } + return diag end function M.add_subsystem(diag, kind, opts) - diag.extra_sections[#diag.extra_sections + 1] = plugin_section(kind, opts) - return diag + diag.extra_sections[#diag.extra_sections + 1] = plugin_section(kind, opts) + return diag end function M.retained_fn(conn, topic, opts) - opts = opts or {} - return function() - local ok, payload = pcall(function() - return probe.wait_payload(conn, topic, { timeout = opts.timeout or 0.01 }) - end) - if ok then return payload end - return { __error = tostring(payload), topic = topic } - end + opts = opts or {} + return function() + local ok, payload = pcall(function() + return probe.wait_payload(conn, topic, { timeout = opts.timeout or 0.01 }) + end) + if ok then return payload end + return { __error = tostring(payload), topic = topic } + end end function M.assert_true(diag, cond, message) - if not cond then diag:fail(message or 'assert_true failed') end - return true + if not cond then diag:fail(message or 'assert_true failed') end + return true end function M.assert_eq(diag, want, got, message) - if want ~= got then - diag:fail((message or 'assert_eq failed') .. ('\nwant=%s\ngot=%s'):format(encode_one(want), encode_one(got))) - end - return true + if want ~= got then + diag:fail((message or 'assert_eq failed') .. ('\nwant=%s\ngot=%s'):format(encode_one(want), encode_one(got))) + end + return true end function M.assert_match(diag, got, partial, message) - if type(got) ~= 'table' or type(partial) ~= 'table' then - diag:fail((message or 'assert_match failed') .. ('\nwant_partial=%s\ngot=%s'):format(encode_one(partial), encode_one(got))) - end - for k, v in pairs(partial) do - if got[k] ~= v then - diag:fail((message or 'assert_match failed') .. ('\nkey=%s\nwant=%s\ngot=%s\nfull=%s'):format(tostring(k), encode_one(v), encode_one(got[k]), encode_one(got))) - end - end - return true + if type(got) ~= 'table' or type(partial) ~= 'table' then + diag:fail((message or 'assert_match failed') .. ('\nwant_partial=%s\ngot=%s'):format(encode_one(partial), encode_one(got))) + end + for k, v in pairs(partial) do + if got[k] ~= v then + diag:fail((message or 'assert_match failed') .. ('\nkey=%s\nwant=%s\ngot=%s\nfull=%s'):format(tostring(k), encode_one(v), encode_one(got[k]), encode_one(got))) + end + end + return true end function M.assert_eventually_eq(diag, label, getter, want, opts) - local got - diag:assert_until((label or 'assert_eventually_eq failed') .. ('\nwant=%s\ngot=%s'):format(encode_one(want), encode_one(got)), function() - got = getter() - return got == want - end, opts) - return true + local got + diag:assert_until((label or 'assert_eventually_eq failed') .. ('\nwant=%s\ngot=%s'):format(encode_one(want), encode_one(got)), function() + got = getter() + return got == want + end, opts) + return true end function M.assert_no_event(diag, pred, window_s, message) - local ok = probe.wait_until(function() - return pred() == true - end, { timeout = window_s or 0.1, interval = 0.01 }) - if ok then - diag:fail(message or 'assert_no_event failed: observed forbidden event') - end - return true + local ok = probe.wait_until(function() + return pred() == true + end, { timeout = window_s or 0.1, interval = 0.01 }) + if ok then + diag:fail(message or 'assert_no_event failed: observed forbidden event') + end + return true end function M.start_profile(scope, bus, name, opts) - opts = opts or {} - local spec = profile_named(name)(M, opts) - local diag = M.for_stack(scope, bus, spec.stack or {}) - local plugins = spec.plugins or {} - - for i = 1, #plugins do - local item = plugins[i] - local merged_opts = M.shallow_merge(opts, item.opts or {}) - diag.extra_sections[#diag.extra_sections + 1] = plugin_section(item.name, merged_opts) - end - - if type(spec.sections) == 'table' then - for i = 1, #spec.sections do - diag.extra_sections[#diag.extra_sections + 1] = spec.sections[i] - end - end - if type(spec.extra_sections) == 'table' then - for i = 1, #spec.extra_sections do - diag.extra_sections[#diag.extra_sections + 1] = spec.extra_sections[i] - end - end - return diag + opts = opts or {} + local spec = profile_named(name)(M, opts) + local diag = M.for_stack(scope, bus, spec.stack or {}) + local plugins = spec.plugins or {} + + for i = 1, #plugins do + local item = plugins[i] + local merged_opts = M.shallow_merge(opts, item.opts or {}) + diag.extra_sections[#diag.extra_sections + 1] = plugin_section(item.name, merged_opts) + end + + if type(spec.sections) == 'table' then + for i = 1, #spec.sections do + diag.extra_sections[#diag.extra_sections + 1] = spec.sections[i] + end + end + if type(spec.extra_sections) == 'table' then + for i = 1, #spec.extra_sections do + diag.extra_sections[#diag.extra_sections + 1] = spec.extra_sections[i] + end + end + return diag end M.for_stack_profile = M.start_profile function M.assert_transitions(diag, label, getter, expected, opts) - opts = opts or {} - local timeout = opts.timeout or 1.0 - local interval = opts.interval or 0.005 - local selector = opts.selector or function(v) return v end - local matches = opts.matches or function(got, want) return got == want end - local ignore_nil = (opts.ignore_nil ~= false) - local allow_other = (opts.allow_other ~= false) - local collapse = (opts.collapse ~= false) - local history = {} - local idx = 1 - local last_key = nil - - local function maybe_record(v) - local key = value_key(v) - if not collapse or key ~= last_key then - history[#history + 1] = v - last_key = key - end - end - - local ok = probe.wait_until(function() - local got_ok, raw = pcall(getter) - if not got_ok then return false end - local got = selector(raw) - if got == nil and ignore_nil then return false end - maybe_record(got) - if matches(got, expected[idx]) then - idx = idx + 1 - return idx > #expected - end - if not allow_other then - return false - end - return false - end, { timeout = timeout, interval = interval }) - - if not ok or idx <= #expected then - diag:fail((label or 'assert_transitions failed') - .. ('\nexpected=%s\nobserved=%s'):format(encode_one(expected), encode_one(history))) - end - - return history + opts = opts or {} + local timeout = opts.timeout or 1.0 + local interval = opts.interval or 0.005 + local selector = opts.selector or function(v) return v end + local matches = opts.matches or function(got, want) return got == want end + local ignore_nil = (opts.ignore_nil ~= false) + local allow_other = (opts.allow_other ~= false) + local collapse = (opts.collapse ~= false) + local history = {} + local idx = 1 + local last_key = nil + + local function maybe_record(v) + local key = value_key(v) + if not collapse or key ~= last_key then + history[#history + 1] = v + last_key = key + end + end + + local ok = probe.wait_until(function() + local got_ok, raw = pcall(getter) + if not got_ok then return false end + local got = selector(raw) + if got == nil and ignore_nil then return false end + maybe_record(got) + if matches(got, expected[idx]) then + idx = idx + 1 + return idx > #expected + end + if not allow_other then + return false + end + return false + end, { timeout = timeout, interval = interval }) + + if not ok or idx <= #expected then + diag:fail((label or 'assert_transitions failed') + .. ('\nexpected=%s\nobserved=%s'):format(encode_one(expected), encode_one(history))) + end + + return history end function M.assert_retained_transitions(diag, conn, topic, expected, opts) - opts = opts or {} - local getter = M.retained_fn(conn, topic, { timeout = opts.sample_timeout or 0.02 }) - return M.assert_transitions(diag, opts.label or ('retained transitions for ' .. encode_one(topic)), getter, expected, opts) + opts = opts or {} + local getter = M.retained_fn(conn, topic, { timeout = opts.sample_timeout or 0.02 }) + return M.assert_transitions(diag, opts.label or ('retained transitions for ' .. encode_one(topic)), getter, expected, opts) end function M.for_stack(scope, bus, opts) - opts = opts or {} - local topics = {} - for _, name in ipairs({ 'update', 'device', 'fabric', 'config', 'obs', 'rpc', 'ui' }) do - if opts[name] then append_plugin_topics(topics, name) end - end - if type(opts.topics) == 'table' then - for i = 1, #opts.topics do topics[#topics + 1] = opts.topics[i] end - end - return M.start(scope, bus, { - topics = topics, - max_records = opts.max_records, - fake_hal = opts.fake_hal, - extra_sections = opts.extra_sections, - }) + opts = opts or {} + local topics = {} + for _, name in ipairs({ 'update', 'device', 'fabric', 'config', 'obs', 'rpc', 'ui' }) do + if opts[name] then append_plugin_topics(topics, name) end + end + if type(opts.topics) == 'table' then + for i = 1, #opts.topics do topics[#topics + 1] = opts.topics[i] end + end + return M.start(scope, bus, { + topics = topics, + max_records = opts.max_records, + fake_hal = opts.fake_hal, + extra_sections = opts.extra_sections, + }) end function M.start(scope, bus, opts) - opts = opts or {} - local rec = stack_diag.start(scope, bus, opts.topics or {}, { max_records = opts.max_records }) - return setmetatable({ - rec = rec, - fake_hal = opts.fake_hal, - extra_sections = opts.extra_sections or {}, - }, M) + opts = opts or {} + local rec = stack_diag.start(scope, bus, opts.topics or {}, { max_records = opts.max_records }) + return setmetatable({ + rec = rec, + fake_hal = opts.fake_hal, + extra_sections = opts.extra_sections or {}, + }, M) end function M:render(message) - local parts = { - tostring(message), - '', - stack_diag.render(self.rec), - } - if self.fake_hal then - parts[#parts + 1] = '' - parts[#parts + 1] = stack_diag.render_fake_hal(self.fake_hal) - end - for i = 1, #self.extra_sections do - local sec = self.extra_sections[i] - parts[#parts + 1] = '' - if type(sec) == 'function' then - parts[#parts + 1] = tostring(sec()) - elseif type(sec) == 'table' and sec.render then - parts[#parts + 1] = tostring(sec.render()) - elseif type(sec) == 'table' and sec.calls then - parts[#parts + 1] = render_calls(sec.label or ('calls' .. tostring(i)), sec.calls, sec.opts) - else - parts[#parts + 1] = tostring(sec) - end - end - return table.concat(parts, '\n') + local parts = { + tostring(message), + '', + stack_diag.render(self.rec), + } + if self.fake_hal then + parts[#parts + 1] = '' + parts[#parts + 1] = stack_diag.render_fake_hal(self.fake_hal) + end + for i = 1, #self.extra_sections do + local sec = self.extra_sections[i] + parts[#parts + 1] = '' + if type(sec) == 'function' then + parts[#parts + 1] = tostring(sec()) + elseif type(sec) == 'table' and sec.render then + parts[#parts + 1] = tostring(sec.render()) + elseif type(sec) == 'table' and sec.calls then + parts[#parts + 1] = render_calls(sec.label or ('calls' .. tostring(i)), sec.calls, sec.opts) + else + parts[#parts + 1] = tostring(sec) + end + end + return table.concat(parts, '\n') end function M:fail(message) - error(self:render(message), 0) + error(self:render(message), 0) end function M:assert_until(message, pred, opts) - if not probe.wait_until(pred, opts) then - self:fail(message) - end + if not probe.wait_until(pred, opts) then + self:fail(message) + end end M.render_calls = render_calls diff --git a/tests/support/time_harness.lua b/tests/support/time_harness.lua index 84896cc7..b79c1ba0 100644 --- a/tests/support/time_harness.lua +++ b/tests/support/time_harness.lua @@ -3,7 +3,7 @@ local runtime = require 'fibers.runtime' local perform = fibers.perform local pack = rawget(table, 'pack') or function(...) - return { n = select('#', ...), ... } + return { n = select('#', ...), ... } end local unpack = rawget(table, 'unpack') or unpack @@ -27,30 +27,30 @@ local NOT_READY = {} ---@param op_or_factory any|fun(): any ---@return any local function resolve_op(op_or_factory) - if type(op_or_factory) == 'function' then - return op_or_factory() - end - return op_or_factory + if type(op_or_factory) == 'function' then + return op_or_factory() + end + return op_or_factory end ---@param op_or_factory any|fun(): any ---@return boolean ---@return ... function M.try_op_now(op_or_factory) - local op = resolve_op(op_or_factory) - local out = pack(perform(op:or_else(function() return NOT_READY end))) - if out.n == 1 and out[1] == NOT_READY then - return false - end - return true, unpack(out, 1, out.n) + local op = resolve_op(op_or_factory) + local out = pack(perform(op:or_else(function() return NOT_READY end))) + if out.n == 1 and out[1] == NOT_READY then + return false + end + return true, unpack(out, 1, out.n) end ---@param max_ticks? integer function M.flush_ticks(max_ticks) - max_ticks = max_ticks or 1 - for _ = 1, max_ticks do - runtime.yield() - end + max_ticks = max_ticks or 1 + for _ = 1, max_ticks do + runtime.yield() + end end ---@param op_or_factory any|fun(): any @@ -58,27 +58,27 @@ end ---@return boolean ---@return ... function M.wait_op_ticks(op_or_factory, opts) - opts = opts or {} + opts = opts or {} - local max_ticks = opts.max_ticks or 1 - local on_miss = opts.on_miss + local max_ticks = opts.max_ticks or 1 + local on_miss = opts.on_miss - for tick = 0, max_ticks do - local out = pack(M.try_op_now(op_or_factory)) - if out[1] then - return unpack(out, 1, out.n) - end + for tick = 0, max_ticks do + local out = pack(M.try_op_now(op_or_factory)) + if out[1] then + return unpack(out, 1, out.n) + end - if tick == max_ticks then break end + if tick == max_ticks then break end - if on_miss then - on_miss(tick + 1) - else - runtime.yield() - end - end + if on_miss then + on_miss(tick + 1) + else + runtime.yield() + end + end - return false + return false end ---@param clock VirtualClock @@ -88,26 +88,26 @@ end ---@return boolean ---@return ... function M.wait_op_within(clock, timeout_s, op_or_factory, opts) - opts = opts or {} - - local step = opts.step or timeout_s - local max_ticks = opts.max_ticks or 1 - local elapsed = 0 - - while true do - local out = pack(M.try_op_now(op_or_factory)) - if out[1] then - return unpack(out, 1, out.n) - end - if elapsed >= timeout_s then - return false - end - - local advance = math.min(step, timeout_s - elapsed) - clock:advance(advance) - M.flush_ticks(max_ticks) - elapsed = elapsed + advance - end + opts = opts or {} + + local step = opts.step or timeout_s + local max_ticks = opts.max_ticks or 1 + local elapsed = 0 + + while true do + local out = pack(M.try_op_now(op_or_factory)) + if out[1] then + return unpack(out, 1, out.n) + end + if elapsed >= timeout_s then + return false + end + + local advance = math.min(step, timeout_s - elapsed) + clock:advance(advance) + M.flush_ticks(max_ticks) + elapsed = elapsed + advance + end end return M diff --git a/tests/support/virtual_time.lua b/tests/support/virtual_time.lua index bfc9436f..f077b6d6 100644 --- a/tests/support/virtual_time.lua +++ b/tests/support/virtual_time.lua @@ -23,106 +23,106 @@ local active_clock = nil ---@param fallback number ---@return number local function now_or(value, fallback) - if value ~= nil then return value end - return fallback + if value ~= nil then return value end + return fallback end ---@param opts? VirtualTimeInstallOpts ---@return VirtualClock function M.install(opts) - if active_clock then - error('virtual_time.install: a clock is already installed') - end - - opts = opts or {} - - local scheduler = runtime.current_scheduler - local original = { - monotonic = time.monotonic, - realtime = time.realtime, - block = time._block, - scheduler_time = scheduler.get_time, - wheel_now = scheduler.wheel.now, - } - - local state = { - scheduler = scheduler, - original = original, - monotonic = now_or(opts.monotonic, scheduler:now()), - realtime = now_or(opts.realtime, time.realtime()), - follow_realtime = opts.follow_realtime ~= false, - restored = false, - } - - local function monotonic_now() - return state.monotonic - end - - local function realtime_now() - return state.realtime - end - - time.monotonic = monotonic_now - time.realtime = realtime_now - time._block = function() - return true - end - - scheduler.get_time = monotonic_now - scheduler.wheel.now = state.monotonic - - local clock = {} - - function clock:monotonic() - return state.monotonic - end - - function clock:realtime() - return state.realtime - end - - function clock:set_monotonic(value) - assert(type(value) == 'number', 'virtual_time.set_monotonic: value must be a number') - assert(value >= state.monotonic, 'virtual_time.set_monotonic: cannot move time backwards') - state.monotonic = value - return state.monotonic - end - - function clock:set_realtime(value) - assert(type(value) == 'number', 'virtual_time.set_realtime: value must be a number') - state.realtime = value - return state.realtime - end - - function clock:advance(dt) - assert(type(dt) == 'number', 'virtual_time.advance: dt must be a number') - assert(dt >= 0, 'virtual_time.advance: dt must be non-negative') - state.monotonic = state.monotonic + dt - if state.follow_realtime then - state.realtime = state.realtime + dt - end - return state.monotonic - end - - function clock:restore() - if state.restored then return end - if active_clock ~= clock then - error('virtual_time.restore: attempted to restore a non-active clock') - end - - scheduler.get_time = original.scheduler_time - scheduler.wheel.now = original.wheel_now - time.monotonic = original.monotonic - time.realtime = original.realtime - time._block = original.block - - state.restored = true - active_clock = nil - end - - ---@cast clock VirtualClock - active_clock = clock - return clock + if active_clock then + error('virtual_time.install: a clock is already installed') + end + + opts = opts or {} + + local scheduler = runtime.current_scheduler + local original = { + monotonic = time.monotonic, + realtime = time.realtime, + block = time._block, + scheduler_time = scheduler.get_time, + wheel_now = scheduler.wheel.now, + } + + local state = { + scheduler = scheduler, + original = original, + monotonic = now_or(opts.monotonic, scheduler:now()), + realtime = now_or(opts.realtime, time.realtime()), + follow_realtime = opts.follow_realtime ~= false, + restored = false, + } + + local function monotonic_now() + return state.monotonic + end + + local function realtime_now() + return state.realtime + end + + time.monotonic = monotonic_now + time.realtime = realtime_now + time._block = function() + return true + end + + scheduler.get_time = monotonic_now + scheduler.wheel.now = state.monotonic + + local clock = {} + + function clock:monotonic() + return state.monotonic + end + + function clock:realtime() + return state.realtime + end + + function clock:set_monotonic(value) + assert(type(value) == 'number', 'virtual_time.set_monotonic: value must be a number') + assert(value >= state.monotonic, 'virtual_time.set_monotonic: cannot move time backwards') + state.monotonic = value + return state.monotonic + end + + function clock:set_realtime(value) + assert(type(value) == 'number', 'virtual_time.set_realtime: value must be a number') + state.realtime = value + return state.realtime + end + + function clock:advance(dt) + assert(type(dt) == 'number', 'virtual_time.advance: dt must be a number') + assert(dt >= 0, 'virtual_time.advance: dt must be non-negative') + state.monotonic = state.monotonic + dt + if state.follow_realtime then + state.realtime = state.realtime + dt + end + return state.monotonic + end + + function clock:restore() + if state.restored then return end + if active_clock ~= clock then + error('virtual_time.restore: attempted to restore a non-active clock') + end + + scheduler.get_time = original.scheduler_time + scheduler.wheel.now = original.wheel_now + time.monotonic = original.monotonic + time.realtime = original.realtime + time._block = original.block + + state.restored = true + active_clock = nil + end + + ---@cast clock VirtualClock + active_clock = clock + return clock end return M diff --git a/tests/unit/devicecode/blob_source_spec.lua b/tests/unit/devicecode/blob_source_spec.lua index 18b681ab..9d43435d 100644 --- a/tests/unit/devicecode/blob_source_spec.lua +++ b/tests/unit/devicecode/blob_source_spec.lua @@ -7,152 +7,152 @@ local blob_source = require 'devicecode.blob_source' local T = {} function T.string_source_reads_chunks_and_reaches_eof() - runfibers.run(function() - local src = blob_source.from_string('abcdef') + runfibers.run(function() + local src = blob_source.from_string('abcdef') - local c1, e1 = fibers.perform(src:read_chunk_op(2)) - assert(c1 == 'ab' and e1 == nil) + local c1, e1 = fibers.perform(src:read_chunk_op(2)) + assert(c1 == 'ab' and e1 == nil) - local c2, e2 = fibers.perform(src:read_chunk_op(3)) - assert(c2 == 'cde' and e2 == nil) + local c2, e2 = fibers.perform(src:read_chunk_op(3)) + assert(c2 == 'cde' and e2 == nil) - local c3, e3 = fibers.perform(src:read_chunk_op(3)) - assert(c3 == 'f' and e3 == nil) + local c3, e3 = fibers.perform(src:read_chunk_op(3)) + assert(c3 == 'f' and e3 == nil) - local c4, e4 = fibers.perform(src:read_chunk_op(3)) - assert(c4 == nil and e4 == nil) - end) + local c4, e4 = fibers.perform(src:read_chunk_op(3)) + assert(c4 == nil and e4 == nil) + end) end function T.string_source_rejects_invalid_max_bytes() - runfibers.run(function() - local src = blob_source.from_string('abc') - local chunk, err = fibers.perform(src:read_chunk_op(0)) - assert(chunk == nil) - assert(tostring(err):match('invalid max_bytes')) - end) + runfibers.run(function() + local src = blob_source.from_string('abc') + local chunk, err = fibers.perform(src:read_chunk_op(0)) + assert(chunk == nil) + assert(tostring(err):match('invalid max_bytes')) + end) end function T.memory_sink_accumulates_written_chunks() - runfibers.run(function() - local sink = blob_source.to_memory() - local ok1, err1 = fibers.perform(sink:write_chunk_op('ab')) - local ok2, err2 = fibers.perform(sink:write_chunk_op('cd')) - assert(ok1 == true and err1 == nil) - assert(ok2 == true and err2 == nil) - assert(sink:result() == 'abcd') - end) + runfibers.run(function() + local sink = blob_source.to_memory() + local ok1, err1 = fibers.perform(sink:write_chunk_op('ab')) + local ok2, err2 = fibers.perform(sink:write_chunk_op('cd')) + assert(ok1 == true and err1 == nil) + assert(ok2 == true and err2 == nil) + assert(sink:result() == 'abcd') + end) end function T.copy_op_copies_string_source_to_memory_sink() - runfibers.run(function() - local src = blob_source.from_string('hello world') - local sink = blob_source.to_memory() - - local st, rep, bytes = fibers.perform(blob_source.copy_op(src, sink, { chunk_size = 4 })) - assert(st == 'ok', tostring(bytes or rep)) - assert(bytes == 11) - assert(sink:result() == 'hello world') - end) + runfibers.run(function() + local src = blob_source.from_string('hello world') + local sink = blob_source.to_memory() + + local st, rep, bytes = fibers.perform(blob_source.copy_op(src, sink, { chunk_size = 4 })) + assert(st == 'ok', tostring(bytes or rep)) + assert(bytes == 11) + assert(sink:result() == 'hello world') + end) end function T.copy_op_does_not_close_when_disabled() - runfibers.run(function() - local src_closed = 0 - local sink_closed = 0 - local src = { - remaining = true, - read_chunk_op = function(self, n) - return op.guard(function() - if self.remaining then - self.remaining = false - return op.always('xy', nil) - end - return op.always(nil, nil) - end) - end, - terminate = function(_) - src_closed = src_closed + 1 - return true, nil - end, - } - local sink = { - chunks = {}, - write_chunk_op = function(self, chunk) - return op.guard(function() - self.chunks[#self.chunks + 1] = chunk - return op.always(true, nil) - end) - end, - terminate = function(_) - sink_closed = sink_closed + 1 - return true, nil - end, - } - - local st, rep, bytes = fibers.perform(blob_source.copy_op(src, sink, { - close_source = false, - close_sink = false, - })) - assert(st == 'ok', tostring(bytes or rep)) - assert(bytes == 2) - assert(src_closed == 0) - assert(sink_closed == 0) - end) + runfibers.run(function() + local src_closed = 0 + local sink_closed = 0 + local src = { + remaining = true, + read_chunk_op = function(self, n) + return op.guard(function() + if self.remaining then + self.remaining = false + return op.always('xy', nil) + end + return op.always(nil, nil) + end) + end, + terminate = function(_) + src_closed = src_closed + 1 + return true, nil + end, + } + local sink = { + chunks = {}, + write_chunk_op = function(self, chunk) + return op.guard(function() + self.chunks[#self.chunks + 1] = chunk + return op.always(true, nil) + end) + end, + terminate = function(_) + sink_closed = sink_closed + 1 + return true, nil + end, + } + + local st, rep, bytes = fibers.perform(blob_source.copy_op(src, sink, { + close_source = false, + close_sink = false, + })) + assert(st == 'ok', tostring(bytes or rep)) + assert(bytes == 2) + assert(src_closed == 0) + assert(sink_closed == 0) + end) end function T.copy_op_closes_on_timeout_losing_arm() - runfibers.run(function() - local src_closed = 0 - local sink_closed = 0 - local src = { - read_chunk_op = function() - return sleep.sleep_op(0.2):wrap(function() return 'late', nil end) - end, - terminate = function(_) - src_closed = src_closed + 1 - return true, nil - end, - } - local sink = { - write_chunk_op = function(self, chunk) - return op.always(true, nil) - end, - terminate = function(_) - sink_closed = sink_closed + 1 - return true, nil - end, - } - - local which = fibers.perform(fibers.named_choice{ - copy = blob_source.copy_op(src, sink):wrap(function(...) return 'copy', ... end), - timeout = sleep.sleep_op(0.02):wrap(function() return 'timeout' end), - }) - assert(which == 'timeout') - - fibers.perform(sleep.sleep_op(0.02)) - - assert(src_closed == 1) - assert(sink_closed == 1) - end) + runfibers.run(function() + local src_closed = 0 + local sink_closed = 0 + local src = { + read_chunk_op = function() + return sleep.sleep_op(0.2):wrap(function() return 'late', nil end) + end, + terminate = function(_) + src_closed = src_closed + 1 + return true, nil + end, + } + local sink = { + write_chunk_op = function(self, chunk) + return op.always(true, nil) + end, + terminate = function(_) + sink_closed = sink_closed + 1 + return true, nil + end, + } + + local which = fibers.perform(fibers.named_choice{ + copy = blob_source.copy_op(src, sink):wrap(function(...) return 'copy', ... end), + timeout = sleep.sleep_op(0.02):wrap(function() return 'timeout' end), + }) + assert(which == 'timeout') + + fibers.perform(sleep.sleep_op(0.02)) + + assert(src_closed == 1) + assert(sink_closed == 1) + end) end function T.copy_op_fails_when_sink_write_fails() - runfibers.run(function() - local src = blob_source.from_string('boom') - local sink = { - write_chunk_op = function(self, chunk) - return op.always(nil, 'sink failed') - end, - terminate = function() - return true, nil - end, - } - - local st, rep, primary = fibers.perform(blob_source.copy_op(src, sink)) - assert(st == 'failed') - assert(tostring(primary):match('sink failed')) - end) + runfibers.run(function() + local src = blob_source.from_string('boom') + local sink = { + write_chunk_op = function(self, chunk) + return op.always(nil, 'sink failed') + end, + terminate = function() + return true, nil + end, + } + + local st, rep, primary = fibers.perform(blob_source.copy_op(src, sink)) + assert(st == 'failed') + assert(tostring(primary):match('sink failed')) + end) end return T diff --git a/tests/unit/devicecode/exec_direct_usage_spec.lua b/tests/unit/devicecode/exec_direct_usage_spec.lua index 120d35e1..bfa55b26 100644 --- a/tests/unit/devicecode/exec_direct_usage_spec.lua +++ b/tests/unit/devicecode/exec_direct_usage_spec.lua @@ -1,43 +1,43 @@ local T = {} local function read_file(path) - local f = assert(io.open(path, 'r')) - local s = f:read('*a') - f:close() - return s + local f = assert(io.open(path, 'r')) + local s = f:read('*a') + f:close() + return s end local function list_src_lua_files() - local roots = { '../src', 'src' } - for _, root in ipairs(roots) do - local p = io.popen(('find %s -type f -name "*.lua" | sort 2>/dev/null'):format(root)) - if p then - local files = {} - for line in p:lines() do files[#files + 1] = line end - p:close() - if #files > 0 then return files end - end - end - error('could not enumerate src lua files') + local roots = { '../src', 'src' } + for _, root in ipairs(roots) do + local p = io.popen(('find %s -type f -name "*.lua" | sort 2>/dev/null'):format(root)) + if p then + local files = {} + for line in p:lines() do files[#files + 1] = line end + p:close() + if #files > 0 then return files end + end + end + error('could not enumerate src lua files') end local function assert_no_source_match(pattern, label) - local violations = {} - for _, path in ipairs(list_src_lua_files()) do - local src = read_file(path) - if src:find(pattern) then - violations[#violations + 1] = path:gsub('^%.%./', '') - end - end - assert(#violations == 0, label .. ': ' .. table.concat(violations, ', ')) + local violations = {} + for _, path in ipairs(list_src_lua_files()) do + local src = read_file(path) + if src:find(pattern) then + violations[#violations + 1] = path:gsub('^%.%./', '') + end + end + assert(#violations == 0, label .. ': ' .. table.concat(violations, ', ')) end function T.source_exec_usage_is_explicit_without_policy_wrapper() - assert_no_source_match('devicecode%.support%.exec', 'top-level exec policy wrapper must not be used') - assert_no_source_match('os%.execute', 'os.execute must not be used in src') - assert_no_source_match('io%.popen', 'io.popen must not be used in src') - assert_no_source_match('exec%.command%([\'\"]', 'exec.command vararg literals must use explicit table specs') - assert_no_source_match('exec%.command%(unpack', 'exec.command(unpack(argv)) must build an explicit table spec') + assert_no_source_match('devicecode%.support%.exec', 'top-level exec policy wrapper must not be used') + assert_no_source_match('os%.execute', 'os.execute must not be used in src') + assert_no_source_match('io%.popen', 'io.popen must not be used in src') + assert_no_source_match('exec%.command%([\'\"]', 'exec.command vararg literals must use explicit table specs') + assert_no_source_match('exec%.command%(unpack', 'exec.command(unpack(argv)) must build an explicit table spec') end return T diff --git a/tests/unit/devicecode/service_base_spec.lua b/tests/unit/devicecode/service_base_spec.lua index f9dbf592..0bb5aca8 100644 --- a/tests/unit/devicecode/service_base_spec.lua +++ b/tests/unit/devicecode/service_base_spec.lua @@ -104,13 +104,13 @@ function T.obs_helpers_publish_legacy_and_v1_topics() local svc = service_base.new(conn, { name = 'beta', env = 'test' }) -- Subscribe BEFORE publishing non-retained events/logs. - local evt_legacy_sub = reader:subscribe({ 'obs', 'event', 'beta', 'tick' }) - local evt_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'event', 'tick' }) + local evt_legacy_sub = reader:subscribe({ 'obs', 'event', 'beta', 'tick' }) + local evt_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'event', 'tick' }) - local log_legacy_sub = reader:subscribe({ 'obs', 'log', 'beta', 'info' }) - local log_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'event', 'log' }) + local log_legacy_sub = reader:subscribe({ 'obs', 'log', 'beta', 'info' }) + local log_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'event', 'log' }) - local counter_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'counter', 'restarts' }) + local counter_v1_sub = reader:subscribe({ 'obs', 'v1', 'beta', 'counter', 'restarts' }) svc:obs_event('tick', { n = 1 }) svc:obs_state('phase', { value = 'booting' }) @@ -151,15 +151,15 @@ function T.obs_helpers_publish_legacy_and_v1_topics() assert(type(metric_v1) == 'table') assert(metric_v1.c == 42) - local counter_v1, err5 = counter_v1_sub:recv() - assert(counter_v1, tostring(err5)) - assert(counter_v1.payload.n == 3) + local counter_v1, err5 = counter_v1_sub:recv() + assert(counter_v1, tostring(err5)) + assert(counter_v1.payload.n == 3) evt_legacy_sub:unsubscribe() evt_v1_sub:unsubscribe() log_legacy_sub:unsubscribe() log_v1_sub:unsubscribe() - counter_v1_sub:unsubscribe() + counter_v1_sub:unsubscribe() end) end diff --git a/tests/unit/devicecode/signal_bridge_spec.lua b/tests/unit/devicecode/signal_bridge_spec.lua index 0b2187c2..e98130e9 100644 --- a/tests/unit/devicecode/signal_bridge_spec.lua +++ b/tests/unit/devicecode/signal_bridge_spec.lua @@ -1,89 +1,89 @@ local T = {} local function with_fake_posix(fn) - local saved_signal = package.loaded['posix.signal'] - local saved_unistd = package.loaded['posix.unistd'] - local saved_nixio = package.loaded['nixio'] - local saved_sleep = package.loaded['fibers.sleep'] - local saved_mod = package.loaded['devicecode.signal_bridge'] + local saved_signal = package.loaded['posix.signal'] + local saved_unistd = package.loaded['posix.unistd'] + local saved_nixio = package.loaded['nixio'] + local saved_sleep = package.loaded['fibers.sleep'] + local saved_mod = package.loaded['devicecode.signal_bridge'] - local handlers = {} - local killed + local handlers = {} + local killed - package.loaded['posix.signal'] = { - SIGTERM = 15, - SIGINT = 2, - signal = function(signum, handler) - local old = handlers[signum] or true - handlers[signum] = handler - return old - end, - kill = function(pid, sig) - killed = { pid = pid, sig = sig } - return true - end, - } - package.loaded['posix.unistd'] = { - getpid = function() return 12345 end, - } - package.loaded['nixio'] = nil - package.loaded['fibers.sleep'] = { - sleep = function(_seconds) - error('test watcher slept before cancellation', 0) - end, - } - package.loaded['devicecode.signal_bridge'] = nil + package.loaded['posix.signal'] = { + SIGTERM = 15, + SIGINT = 2, + signal = function(signum, handler) + local old = handlers[signum] or true + handlers[signum] = handler + return old + end, + kill = function(pid, sig) + killed = { pid = pid, sig = sig } + return true + end, + } + package.loaded['posix.unistd'] = { + getpid = function() return 12345 end, + } + package.loaded['nixio'] = nil + package.loaded['fibers.sleep'] = { + sleep = function(_seconds) + error('test watcher slept before cancellation', 0) + end, + } + package.loaded['devicecode.signal_bridge'] = nil - local ok, a, b = pcall(function() - return fn(handlers, function() return killed end) - end) + local ok, a, b = pcall(function() + return fn(handlers, function() return killed end) + end) - package.loaded['posix.signal'] = saved_signal - package.loaded['posix.unistd'] = saved_unistd - package.loaded['nixio'] = saved_nixio - package.loaded['fibers.sleep'] = saved_sleep - package.loaded['devicecode.signal_bridge'] = saved_mod + package.loaded['posix.signal'] = saved_signal + package.loaded['posix.unistd'] = saved_unistd + package.loaded['nixio'] = saved_nixio + package.loaded['fibers.sleep'] = saved_sleep + package.loaded['devicecode.signal_bridge'] = saved_mod - if not ok then error(a, 0) end - return a, b + if not ok then error(a, 0) end + return a, b end function T.signal_bridge_cancels_scope_from_watcher_not_handler() - with_fake_posix(function(handlers) - local bridge = require 'devicecode.signal_bridge' - local spawned - local restored - local cancelled - local scope = { - spawn = function(_self, fn) - spawned = fn - return true - end, - finally = function(_self, fn) - restored = fn - return function() end - end, - cancel = function(_self, reason) - cancelled = reason - end, - } + with_fake_posix(function(handlers) + local bridge = require 'devicecode.signal_bridge' + local spawned + local restored + local cancelled + local scope = { + spawn = function(_self, fn) + spawned = fn + return true + end, + finally = function(_self, fn) + restored = fn + return function() end + end, + cancel = function(_self, reason) + cancelled = reason + end, + } - local ok, backend = bridge.install(scope, { TERM = true }) - assert(ok == true) - assert(backend == 'posix.signal') - assert(type(spawned) == 'function') - assert(type(restored) == 'function') - assert(type(handlers[15]) == 'function') - assert(cancelled == nil) + local ok, backend = bridge.install(scope, { TERM = true }) + assert(ok == true) + assert(backend == 'posix.signal') + assert(type(spawned) == 'function') + assert(type(restored) == 'function') + assert(type(handlers[15]) == 'function') + assert(cancelled == nil) - handlers[15]() - assert(cancelled == nil, 'handler must not cancel scope directly') + handlers[15]() + assert(cancelled == nil, 'handler must not cancel scope directly') - spawned() - assert(cancelled == 'signal:TERM') + spawned() + assert(cancelled == 'signal:TERM') - restored() - end) + restored() + end) end return T diff --git a/tests/unit/hal/control_loop_spec.lua b/tests/unit/hal/control_loop_spec.lua index b3bee05c..6cfc957f 100644 --- a/tests/unit/hal/control_loop_spec.lua +++ b/tests/unit/hal/control_loop_spec.lua @@ -9,182 +9,182 @@ local types = require 'services.hal.types.core' local T = {} function T.evaluate_request_op_rejects_unknown_verbs() - runfibers.run(function() - local req = assert(types.new.ControlRequest('missing', {}, channel.new())) - local ok, err = fibers.perform(control_loop.evaluate_request_op({}, req)) - assert(ok == false) - assert(tostring(err):match('unsupported verb')) - end) + runfibers.run(function() + local req = assert(types.new.ControlRequest('missing', {}, channel.new())) + local ok, err = fibers.perform(control_loop.evaluate_request_op({}, req)) + assert(ok == false) + assert(tostring(err):match('unsupported verb')) + end) end function T.evaluate_request_op_requires_op_handlers() - runfibers.run(function() - local req = assert(types.new.ControlRequest('bad', {}, channel.new())) - local ok, err = pcall(function() - fibers.perform(control_loop.evaluate_request_op({ - bad = function() return true, 'nope' end, - }, req)) - end) - assert(ok == false) - assert(tostring(err):match('must return an Op')) - end) + runfibers.run(function() + local req = assert(types.new.ControlRequest('bad', {}, channel.new())) + local ok, err = pcall(function() + fibers.perform(control_loop.evaluate_request_op({ + bad = function() return true, 'nope' end, + }, req)) + end) + assert(ok == false) + assert(tostring(err):match('must return an Op')) + end) end function T.run_request_loop_handles_requests_and_replies() - runfibers.run(function(scope) - local ch = channel.new() - - local ok_spawn, err = scope:spawn(function() - control_loop.run_request_loop(ch, { - echo = function(opts) - return op.always(true, { echoed = opts.value }) - end, - }, nil, 'test_loop') - end) - assert(ok_spawn, tostring(err)) - - local reply_ch = channel.new() - local req = assert(types.new.ControlRequest('echo', { value = 42 }, reply_ch)) - - local sent, send_err = fibers.perform(ch:put_op(req)) - assert(sent ~= false, tostring(send_err)) - - local reply, reply_err = fibers.perform(reply_ch:get_op()) - assert(reply, tostring(reply_err)) - assert(reply.ok == true) - assert(type(reply.reason) == 'table') - assert(reply.reason.echoed == 42) - end) + runfibers.run(function(scope) + local ch = channel.new() + + local ok_spawn, err = scope:spawn(function() + control_loop.run_request_loop(ch, { + echo = function(opts) + return op.always(true, { echoed = opts.value }) + end, + }, nil, 'test_loop') + end) + assert(ok_spawn, tostring(err)) + + local reply_ch = channel.new() + local req = assert(types.new.ControlRequest('echo', { value = 42 }, reply_ch)) + + local sent, send_err = fibers.perform(ch:put_op(req)) + assert(sent ~= false, tostring(send_err)) + + local reply, reply_err = fibers.perform(reply_ch:get_op()) + assert(reply, tostring(reply_err)) + assert(reply.ok == true) + assert(type(reply.reason) == 'table') + assert(reply.reason.echoed == 42) + end) end function T.run_request_loop_returns_error_reply_for_unsupported_verbs() - runfibers.run(function(scope) - local ch = channel.new() + runfibers.run(function(scope) + local ch = channel.new() - local ok_spawn, err = scope:spawn(function() - control_loop.run_request_loop(ch, {}, nil, 'test_loop') - end) - assert(ok_spawn, tostring(err)) + local ok_spawn, err = scope:spawn(function() + control_loop.run_request_loop(ch, {}, nil, 'test_loop') + end) + assert(ok_spawn, tostring(err)) - local reply_ch = channel.new() - local req = assert(types.new.ControlRequest('nope', {}, reply_ch)) + local reply_ch = channel.new() + local req = assert(types.new.ControlRequest('nope', {}, reply_ch)) - local sent, send_err = fibers.perform(ch:put_op(req)) - assert(sent ~= false, tostring(send_err)) + local sent, send_err = fibers.perform(ch:put_op(req)) + assert(sent ~= false, tostring(send_err)) - local reply = fibers.perform(reply_ch:get_op()) - assert(reply ~= nil) - assert(reply.ok == false) - assert(tostring(reply.reason):match('unsupported verb')) - end) + local reply = fibers.perform(reply_ch:get_op()) + assert(reply ~= nil) + assert(reply.ok == false) + assert(tostring(reply.reason):match('unsupported verb')) + end) end function T.run_request_loop_exits_when_scope_is_cancelled() - runfibers.run(function(scope) - local loop_scope, cerr = scope:child() - assert(loop_scope, tostring(cerr)) - - local ch = channel.new() - - local ok_spawn, err = loop_scope:spawn(function() - control_loop.run_request_loop(ch, { - echo = function(opts) - return op.always(true, opts) - end, - }, nil, 'test_loop') - end) - assert(ok_spawn, tostring(err)) - - loop_scope:cancel('stop test') - - local st, rep, primary = fibers.perform(loop_scope:join_op()) - assert(st == 'cancelled', tostring(primary)) - end) + runfibers.run(function(scope) + local loop_scope, cerr = scope:child() + assert(loop_scope, tostring(cerr)) + + local ch = channel.new() + + local ok_spawn, err = loop_scope:spawn(function() + control_loop.run_request_loop(ch, { + echo = function(opts) + return op.always(true, opts) + end, + }, nil, 'test_loop') + end) + assert(ok_spawn, tostring(err)) + + loop_scope:cancel('stop test') + + local st, rep, primary = fibers.perform(loop_scope:join_op()) + assert(st == 'cancelled', tostring(primary)) + end) end function T.run_request_loop_cancels_handler_op_when_request_cancel_op_fires() - runfibers.run(function(scope) - local ch = channel.new() - local reply_ch = channel.new() - local cancel_ch = channel.new() - local entered = channel.new() - local aborted = false - - local ok_spawn, err = scope:spawn(function() - control_loop.run_request_loop(ch, { - slow = function() - entered:put(true) - return op.never():on_abort(function () aborted = true end) - end, - }, nil, 'test_loop') - end) - assert(ok_spawn, tostring(err)) - - local cancel_op = cancel_ch:get_op():wrap(function (reason) return reason or 'caller_abandoned' end) - local req = assert(types.new.ControlRequest('slow', {}, reply_ch, cancel_op)) - assert(fibers.perform(ch:put_op(req)) ~= false) - assert(fibers.perform(entered:get_op()) == true) - assert(fibers.perform(cancel_ch:put_op('caller_abandoned')) ~= false) - - for _ = 1, 4 do runtime.yield() end - assert(aborted == true) - - local got = fibers.perform(reply_ch:get_op():or_else(function () return nil, 'not_ready' end)) - assert(got == nil) - end) + runfibers.run(function(scope) + local ch = channel.new() + local reply_ch = channel.new() + local cancel_ch = channel.new() + local entered = channel.new() + local aborted = false + + local ok_spawn, err = scope:spawn(function() + control_loop.run_request_loop(ch, { + slow = function() + entered:put(true) + return op.never():on_abort(function () aborted = true end) + end, + }, nil, 'test_loop') + end) + assert(ok_spawn, tostring(err)) + + local cancel_op = cancel_ch:get_op():wrap(function (reason) return reason or 'caller_abandoned' end) + local req = assert(types.new.ControlRequest('slow', {}, reply_ch, cancel_op)) + assert(fibers.perform(ch:put_op(req)) ~= false) + assert(fibers.perform(entered:get_op()) == true) + assert(fibers.perform(cancel_ch:put_op('caller_abandoned')) ~= false) + + for _ = 1, 4 do runtime.yield() end + assert(aborted == true) + + local got = fibers.perform(reply_ch:get_op():or_else(function () return nil, 'not_ready' end)) + assert(got == nil) + end) end function T.run_request_loop_detaches_caller_after_admission_when_policy_requests_it() - runfibers.run(function(scope) - local ch = channel.new() - local reply_ch = channel.new() - local cancel_ch = channel.new(1) - local entered = channel.new(1) - local release = channel.new(1) - local aborted = false - local completed = false - local logs = {} - local logger = { - warn = function(_, payload) logs[#logs + 1] = payload end, - info = function(_, payload) logs[#logs + 1] = payload end, - debug = function(_, payload) logs[#logs + 1] = payload end, - } - - local ok_spawn, err = scope:spawn(function() - control_loop.run_request_loop(ch, { - __cancel_policy = { apply = 'detach_after_admission' }, - apply = function() - fibers.perform(entered:put_op(true)) - return release:get_op():wrap(function () completed = true; return true, { applied = true } end) - :on_abort(function () aborted = true end) - end, - }, logger, 'network_config') - end) - assert(ok_spawn, tostring(err)) - - local cancel_op = cancel_ch:get_op():wrap(function (reason) return reason or 'caller_abandoned' end) - local req = assert(types.new.ControlRequest('apply', {}, reply_ch, cancel_op)) - assert(fibers.perform(ch:put_op(req)) ~= false) - assert(fibers.perform(entered:get_op()) == true) - assert(fibers.perform(cancel_ch:put_op('caller_abandoned')) ~= false) - for _ = 1, 4 do runtime.yield() end - assert(aborted == false, 'admitted apply must not be aborted by caller abandonment') - assert(fibers.perform(release:put_op(true)) ~= false) - for _ = 1, 4 do runtime.yield() end - assert(completed == true, 'admitted apply should complete after caller detaches') - local got = fibers.perform(reply_ch:get_op():or_else(function () return nil, 'not_ready' end)) - assert(got == nil, 'detached caller should not receive a late reply') - - local saw_detached = false - for _, rec in ipairs(logs) do - if rec.what == 'network_config_request_detached' and rec.admitted == true then - saw_detached = true - end - end - assert(saw_detached == true, 'expected admitted caller detachment log') - end) + runfibers.run(function(scope) + local ch = channel.new() + local reply_ch = channel.new() + local cancel_ch = channel.new(1) + local entered = channel.new(1) + local release = channel.new(1) + local aborted = false + local completed = false + local logs = {} + local logger = { + warn = function(_, payload) logs[#logs + 1] = payload end, + info = function(_, payload) logs[#logs + 1] = payload end, + debug = function(_, payload) logs[#logs + 1] = payload end, + } + + local ok_spawn, err = scope:spawn(function() + control_loop.run_request_loop(ch, { + __cancel_policy = { apply = 'detach_after_admission' }, + apply = function() + fibers.perform(entered:put_op(true)) + return release:get_op():wrap(function () completed = true; return true, { applied = true } end) + :on_abort(function () aborted = true end) + end, + }, logger, 'network_config') + end) + assert(ok_spawn, tostring(err)) + + local cancel_op = cancel_ch:get_op():wrap(function (reason) return reason or 'caller_abandoned' end) + local req = assert(types.new.ControlRequest('apply', {}, reply_ch, cancel_op)) + assert(fibers.perform(ch:put_op(req)) ~= false) + assert(fibers.perform(entered:get_op()) == true) + assert(fibers.perform(cancel_ch:put_op('caller_abandoned')) ~= false) + for _ = 1, 4 do runtime.yield() end + assert(aborted == false, 'admitted apply must not be aborted by caller abandonment') + assert(fibers.perform(release:put_op(true)) ~= false) + for _ = 1, 4 do runtime.yield() end + assert(completed == true, 'admitted apply should complete after caller detaches') + local got = fibers.perform(reply_ch:get_op():or_else(function () return nil, 'not_ready' end)) + assert(got == nil, 'detached caller should not receive a late reply') + + local saw_detached = false + for _, rec in ipairs(logs) do + if rec.what == 'network_config_request_detached' and rec.admitted == true then + saw_detached = true + end + end + assert(saw_detached == true, 'expected admitted caller detachment log') + end) end return T diff --git a/tests/unit/hal/control_store_manager_spec.lua b/tests/unit/hal/control_store_manager_spec.lua index 714f27c1..3340700b 100644 --- a/tests/unit/hal/control_store_manager_spec.lua +++ b/tests/unit/hal/control_store_manager_spec.lua @@ -5,226 +5,226 @@ local runfibers = require 'tests.support.run_fibers' local T = {} local function mk_tmpdir(tag) - local path = ('/tmp/dc-lua-%s-%d-%06d'):format(tag, os.time(), math.random(0, 999999)) - local ok = os.execute(('mkdir -p %q'):format(path)) - assert(ok == true or ok == 0, 'failed to create temp dir: ' .. path) - return path + local path = ('/tmp/dc-lua-%s-%d-%06d'):format(tag, os.time(), math.random(0, 999999)) + local ok = os.execute(('mkdir -p %q'):format(path)) + assert(ok == true or ok == 0, 'failed to create temp dir: ' .. path) + return path end local function rm_rf(path) - os.execute(('rm -rf %q'):format(path)) + os.execute(('rm -rf %q'):format(path)) end local function fresh_manager() - package.loaded['services.hal.managers.control_store'] = nil - package.loaded['services.hal.drivers.control_store'] = nil - package.loaded['services.hal.drivers.control_store_provider'] = nil - return require('services.hal.managers.control_store') + package.loaded['services.hal.managers.control_store'] = nil + package.loaded['services.hal.drivers.control_store'] = nil + package.loaded['services.hal.drivers.control_store_provider'] = nil + return require('services.hal.managers.control_store') end local function recv_or_fail(ch) - local v, err = fibers.perform(ch:get_op()) - assert(v ~= nil, tostring(err)) - return v + local v, err = fibers.perform(ch:get_op()) + assert(v ~= nil, tostring(err)) + return v end function T.start_apply_config_and_stop_round_trip() - local root = mk_tmpdir('csm-roundtrip') - local M = fresh_manager() + local root = mk_tmpdir('csm-roundtrip') + local M = fresh_manager() - runfibers.run(function(scope) - local dev_ev_ch = channel.new(8) - local cap_emit_ch = channel.new(8) + runfibers.run(function(scope) + local dev_ev_ch = channel.new(8) + local cap_emit_ch = channel.new(8) - local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) - assert(ok_start == true, tostring(err_start)) + local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) + assert(ok_start == true, tostring(err_start)) - local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ - { name = 'main', root = root }, - })) - assert(ok_cfg == true, tostring(err_cfg)) + local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ + { name = 'main', root = root }, + })) + assert(ok_cfg == true, tostring(err_cfg)) - local ev = recv_or_fail(dev_ev_ch) - assert(ev.event_type == 'added') - assert(ev.class == 'control-store') - assert(ev.id == 'main') - assert(type(ev.capabilities) == 'table' and #ev.capabilities == 1) + local ev = recv_or_fail(dev_ev_ch) + assert(ev.event_type == 'added') + assert(ev.class == 'control-store') + assert(ev.id == 'main') + assert(type(ev.capabilities) == 'table' and #ev.capabilities == 1) - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - end) + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + end) - rm_rf(root) + rm_rf(root) end function T.apply_config_creates_missing_control_store_root() - local base = mk_tmpdir('csm-create-root') - local root = base .. '/nested/control-store' - local M = fresh_manager() + local base = mk_tmpdir('csm-create-root') + local root = base .. '/nested/control-store' + local M = fresh_manager() - runfibers.run(function() - local dev_ev_ch = channel.new(8) - local cap_emit_ch = channel.new(8) + runfibers.run(function() + local dev_ev_ch = channel.new(8) + local cap_emit_ch = channel.new(8) - local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) - assert(ok_start == true, tostring(err_start)) + local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) + assert(ok_start == true, tostring(err_start)) - local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ - { name = 'main', root = root }, - })) - assert(ok_cfg == true, tostring(err_cfg)) + local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ + { name = 'main', root = root }, + })) + assert(ok_cfg == true, tostring(err_cfg)) - local ev = recv_or_fail(dev_ev_ch) - assert(ev.event_type == 'added') - assert(ev.class == 'control-store') - assert(ev.id == 'main') + local ev = recv_or_fail(dev_ev_ch) + assert(ev.event_type == 'added') + assert(ev.class == 'control-store') + assert(ev.id == 'main') - local probe, perr = io.open(root .. '/.probe', 'wb') - assert(probe ~= nil, tostring(perr)) - probe:write('ok') - probe:close() + local probe, perr = io.open(root .. '/.probe', 'wb') + assert(probe ~= nil, tostring(perr)) + probe:write('ok') + probe:close() - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - end) + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + end) - rm_rf(base) + rm_rf(base) end function T.apply_config_fails_when_not_started() - local root = mk_tmpdir('csm-not-started') - local M = fresh_manager() - - runfibers.run(function() - local ok, err = fibers.perform(M.apply_config_op({ - { name = 'main', root = root }, - })) - assert(ok == false) - assert(tostring(err):match('not started')) - end) - - rm_rf(root) + local root = mk_tmpdir('csm-not-started') + local M = fresh_manager() + + runfibers.run(function() + local ok, err = fibers.perform(M.apply_config_op({ + { name = 'main', root = root }, + })) + assert(ok == false) + assert(tostring(err):match('not started')) + end) + + rm_rf(root) end function T.cap_emit_channel_receives_initial_meta_and_state() - local root = mk_tmpdir('csm-emit') - local M = fresh_manager() - - runfibers.run(function() - local dev_ev_ch = channel.new(8) - local cap_emit_ch = channel.new(8) - - local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) - assert(ok_start == true, tostring(err_start)) - - local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ - { name = 'main', root = root }, - })) - assert(ok_cfg == true, tostring(err_cfg)) - - local e1 = recv_or_fail(cap_emit_ch) - local e2 = recv_or_fail(cap_emit_ch) - - local by_mode = { - [e1.mode] = e1, - [e2.mode] = e2, - } - - assert(by_mode.meta ~= nil) - assert(by_mode.state ~= nil) - assert(by_mode.meta.class == 'control-store') - assert(by_mode.meta.id == 'main') - assert(by_mode.meta.key == 'details') - assert(by_mode.meta.data.root == root) - assert(by_mode.state.key == 'status') - assert(by_mode.state.data.state == 'available') - - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - end) - - rm_rf(root) + local root = mk_tmpdir('csm-emit') + local M = fresh_manager() + + runfibers.run(function() + local dev_ev_ch = channel.new(8) + local cap_emit_ch = channel.new(8) + + local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) + assert(ok_start == true, tostring(err_start)) + + local ok_cfg, err_cfg = fibers.perform(M.apply_config_op({ + { name = 'main', root = root }, + })) + assert(ok_cfg == true, tostring(err_cfg)) + + local e1 = recv_or_fail(cap_emit_ch) + local e2 = recv_or_fail(cap_emit_ch) + + local by_mode = { + [e1.mode] = e1, + [e2.mode] = e2, + } + + assert(by_mode.meta ~= nil) + assert(by_mode.state ~= nil) + assert(by_mode.meta.class == 'control-store') + assert(by_mode.meta.id == 'main') + assert(by_mode.meta.key == 'details') + assert(by_mode.meta.data.root == root) + assert(by_mode.state.key == 'status') + assert(by_mode.state.data.state == 'available') + + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + end) + + rm_rf(root) end function T.reapply_same_config_is_idempotent() - local root = mk_tmpdir('csm-idempotent') - local M = fresh_manager() + local root = mk_tmpdir('csm-idempotent') + local M = fresh_manager() - runfibers.run(function() - local dev_ev_ch = channel.new(8) - local cap_emit_ch = channel.new(8) + runfibers.run(function() + local dev_ev_ch = channel.new(8) + local cap_emit_ch = channel.new(8) - local ok_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) - assert(ok_start == true) + local ok_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) + assert(ok_start == true) - local ok1, err1 = fibers.perform(M.apply_config_op({ { name = 'main', root = root } })) - assert(ok1 == true, tostring(err1)) - local added = recv_or_fail(dev_ev_ch) - assert(added.event_type == 'added') + local ok1, err1 = fibers.perform(M.apply_config_op({ { name = 'main', root = root } })) + assert(ok1 == true, tostring(err1)) + local added = recv_or_fail(dev_ev_ch) + assert(added.event_type == 'added') - local ok2, err2 = fibers.perform(M.apply_config_op({ { name = 'main', root = root } })) - assert(ok2 == true, tostring(err2)) + local ok2, err2 = fibers.perform(M.apply_config_op({ { name = 'main', root = root } })) + assert(ok2 == true, tostring(err2)) - local which = fibers.perform(require('fibers').named_choice{ - msg = dev_ev_ch:get_op():wrap(function(v) return 'msg', v end), - timeout = require('fibers.sleep').sleep_op(0.05):wrap(function() return 'timeout' end), - }) - assert(which == 'timeout', 'reapplying same config should not emit new device events') + local which = fibers.perform(require('fibers').named_choice{ + msg = dev_ev_ch:get_op():wrap(function(v) return 'msg', v end), + timeout = require('fibers.sleep').sleep_op(0.05):wrap(function() return 'timeout' end), + }) + assert(which == 'timeout', 'reapplying same config should not emit new device events') - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - end) + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + end) - rm_rf(root) + rm_rf(root) end function T.reconcile_root_change_emits_removed_then_added() - local root1 = mk_tmpdir('csm-root1') - local root2 = mk_tmpdir('csm-root2') - local M = fresh_manager() + local root1 = mk_tmpdir('csm-root1') + local root2 = mk_tmpdir('csm-root2') + local M = fresh_manager() - runfibers.run(function() - local dev_ev_ch = channel.new(8) - local cap_emit_ch = channel.new(8) + runfibers.run(function() + local dev_ev_ch = channel.new(8) + local cap_emit_ch = channel.new(8) - local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) - assert(ok_start == true, tostring(err_start)) + local ok_start, err_start = fibers.perform(M.start_op(nil, dev_ev_ch, cap_emit_ch)) + assert(ok_start == true, tostring(err_start)) - local ok1, err1 = fibers.perform(M.apply_config_op({ { name = 'main', root = root1 } })) - assert(ok1 == true, tostring(err1)) - local first = recv_or_fail(dev_ev_ch) - assert(first.event_type == 'added') + local ok1, err1 = fibers.perform(M.apply_config_op({ { name = 'main', root = root1 } })) + assert(ok1 == true, tostring(err1)) + local first = recv_or_fail(dev_ev_ch) + assert(first.event_type == 'added') - local ok2, err2 = fibers.perform(M.apply_config_op({ { name = 'main', root = root2 } })) - assert(ok2 == true, tostring(err2)) + local ok2, err2 = fibers.perform(M.apply_config_op({ { name = 'main', root = root2 } })) + assert(ok2 == true, tostring(err2)) - local ev_a = recv_or_fail(dev_ev_ch) - local ev_b = recv_or_fail(dev_ev_ch) - assert(ev_a.event_type == 'removed') - assert(ev_b.event_type == 'added') - assert(ev_b.id == 'main') + local ev_a = recv_or_fail(dev_ev_ch) + local ev_b = recv_or_fail(dev_ev_ch) + assert(ev_a.event_type == 'removed') + assert(ev_b.event_type == 'added') + assert(ev_b.id == 'main') - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - end) + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + end) - rm_rf(root1) - rm_rf(root2) + rm_rf(root1) + rm_rf(root2) end function T.shutdown_op_before_start_is_ok_and_fault_op_is_inert() - local M = fresh_manager() - - runfibers.run(function() - local ok_stop, err_stop = fibers.perform(M.shutdown_op()) - assert(ok_stop == true, tostring(err_stop)) - - local which = fibers.perform(fibers.named_choice{ - fault = M.fault_op():wrap(function(...) return 'fault', ... end), - timeout = require('fibers.sleep').sleep_op(0.05):wrap(function() return 'timeout' end), - }) - assert(which == 'timeout') - end) + local M = fresh_manager() + + runfibers.run(function() + local ok_stop, err_stop = fibers.perform(M.shutdown_op()) + assert(ok_stop == true, tostring(err_stop)) + + local which = fibers.perform(fibers.named_choice{ + fault = M.fault_op():wrap(function(...) return 'fault', ... end), + timeout = require('fibers.sleep').sleep_op(0.05):wrap(function() return 'timeout' end), + }) + assert(which == 'timeout') + end) end return T diff --git a/tests/unit/hal/control_store_provider_spec.lua b/tests/unit/hal/control_store_provider_spec.lua index 94a6a8b9..58fa17f2 100644 --- a/tests/unit/hal/control_store_provider_spec.lua +++ b/tests/unit/hal/control_store_provider_spec.lua @@ -4,168 +4,168 @@ local cap_args = require 'services.hal.types.capability_args' local T = {} local function mk_tmpdir(tag) - local path = ('/tmp/dc-lua-%s-%d-%06d'):format(tag, os.time(), math.random(0, 999999)) - local ok = os.execute(('mkdir -p %q'):format(path)) - assert(ok == true or ok == 0, 'failed to create temp dir: ' .. path) - return path + local path = ('/tmp/dc-lua-%s-%d-%06d'):format(tag, os.time(), math.random(0, 999999)) + local ok = os.execute(('mkdir -p %q'):format(path)) + assert(ok == true or ok == 0, 'failed to create temp dir: ' .. path) + return path end local function rm_rf(path) - os.execute(('rm -rf %q'):format(path)) + os.execute(('rm -rf %q'):format(path)) end local function read_file(path) - local f, err = io.open(path, 'rb') - if not f then return nil, err end - local data = f:read('*a') - f:close() - return data + local f, err = io.open(path, 'rb') + if not f then return nil, err end + local data = f:read('*a') + f:close() + return data end local function fresh_provider(root) - package.loaded['services.hal.drivers.control_store_provider'] = nil - return require('services.hal.drivers.control_store_provider').new(root, nil) + package.loaded['services.hal.drivers.control_store_provider'] = nil + return require('services.hal.drivers.control_store_provider').new(root, nil) end function T.status_op_reports_root_and_kind() - local root = mk_tmpdir('csp-status') - local provider = fresh_provider(root) - - runfibers.run(function() - local ok, payload = require('fibers').perform(provider:status_op()) - assert(ok == true) - assert(type(payload) == 'table') - assert(payload.root == root) - assert(payload.kind == 'control-store') - end) - - rm_rf(root) + local root = mk_tmpdir('csp-status') + local provider = fresh_provider(root) + + runfibers.run(function() + local ok, payload = require('fibers').perform(provider:status_op()) + assert(ok == true) + assert(type(payload) == 'table') + assert(payload.root == root) + assert(payload.kind == 'control-store') + end) + + rm_rf(root) end function T.put_get_and_list_round_trip() - local root = mk_tmpdir('csp-roundtrip') - local provider = fresh_provider(root) + local root = mk_tmpdir('csp-roundtrip') + local provider = fresh_provider(root) - runfibers.run(function() - local fibers = require 'fibers' + runfibers.run(function() + local fibers = require 'fibers' - local put_opts = assert(cap_args.new.ControlStorePutOpts('alpha', 'hello')) - local ok_put, err_put = fibers.perform(provider:put_op(put_opts)) - assert(ok_put == true, tostring(err_put)) + local put_opts = assert(cap_args.new.ControlStorePutOpts('alpha', 'hello')) + local ok_put, err_put = fibers.perform(provider:put_op(put_opts)) + assert(ok_put == true, tostring(err_put)) - local get_opts = assert(cap_args.new.ControlStoreGetOpts('alpha')) - local ok_get, value = fibers.perform(provider:get_op(get_opts)) - assert(ok_get == true, tostring(value)) - assert(value == 'hello') + local get_opts = assert(cap_args.new.ControlStoreGetOpts('alpha')) + local ok_get, value = fibers.perform(provider:get_op(get_opts)) + assert(ok_get == true, tostring(value)) + assert(value == 'hello') - local ok_list, keys = fibers.perform(provider:list_op()) - assert(ok_list == true, tostring(keys)) - assert(#keys == 1) - assert(keys[1] == 'alpha') - end) + local ok_list, keys = fibers.perform(provider:list_op()) + assert(ok_list == true, tostring(keys)) + assert(#keys == 1) + assert(keys[1] == 'alpha') + end) - rm_rf(root) + rm_rf(root) end function T.list_op_filters_by_prefix_and_is_sorted_unique() - local root = mk_tmpdir('csp-prefix') - local provider = fresh_provider(root) - - runfibers.run(function() - local fibers = require 'fibers' - - for _, pair in ipairs({ - { 'a.one', '1' }, - { 'a.two', '2' }, - { 'b.one', '3' }, - }) do - local ok, err = fibers.perform(provider:put_op(assert(cap_args.new.ControlStorePutOpts(pair[1], pair[2])))) - assert(ok == true, tostring(err)) - end - - local ok, keys = fibers.perform(provider:list_op(assert(cap_args.new.ControlStoreListOpts('a.')))) - assert(ok == true, tostring(keys)) - assert(#keys == 2) - assert(keys[1] == 'a.one') - assert(keys[2] == 'a.two') - end) - - rm_rf(root) + local root = mk_tmpdir('csp-prefix') + local provider = fresh_provider(root) + + runfibers.run(function() + local fibers = require 'fibers' + + for _, pair in ipairs({ + { 'a.one', '1' }, + { 'a.two', '2' }, + { 'b.one', '3' }, + }) do + local ok, err = fibers.perform(provider:put_op(assert(cap_args.new.ControlStorePutOpts(pair[1], pair[2])))) + assert(ok == true, tostring(err)) + end + + local ok, keys = fibers.perform(provider:list_op(assert(cap_args.new.ControlStoreListOpts('a.')))) + assert(ok == true, tostring(keys)) + assert(#keys == 2) + assert(keys[1] == 'a.one') + assert(keys[2] == 'a.two') + end) + + rm_rf(root) end function T.get_op_rejects_invalid_key() - local root = mk_tmpdir('csp-invalid-get') - local provider = fresh_provider(root) + local root = mk_tmpdir('csp-invalid-get') + local provider = fresh_provider(root) - runfibers.run(function() - local fibers = require 'fibers' - local ok, err = fibers.perform(provider:get_op({ key = '../bad' })) - assert(ok == false) - assert(tostring(err):match('invalid key')) - end) + runfibers.run(function() + local fibers = require 'fibers' + local ok, err = fibers.perform(provider:get_op({ key = '../bad' })) + assert(ok == false) + assert(tostring(err):match('invalid key')) + end) - rm_rf(root) + rm_rf(root) end function T.put_op_rejects_invalid_data() - local root = mk_tmpdir('csp-invalid-put') - local provider = fresh_provider(root) + local root = mk_tmpdir('csp-invalid-put') + local provider = fresh_provider(root) - runfibers.run(function() - local fibers = require 'fibers' - local ok, err = fibers.perform(provider:put_op({ key = 'alpha', data = 123 })) - assert(ok == false) - assert(tostring(err):match('data must be a string')) - end) + runfibers.run(function() + local fibers = require 'fibers' + local ok, err = fibers.perform(provider:put_op({ key = 'alpha', data = 123 })) + assert(ok == false) + assert(tostring(err):match('data must be a string')) + end) - rm_rf(root) + rm_rf(root) end function T.get_op_returns_not_found_for_missing_key() - local root = mk_tmpdir('csp-missing') - local provider = fresh_provider(root) + local root = mk_tmpdir('csp-missing') + local provider = fresh_provider(root) - runfibers.run(function() - local fibers = require 'fibers' - local ok, err = fibers.perform(provider:get_op(assert(cap_args.new.ControlStoreGetOpts('missing')))) - assert(ok == false) - assert(tostring(err):match('not found')) - end) + runfibers.run(function() + local fibers = require 'fibers' + local ok, err = fibers.perform(provider:get_op(assert(cap_args.new.ControlStoreGetOpts('missing')))) + assert(ok == false) + assert(tostring(err):match('not found')) + end) - rm_rf(root) + rm_rf(root) end function T.delete_op_removes_key_from_index_and_truncates_file() - local root = mk_tmpdir('csp-delete') - local provider = fresh_provider(root) - local key_path = root .. '/alpha' - local index_path = root .. '/.control_store_index' + local root = mk_tmpdir('csp-delete') + local provider = fresh_provider(root) + local key_path = root .. '/alpha' + local index_path = root .. '/.control_store_index' - runfibers.run(function() - local fibers = require 'fibers' + runfibers.run(function() + local fibers = require 'fibers' - local ok_put, err_put = fibers.perform(provider:put_op(assert(cap_args.new.ControlStorePutOpts('alpha', 'payload')))) - assert(ok_put == true, tostring(err_put)) + local ok_put, err_put = fibers.perform(provider:put_op(assert(cap_args.new.ControlStorePutOpts('alpha', 'payload')))) + assert(ok_put == true, tostring(err_put)) - local ok_del, err_del = fibers.perform(provider:delete_op(assert(cap_args.new.ControlStoreDeleteOpts('alpha')))) - assert(ok_del == true, tostring(err_del)) + local ok_del, err_del = fibers.perform(provider:delete_op(assert(cap_args.new.ControlStoreDeleteOpts('alpha')))) + assert(ok_del == true, tostring(err_del)) - local ok_get, err_get = fibers.perform(provider:get_op(assert(cap_args.new.ControlStoreGetOpts('alpha')))) - assert(ok_get == false) - assert(tostring(err_get):match('not found')) + local ok_get, err_get = fibers.perform(provider:get_op(assert(cap_args.new.ControlStoreGetOpts('alpha')))) + assert(ok_get == false) + assert(tostring(err_get):match('not found')) - local ok_list, keys = fibers.perform(provider:list_op()) - assert(ok_list == true, tostring(keys)) - assert(#keys == 0) - end) + local ok_list, keys = fibers.perform(provider:list_op()) + assert(ok_list == true, tostring(keys)) + assert(#keys == 0) + end) - local data = assert(read_file(key_path)) - assert(data == '', 'delete currently truncates underlying file') + local data = assert(read_file(key_path)) + assert(data == '', 'delete currently truncates underlying file') - local index_data = assert(read_file(index_path)) - assert(index_data == '', 'index should no longer contain deleted key') + local index_data = assert(read_file(index_path)) + assert(index_data == '', 'index should no longer contain deleted key') - rm_rf(root) + rm_rf(root) end return T diff --git a/tests/unit/hal/modem_process_flags_spec.lua b/tests/unit/hal/modem_process_flags_spec.lua index 064d6e80..78476f08 100644 --- a/tests/unit/hal/modem_process_flags_spec.lua +++ b/tests/unit/hal/modem_process_flags_spec.lua @@ -1,65 +1,65 @@ local T = {} local function with_exec_supports(supports_parent_death_signal, fn) - local saved_exec = package.loaded['fibers.io.exec'] - local saved_mod = package.loaded['services.hal.backends.modem.process_flags'] + local saved_exec = package.loaded['fibers.io.exec'] + local saved_mod = package.loaded['services.hal.backends.modem.process_flags'] - package.loaded['fibers.io.exec'] = { - supports = function(name) - return name == 'parent_death_signal' and supports_parent_death_signal or false - end, - } - package.loaded['services.hal.backends.modem.process_flags'] = nil + package.loaded['fibers.io.exec'] = { + supports = function(name) + return name == 'parent_death_signal' and supports_parent_death_signal or false + end, + } + package.loaded['services.hal.backends.modem.process_flags'] = nil - local ok, a, b = pcall(fn) + local ok, a, b = pcall(fn) - package.loaded['fibers.io.exec'] = saved_exec - package.loaded['services.hal.backends.modem.process_flags'] = saved_mod + package.loaded['fibers.io.exec'] = saved_exec + package.loaded['services.hal.backends.modem.process_flags'] = saved_mod - if not ok then error(a, 0) end - return a, b + if not ok then error(a, 0) end + return a, b end function T.owned_monitor_flags_always_requests_process_group() - with_exec_supports(false, function() - local flags = require('services.hal.backends.modem.process_flags').owned_monitor_flags() - assert(flags.process_group == true) - assert(flags.parent_death_signal == nil) - assert(flags.pdeathsig == nil) - end) + with_exec_supports(false, function() + local flags = require('services.hal.backends.modem.process_flags').owned_monitor_flags() + assert(flags.process_group == true) + assert(flags.parent_death_signal == nil) + assert(flags.pdeathsig == nil) + end) end function T.owned_monitor_flags_adds_parent_death_signal_only_when_supported() - with_exec_supports(true, function() - local flags = require('services.hal.backends.modem.process_flags').owned_monitor_flags() - assert(flags.process_group == true) - assert(flags.parent_death_signal == 'TERM') - assert(flags.pdeathsig == nil) - end) + with_exec_supports(true, function() + local flags = require('services.hal.backends.modem.process_flags').owned_monitor_flags() + assert(flags.process_group == true) + assert(flags.parent_death_signal == 'TERM') + assert(flags.pdeathsig == nil) + end) end local function read_qmi_source() - local candidates = { - '../src/services/hal/backends/modem/modes/qmi.lua', - 'src/services/hal/backends/modem/modes/qmi.lua', - } - for _, path in ipairs(candidates) do - local f = io.open(path, 'r') - if f then - local src = f:read('*a') - f:close() - return src - end - end - error('could not read qmi.lua') + local candidates = { + '../src/services/hal/backends/modem/modes/qmi.lua', + 'src/services/hal/backends/modem/modes/qmi.lua', + } + for _, path in ipairs(candidates) do + local f = io.open(path, 'r') + if f then + local src = f:read('*a') + f:close() + return src + end + end + error('could not read qmi.lua') end function T.qmi_sim_power_cycle_commands_use_owned_process_flags() - local src = read_qmi_source() - assert(src:match('uim%-sim%-power%-off=1"[%s%S]-flags%s*=%s*process_flags%.owned_monitor_flags%(%)'), - 'qmicli SIM power-off command must use owned process flags') - assert(src:match('uim%-sim%-power%-on=1"[%s%S]-flags%s*=%s*process_flags%.owned_monitor_flags%(%)'), - 'qmicli SIM power-on command must use owned process flags') + local src = read_qmi_source() + assert(src:match('uim%-sim%-power%-off=1"[%s%S]-flags%s*=%s*process_flags%.owned_monitor_flags%(%)'), + 'qmicli SIM power-off command must use owned process flags') + assert(src:match('uim%-sim%-power%-on=1"[%s%S]-flags%s*=%s*process_flags%.owned_monitor_flags%(%)'), + 'qmicli SIM power-on command must use owned process flags') end return T diff --git a/tests/unit/net/test_backhaul_model.lua b/tests/unit/net/test_backhaul_model.lua index 300729d2..a50695a1 100644 --- a/tests/unit/net/test_backhaul_model.lua +++ b/tests/unit/net/test_backhaul_model.lua @@ -8,197 +8,197 @@ local function eq(a, b, msg) if a ~= b then fail((msg or 'assertion failed') .. local function ok(v, msg) if not v then fail(msg or 'expected truthy') end return v end function tests.test_reduces_hal_multiwan_facts_to_semantic_backhaul() - local model = backhaul.reduce({ - segments = { - wan = { kind = 'wan', vlan = { id = 4 } }, - }, - wan = { configured_members = { wan = { interface = 'wan', metric = 10 } } }, - observed = { - snapshot = { - multiwan = { - backend = 'openwrt', - source = 'mwan3', - interfaces_by_semantic = { - wan = { - interface = 'wan', - ifname = 'eth0.2', - state = 'online', - usable = true, - uptime_s = 123, - age_s = 4, - metric = 10, - }, - }, - }, - live = { - interfaces = { - wan = { ipv4 = { { address = '203.0.113.10', mask = 24 } } }, - }, - }, - }, - }, - }, { now = 42 }) + local model = backhaul.reduce({ + segments = { + wan = { kind = 'wan', vlan = { id = 4 } }, + }, + wan = { configured_members = { wan = { interface = 'wan', metric = 10 } } }, + observed = { + snapshot = { + multiwan = { + backend = 'openwrt', + source = 'mwan3', + interfaces_by_semantic = { + wan = { + interface = 'wan', + ifname = 'eth0.2', + state = 'online', + usable = true, + uptime_s = 123, + age_s = 4, + metric = 10, + }, + }, + }, + live = { + interfaces = { + wan = { ipv4 = { { address = '203.0.113.10', mask = 24 } } }, + }, + }, + }, + }, + }, { now = 42 }) - eq(model.state, 'ok') - local wan = ok(model.uplinks.wan, 'wan uplink expected') - eq(wan.state, 'online') - eq(wan.usable, true) - eq(wan.uptime_s, 123) - eq(wan.source.kind, 'host-multiwan') - eq(wan.source.tool, 'mwan3') - eq(wan.status_source.tool, 'mwan3') - eq(wan.link.kind, 'wired') - eq(wan.link.vlan, 4) - eq(wan.path_address.address, '203.0.113.10') + eq(model.state, 'ok') + local wan = ok(model.uplinks.wan, 'wan uplink expected') + eq(wan.state, 'online') + eq(wan.usable, true) + eq(wan.uptime_s, 123) + eq(wan.source.kind, 'host-multiwan') + eq(wan.source.tool, 'mwan3') + eq(wan.status_source.tool, 'mwan3') + eq(wan.link.kind, 'wired') + eq(wan.link.vlan, 4) + eq(wan.path_address.address, '203.0.113.10') end function tests.test_backhaul_uses_mwan3_for_status_and_endpoint_for_device_name() - local model = backhaul.reduce({ - interfaces = { - wan = { endpoint = { ifname = 'vl-wan' } }, - }, - wan = { configured_members = { wan = { interface = 'wan', metric = 10 } } }, - observed = { - snapshot = { - multiwan = { - backend = 'openwrt', - source = 'mwan3', - interfaces_by_semantic = { - wan = { interface = 'wan', state = 'online', usable = true }, - }, - }, - }, - }, - }, { now = 42 }) + local model = backhaul.reduce({ + interfaces = { + wan = { endpoint = { ifname = 'vl-wan' } }, + }, + wan = { configured_members = { wan = { interface = 'wan', metric = 10 } } }, + observed = { + snapshot = { + multiwan = { + backend = 'openwrt', + source = 'mwan3', + interfaces_by_semantic = { + wan = { interface = 'wan', state = 'online', usable = true }, + }, + }, + }, + }, + }, { now = 42 }) - local wan = ok(model.uplinks.wan, 'wan uplink expected') - eq(wan.state, 'online') - eq(wan.usable, true) - eq(wan.ifname, 'vl-wan') - eq(wan.source.tool, 'mwan3') - eq(wan.status_source.tool, 'mwan3') + local wan = ok(model.uplinks.wan, 'wan uplink expected') + eq(wan.state, 'online') + eq(wan.usable, true) + eq(wan.ifname, 'vl-wan') + eq(wan.source.tool, 'mwan3') + eq(wan.status_source.tool, 'mwan3') end function tests.test_gsm_uplink_uses_mwan3_status_not_gsm_connection_state() - local model = backhaul.reduce({ - wan = { - configured_members = { - gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, - }, - }, - sources = { - gsm_uplinks = { - primary = { - state = 'sim_absent', - connected = false, - linux = { ifname = 'wwan0' }, - }, - }, - }, - observed = { - snapshot = { - multiwan = { - backend = 'openwrt', - source = 'mwan3', - interfaces_by_semantic = { - modem_primary = { - interface = 'modem_primary', - ifname = 'wwan0', - state = 'online', - usable = true, - }, - }, - }, - live = { interfaces = { wwan0 = { ipv4 = { { address = '10.1.2.3' } } } } }, - }, - }, - }, { now = 42 }) + local model = backhaul.reduce({ + wan = { + configured_members = { + gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, + }, + }, + sources = { + gsm_uplinks = { + primary = { + state = 'sim_absent', + connected = false, + linux = { ifname = 'wwan0' }, + }, + }, + }, + observed = { + snapshot = { + multiwan = { + backend = 'openwrt', + source = 'mwan3', + interfaces_by_semantic = { + modem_primary = { + interface = 'modem_primary', + ifname = 'wwan0', + state = 'online', + usable = true, + }, + }, + }, + live = { interfaces = { wwan0 = { ipv4 = { { address = '10.1.2.3' } } } } }, + }, + }, + }, { now = 42 }) - local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') - eq(uplink.state, 'online') - eq(uplink.usable, true) - eq(uplink.observed, true) - eq(uplink.source.kind, 'gsm-uplink') - eq(uplink.source.id, 'primary') - eq(uplink.status_source.kind, 'host-multiwan') - eq(uplink.status_source.tool, 'mwan3') - eq(uplink.link.kind, 'cellular') - eq(uplink.ifname, 'wwan0') - eq(uplink.path_address.address, '10.1.2.3') - eq(uplink.gsm, nil) + local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') + eq(uplink.state, 'online') + eq(uplink.usable, true) + eq(uplink.observed, true) + eq(uplink.source.kind, 'gsm-uplink') + eq(uplink.source.id, 'primary') + eq(uplink.status_source.kind, 'host-multiwan') + eq(uplink.status_source.tool, 'mwan3') + eq(uplink.link.kind, 'cellular') + eq(uplink.ifname, 'wwan0') + eq(uplink.path_address.address, '10.1.2.3') + eq(uplink.gsm, nil) end function tests.test_gsm_uplink_remains_offline_when_mwan3_is_offline_even_if_gsm_connected() - local model = backhaul.reduce({ - wan = { - configured_members = { - gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, - }, - }, - sources = { - gsm_uplinks = { - primary = { - state = 'connected', - connected = true, - linux = { ifname = 'wwan0' }, - }, - }, - }, - observed = { - snapshot = { - multiwan = { - backend = 'openwrt', - source = 'mwan3', - interfaces_by_semantic = { - modem_primary = { - interface = 'modem_primary', - ifname = 'wwan0', - state = 'offline', - usable = false, - }, - }, - }, - }, - }, - }, { now = 42 }) + local model = backhaul.reduce({ + wan = { + configured_members = { + gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, + }, + }, + sources = { + gsm_uplinks = { + primary = { + state = 'connected', + connected = true, + linux = { ifname = 'wwan0' }, + }, + }, + }, + observed = { + snapshot = { + multiwan = { + backend = 'openwrt', + source = 'mwan3', + interfaces_by_semantic = { + modem_primary = { + interface = 'modem_primary', + ifname = 'wwan0', + state = 'offline', + usable = false, + }, + }, + }, + }, + }, + }, { now = 42 }) - local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') - eq(uplink.state, 'offline') - eq(uplink.usable, false) - eq(uplink.ifname, 'wwan0') + local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') + eq(uplink.state, 'offline') + eq(uplink.usable, false) + eq(uplink.ifname, 'wwan0') end function tests.test_gsm_member_stays_present_without_gsm_details() - local model = backhaul.reduce({ - wan = { - configured_members = { - gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, - }, - }, - observed = { - snapshot = { - multiwan = { - backend = 'openwrt', - source = 'mwan3', - interfaces_by_semantic = { - modem_primary = { - interface = 'modem_primary', - ifname = 'wwan0', - state = 'offline', - usable = false, - }, - }, - }, - }, - }, - }, { now = 42 }) + local model = backhaul.reduce({ + wan = { + configured_members = { + gsm_primary = { interface = 'modem_primary', source = { kind = 'gsm-uplink', id = 'primary' } }, + }, + }, + observed = { + snapshot = { + multiwan = { + backend = 'openwrt', + source = 'mwan3', + interfaces_by_semantic = { + modem_primary = { + interface = 'modem_primary', + ifname = 'wwan0', + state = 'offline', + usable = false, + }, + }, + }, + }, + }, + }, { now = 42 }) - local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') - eq(uplink.state, 'offline') - eq(uplink.usable, false) - eq(uplink.source.id, 'primary') - eq(uplink.ifname, 'wwan0') + local uplink = ok(model.uplinks.gsm_primary, 'gsm uplink expected') + eq(uplink.state, 'offline') + eq(uplink.usable, false) + eq(uplink.source.id, 'primary') + eq(uplink.ifname, 'wwan0') end return tests diff --git a/tests/unit/net/test_wan_policy_event_led.lua b/tests/unit/net/test_wan_policy_event_led.lua index c2e2c0b5..f8804f4d 100644 --- a/tests/unit/net/test_wan_policy_event_led.lua +++ b/tests/unit/net/test_wan_policy_event_led.lua @@ -28,26 +28,26 @@ local function snapshot() end local function uplinks_by_id(s) - local out = {} - for _, u in ipairs(policy.collect_uplinks(s)) do out[u.uplink_id] = u end - return out + local out = {} + for _, u in ipairs(policy.collect_uplinks(s)) do out[u.uplink_id] = u end + return out end local function keyed_success(s, uplink_id, mbps, completed_at) - local uplink = uplinks_by_id(s)[uplink_id] - local measurement = assert(policy.measurement(s, uplink)) - return { - state = 'ok', - generation = s.generation, - ok = true, - peak_mbps = mbps, - last_success_mbps = mbps, - completed_at = completed_at, - interface = uplink.request.interface, - measurement_key = measurement.key, - measurement = measurement, - last_success = { mbps = mbps, completed_at = completed_at, measurement_key = measurement.key, measurement = measurement }, - } + local uplink = uplinks_by_id(s)[uplink_id] + local measurement = assert(policy.measurement(s, uplink)) + return { + state = 'ok', + generation = s.generation, + ok = true, + peak_mbps = mbps, + last_success_mbps = mbps, + completed_at = completed_at, + interface = uplink.request.interface, + measurement_key = measurement.key, + measurement = measurement, + last_success = { mbps = mbps, completed_at = completed_at, measurement_key = measurement.key, measurement = measurement }, + } end function tests.test_only_observed_online_uplink_is_due() @@ -74,68 +74,68 @@ function tests.test_weights_include_probe_members_after_one_measurement() end function tests.test_fresh_previous_generation_success_is_used_for_weights() - local s = snapshot() - s.generation = 2 - s.wan.load_balancing.speedtests.interval_s = 100 - s.backhaul.uplinks.modem_primary.state = 'online' - s.backhaul.uplinks.modem_primary.usable = true - s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) - s.wan_runtime.speedtests.wan.generation = 1 - s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 20) - s.wan_runtime.speedtests.modem_primary.generation = 2 - local uplinks = policy.collect_uplinks(s) - local by_uplink = {} - for _, u in ipairs(uplinks) do by_uplink[u.uplink_id] = u end - local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) - eq(due, false) - eq(reason, 'fresh_same_path') - - local weights = assert(policy.compute_weights(s, 2, { now = 30 })) - local by_id = {} - for _, m in ipairs(weights) do by_id[m.id] = m end - eq(by_id.wan.weight, 80) - eq(by_id.wan.probe, false) - eq(by_id.modem_primary.weight, 20) - - local expired_weights = assert(policy.compute_weights(s, 2, { now = 119 })) - local expired_by_id = {} - for _, m in ipairs(expired_weights) do expired_by_id[m.id] = m end - eq(expired_by_id.wan.weight, 1) - eq(expired_by_id.wan.probe, true) - eq(expired_by_id.modem_primary.weight, 100) + local s = snapshot() + s.generation = 2 + s.wan.load_balancing.speedtests.interval_s = 100 + s.backhaul.uplinks.modem_primary.state = 'online' + s.backhaul.uplinks.modem_primary.usable = true + s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) + s.wan_runtime.speedtests.wan.generation = 1 + s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 20) + s.wan_runtime.speedtests.modem_primary.generation = 2 + local uplinks = policy.collect_uplinks(s) + local by_uplink = {} + for _, u in ipairs(uplinks) do by_uplink[u.uplink_id] = u end + local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) + eq(due, false) + eq(reason, 'fresh_same_path') + + local weights = assert(policy.compute_weights(s, 2, { now = 30 })) + local by_id = {} + for _, m in ipairs(weights) do by_id[m.id] = m end + eq(by_id.wan.weight, 80) + eq(by_id.wan.probe, false) + eq(by_id.modem_primary.weight, 20) + + local expired_weights = assert(policy.compute_weights(s, 2, { now = 119 })) + local expired_by_id = {} + for _, m in ipairs(expired_weights) do expired_by_id[m.id] = m end + eq(expired_by_id.wan.weight, 1) + eq(expired_by_id.wan.probe, true) + eq(expired_by_id.modem_primary.weight, 100) end function tests.test_failed_latest_speedtest_keeps_fresh_last_success_for_weights() - local s = snapshot() - s.generation = 2 - s.wan.load_balancing.speedtests.interval_s = 100 - s.backhaul.uplinks.modem_primary.state = 'online' - s.backhaul.uplinks.modem_primary.usable = true - s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) - s.wan_runtime.speedtests.wan.state = 'failed' - s.wan_runtime.speedtests.wan.ok = false - s.wan_runtime.speedtests.wan.last_attempt = { state = 'failed', reason = 'counter_unavailable', completed_at = 30 } - s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 20) - - local weights = assert(policy.compute_weights(s, 2, { now = 30 })) - local by_id = {} - for _, m in ipairs(weights) do by_id[m.id] = m end - eq(by_id.wan.weight, 80) - eq(by_id.wan.probe, false) - eq(by_id.modem_primary.weight, 20) + local s = snapshot() + s.generation = 2 + s.wan.load_balancing.speedtests.interval_s = 100 + s.backhaul.uplinks.modem_primary.state = 'online' + s.backhaul.uplinks.modem_primary.usable = true + s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) + s.wan_runtime.speedtests.wan.state = 'failed' + s.wan_runtime.speedtests.wan.ok = false + s.wan_runtime.speedtests.wan.last_attempt = { state = 'failed', reason = 'counter_unavailable', completed_at = 30 } + s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 20) + + local weights = assert(policy.compute_weights(s, 2, { now = 30 })) + local by_id = {} + for _, m in ipairs(weights) do by_id[m.id] = m end + eq(by_id.wan.weight, 80) + eq(by_id.wan.probe, false) + eq(by_id.modem_primary.weight, 20) end function tests.test_weights_fall_back_to_mwan3_online_members_without_speedtest_success() - local s = snapshot() - local weights = assert(policy.compute_weights(s, 1, { now = 30 })) - eq(#weights, 1) - eq(weights[1].id, 'wan') - eq(weights[1].weight, 100) - eq(weights[1].probe, true) - eq(weights[1].reason, 'no_successful_speedtests') + local s = snapshot() + local weights = assert(policy.compute_weights(s, 1, { now = 30 })) + eq(#weights, 1) + eq(weights[1].id, 'wan') + eq(weights[1].weight, 100) + eq(weights[1].probe, true) + eq(weights[1].reason, 'no_successful_speedtests') end function tests.test_default_speedtest_interval_is_six_hours() @@ -174,87 +174,87 @@ end function tests.test_ip_change_invalidates_fresh_measurement() - local s = snapshot() - s.wan.load_balancing.speedtests.interval_s = 100 - s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) - s.backhaul.uplinks.wan.path_address.address = '198.51.100.42' - local by_uplink = uplinks_by_id(s) - local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) - eq(due, true) - eq(reason, 'path_changed_ip') + local s = snapshot() + s.wan.load_balancing.speedtests.interval_s = 100 + s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) + s.backhaul.uplinks.wan.path_address.address = '198.51.100.42' + local by_uplink = uplinks_by_id(s) + local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) + eq(due, true) + eq(reason, 'path_changed_ip') end function tests.test_modem_ip_change_invalidates_fresh_measurement() - local s = snapshot() - s.wan.load_balancing.speedtests.interval_s = 100 - s.backhaul.uplinks.modem_primary.state = 'online' - s.backhaul.uplinks.modem_primary.usable = true - s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 10) - s.backhaul.uplinks.modem_primary.path_address.address = '10.9.8.7' - local by_uplink = uplinks_by_id(s) - local due, reason = policy.speedtest_due(s, by_uplink.modem_primary, { generation = 2, now = 30 }) - eq(due, true) - eq(reason, 'path_changed_ip') + local s = snapshot() + s.wan.load_balancing.speedtests.interval_s = 100 + s.backhaul.uplinks.modem_primary.state = 'online' + s.backhaul.uplinks.modem_primary.usable = true + s.wan_runtime.speedtests.modem_primary = keyed_success(s, 'modem_primary', 20, 10) + s.backhaul.uplinks.modem_primary.path_address.address = '10.9.8.7' + local by_uplink = uplinks_by_id(s) + local due, reason = policy.speedtest_due(s, by_uplink.modem_primary, { generation = 2, now = 30 }) + eq(due, true) + eq(reason, 'path_changed_ip') end function tests.test_online_uplink_without_ip_still_runs_first_measurement() - local s = snapshot() - s.backhaul.uplinks.wan.path_address = nil - local by_uplink = uplinks_by_id(s) - local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 10 }) - eq(due, true) - eq(reason, 'due') - local measurement = assert(policy.measurement(s, by_uplink.wan)) - eq(measurement.address_family, 'unknown') - eq(measurement.address, 'unknown') + local s = snapshot() + s.backhaul.uplinks.wan.path_address = nil + local by_uplink = uplinks_by_id(s) + local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 10 }) + eq(due, true) + eq(reason, 'due') + local measurement = assert(policy.measurement(s, by_uplink.wan)) + eq(measurement.address_family, 'unknown') + eq(measurement.address, 'unknown') end function tests.test_fresh_weak_measurement_is_reused_when_ip_later_appears() - local s = snapshot() - s.wan.load_balancing.speedtests.interval_s = 100 - s.backhaul.uplinks.wan.path_address = nil - s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) - s.backhaul.uplinks.wan.path_address = { family = 'ipv4', address = '203.0.113.10' } - local by_uplink = uplinks_by_id(s) - local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) - eq(due, false) - eq(reason, 'fresh_weak_path') + local s = snapshot() + s.wan.load_balancing.speedtests.interval_s = 100 + s.backhaul.uplinks.wan.path_address = nil + s.wan_runtime.speedtests.wan = keyed_success(s, 'wan', 80, 10) + s.backhaul.uplinks.wan.path_address = { family = 'ipv4', address = '203.0.113.10' } + local by_uplink = uplinks_by_id(s) + local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 2, now = 30 }) + eq(due, false) + eq(reason, 'fresh_weak_path') end function tests.test_speedtest_retry_delay_uses_configured_exponential_backoff() - local s = snapshot() - s.wan.load_balancing.speedtests.retry_after_s = 10 - s.wan.load_balancing.speedtests.retry_max_s = 60 - eq(policy.speedtest_retry_delay_s(s, { failure_count = 1 }), 10) - eq(policy.speedtest_retry_delay_s(s, { failure_count = 2 }), 20) - eq(policy.speedtest_retry_delay_s(s, { failure_count = 3 }), 40) - eq(policy.speedtest_retry_delay_s(s, { failure_count = 4 }), 60) + local s = snapshot() + s.wan.load_balancing.speedtests.retry_after_s = 10 + s.wan.load_balancing.speedtests.retry_max_s = 60 + eq(policy.speedtest_retry_delay_s(s, { failure_count = 1 }), 10) + eq(policy.speedtest_retry_delay_s(s, { failure_count = 2 }), 20) + eq(policy.speedtest_retry_delay_s(s, { failure_count = 3 }), 40) + eq(policy.speedtest_retry_delay_s(s, { failure_count = 4 }), 60) end function tests.test_speedtest_retry_delay_defaults_to_ten_seconds() - local s = snapshot() - eq(policy.speedtest_retry_delay_s(s, { failure_count = 1 }), 10) + local s = snapshot() + eq(policy.speedtest_retry_delay_s(s, { failure_count = 1 }), 10) end function tests.test_retry_backoff_only_suppresses_same_path() - local s = snapshot() - local by_uplink = uplinks_by_id(s) - local measurement = assert(policy.measurement(s, by_uplink.wan)) - s.wan_runtime.speedtests.wan = { - state = 'failed', ok = false, measurement_key = measurement.key, - failure_count = 1, retry_after = 40, - } - local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 20 }) - eq(due, false) - eq(reason, 'retry_later') - - s.backhaul.uplinks.wan.path_address.address = '198.51.100.42' - by_uplink = uplinks_by_id(s) - due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 20 }) - eq(due, true) - eq(reason, 'due') + local s = snapshot() + local by_uplink = uplinks_by_id(s) + local measurement = assert(policy.measurement(s, by_uplink.wan)) + s.wan_runtime.speedtests.wan = { + state = 'failed', ok = false, measurement_key = measurement.key, + failure_count = 1, retry_after = 40, + } + local due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 20 }) + eq(due, false) + eq(reason, 'retry_later') + + s.backhaul.uplinks.wan.path_address.address = '198.51.100.42' + by_uplink = uplinks_by_id(s) + due, reason = policy.speedtest_due(s, by_uplink.wan, { generation = 1, now = 20 }) + eq(due, true) + eq(reason, 'due') end return tests diff --git a/tests/unit/tools/lua_indentation_spec.lua b/tests/unit/tools/lua_indentation_spec.lua new file mode 100644 index 00000000..5602327e --- /dev/null +++ b/tests/unit/tools/lua_indentation_spec.lua @@ -0,0 +1,52 @@ +local indentation = require 'tools.lua_indentation' + +local T = {} + +local function eq(got, expected) + assert(got == expected, ('expected %s, got %s'):format(tostring(expected), tostring(got))) +end + +function T.accepts_tabs_and_alignment_spaces() + local content = table.concat({ + 'local function example()', + '\tlocal value = {', + '\t\tkey = true,', + '\t}', + '\t return value', + 'end', + }, '\n') + + eq(#indentation.check_content(content), 0) +end + +function T.ignores_multiline_string_and_comment_payloads() + local content = table.concat({ + 'local json = [=[', + ' { "nested": true }', + ' ]=]', + '--[==[', + ' comment payload', + ' ]==]', + 'local quoted = "continued\\', + ' string"', + '\treturn json .. quoted', + }, '\n') + + eq(#indentation.check_content(content), 0) +end + +function T.rejects_space_indentation_and_space_before_tab() + local content = table.concat({ + 'local function example()', + ' local first = true', + '\t \tlocal second = true', + 'end', + }, '\n') + local violations = indentation.check_content(content) + + eq(#violations, 2) + eq(violations[1].line, 2) + eq(violations[2].line, 3) +end + +return T diff --git a/tests/unit/update/test_active_runtime.lua b/tests/unit/update/test_active_runtime.lua index e45dd45f..e68e943e 100644 --- a/tests/unit/update/test_active_runtime.lua +++ b/tests/unit/update/test_active_runtime.lua @@ -10,224 +10,224 @@ local function assert_nil(v,msg) if v ~= nil then fail(msg or ('expected nil, go local function assert_not_nil(v,msg) if v == nil then fail(msg or 'expected non-nil') end end local function stage_backend(result) - return { stage_op = function () return op.always(result or { ok = true }, nil) end } + return { stage_op = function () return op.always(result or { ok = true }, nil) end } end local function transition_handle(result) - return { outcome_op = function () return op.always(result or { status = 'persisted' }, nil) end } + return { outcome_op = function () return op.always(result or { status = 'persisted' }, nil) end } end function tests.test_claim_rejects_busy_and_completion_releases_slot() - fibers.run(function (scope) - local state = active.new_state(); local done_tx, done_rx = mailbox.new(8, { full='reject_newest' }) - local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) - local second, err = active.claim(state, { job_id='j2', generation=1, phase='stage' }); assert_nil(second); assert_eq(err, 'slot_busy') - local handle = assert(active.start_work(scope, state, { lease=lease, done_tx=done_tx, job={ job_id='j1', component='cm5' }, backend=stage_backend({ ok=true }) })) - assert_not_nil(handle) - local ev = fibers.perform(done_rx:recv_op()); assert_eq(ev.kind, 'active_job_done'); assert_eq(ev.status, 'ok') - assert_true(active.apply_completion(state, ev)); assert_not_nil(state.active); assert_eq(state.active.status, 'completed_pending_persist') - local busy, busy_err = active.claim(state, { job_id='j2', generation=1, phase='stage' }); assert_nil(busy); assert_eq(busy_err, 'slot_busy') - assert_true(active.release_completed(state, ev.token)); assert_nil(state.active) - assert_not_nil(active.claim(state, { job_id='j2', generation=1, phase='stage' })) - end) + fibers.run(function (scope) + local state = active.new_state(); local done_tx, done_rx = mailbox.new(8, { full='reject_newest' }) + local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) + local second, err = active.claim(state, { job_id='j2', generation=1, phase='stage' }); assert_nil(second); assert_eq(err, 'slot_busy') + local handle = assert(active.start_work(scope, state, { lease=lease, done_tx=done_tx, job={ job_id='j1', component='cm5' }, backend=stage_backend({ ok=true }) })) + assert_not_nil(handle) + local ev = fibers.perform(done_rx:recv_op()); assert_eq(ev.kind, 'active_job_done'); assert_eq(ev.status, 'ok') + assert_true(active.apply_completion(state, ev)); assert_not_nil(state.active); assert_eq(state.active.status, 'completed_pending_persist') + local busy, busy_err = active.claim(state, { job_id='j2', generation=1, phase='stage' }); assert_nil(busy); assert_eq(busy_err, 'slot_busy') + assert_true(active.release_completed(state, ev.token)); assert_nil(state.active) + assert_not_nil(active.claim(state, { job_id='j2', generation=1, phase='stage' })) + end) end function tests.test_lease_release_clears_unstarted_slot_only() - local state = active.new_state() - local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) - local ok = assert(lease:release('never_started')) - assert_eq(ok, true) - assert_nil(state.active) - assert_eq(state.stats.released, 1) - assert_not_nil(active.claim(state, { job_id='j2', generation=1, phase='stage' })) + local state = active.new_state() + local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) + local ok = assert(lease:release('never_started')) + assert_eq(ok, true) + assert_nil(state.active) + assert_eq(state.stats.released, 1) + assert_not_nil(active.claim(state, { job_id='j2', generation=1, phase='stage' })) end function tests.test_handed_off_lease_cannot_release_running_slot() - local state = active.new_state() - local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) - assert_true(lease:handoff()) - local ok, err = lease:release('too_late') - assert_eq(ok, false) - assert_eq(err, 'transferred') - assert_not_nil(state.active) + local state = active.new_state() + local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) + assert_true(lease:handoff()) + local ok, err = lease:release('too_late') + assert_eq(ok, false) + assert_eq(err, 'transferred') + assert_not_nil(state.active) end function tests.test_stale_completion_does_not_release_current_slot() - local state = active.new_state(); assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) - local ok, err = active.apply_completion(state, { kind='active_job_done', job_id='j1', generation=1, phase='stage', token='wrong', status='ok', result={ tag='staged' } }) - assert_eq(ok, false); assert_eq(err, 'stale'); assert_not_nil(state.active); assert_eq(state.stats.stale, 1) + local state = active.new_state(); assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) + local ok, err = active.apply_completion(state, { kind='active_job_done', job_id='j1', generation=1, phase='stage', token='wrong', status='ok', result={ tag='staged' } }) + assert_eq(ok, false); assert_eq(err, 'stale'); assert_not_nil(state.active); assert_eq(state.stats.stale, 1) end function tests.test_local_observer_is_notified_after_authoritative_completion_admission() - fibers.run(function (scope) - local state = active.new_state() - local done_tx, done_rx = mailbox.new(8, { full='reject_newest' }) - local observer_scope = assert(scope:child()) - local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) - local handle = assert(active.start_work(scope, state, { - lease = lease, - done_tx = done_tx, - local_observer_scope = observer_scope, - job={ job_id='j1', component='cm5' }, - backend=stage_backend({ ok=true }), - })) - - local authoritative = fibers.perform(done_rx:recv_op()) - assert_eq(authoritative.kind, 'active_job_done') - assert_eq(authoritative.status, 'ok') - - local observed = fibers.perform(handle:outcome_op()) - assert_eq(observed.kind, 'active_job_done') - assert_eq(observed.token, authoritative.token) - end) + fibers.run(function (scope) + local state = active.new_state() + local done_tx, done_rx = mailbox.new(8, { full='reject_newest' }) + local observer_scope = assert(scope:child()) + local lease = assert(active.claim(state, { job_id='j1', generation=1, phase='stage' })) + local handle = assert(active.start_work(scope, state, { + lease = lease, + done_tx = done_tx, + local_observer_scope = observer_scope, + job={ job_id='j1', component='cm5' }, + backend=stage_backend({ ok=true }), + })) + + local authoritative = fibers.perform(done_rx:recv_op()) + assert_eq(authoritative.kind, 'active_job_done') + assert_eq(authoritative.status, 'ok') + + local observed = fibers.perform(handle:outcome_op()) + assert_eq(observed.kind, 'active_job_done') + assert_eq(observed.token, authoritative.token) + end) end function tests.test_component_stores_completion_before_reporting_to_service() - fibers.run(function (scope) - local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) - local fake_jobs = { admit_transition = function () return { outcome_op = function () return fibers.never() end }, nil end } - local component = assert(active.start_component(scope, { - service_id = 'update', - done_tx = service_tx, - work_scope = scope, - jobs = fake_jobs, - })) - local lease = assert(component:claim({ job_id='j1', generation=1, phase='stage' })) - assert(component:start_work({ - lease = lease, - job={ job_id='j1', component='cm5' }, - backend=stage_backend({ ok=true }), - })) - local ev = fibers.perform(service_rx:recv_op()) - assert_eq(ev.kind, 'active_runtime_changed') - assert_eq(ev.reason, 'active_job_completed') - assert_not_nil(active.completion(component:state(), ev.token), 'completion should be stored before report') - assert_not_nil(component:state().active) - assert_eq(component:state().active.status, 'completed_pending_persist') - component:release_completed(ev.token, 'test persisted') - assert_nil(component:state().active) - component:cancel('test complete') - end) + fibers.run(function (scope) + local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) + local fake_jobs = { admit_transition = function () return { outcome_op = function () return fibers.never() end }, nil end } + local component = assert(active.start_component(scope, { + service_id = 'update', + done_tx = service_tx, + work_scope = scope, + jobs = fake_jobs, + })) + local lease = assert(component:claim({ job_id='j1', generation=1, phase='stage' })) + assert(component:start_work({ + lease = lease, + job={ job_id='j1', component='cm5' }, + backend=stage_backend({ ok=true }), + })) + local ev = fibers.perform(service_rx:recv_op()) + assert_eq(ev.kind, 'active_runtime_changed') + assert_eq(ev.reason, 'active_job_completed') + assert_not_nil(active.completion(component:state(), ev.token), 'completion should be stored before report') + assert_not_nil(component:state().active) + assert_eq(component:state().active.status, 'completed_pending_persist') + component:release_completed(ev.token, 'test persisted') + assert_nil(component:state().active) + component:cancel('test complete') + end) end function tests.test_component_apply_start_failure_keeps_stored_completion_and_fails_component() - fibers.run(function (scope) - local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) - local component = assert(active.start_component(scope, { - service_id = 'update', - done_tx = service_tx, - work_scope = scope, - jobs = {}, - })) - - local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) - assert(component:start_work({ - lease = lease, - job={ job_id='j1', component='cm5' }, - backend=stage_backend({ ok=true }), - })) - - local completed = fibers.perform(service_rx:recv_op()) - assert_eq(completed.kind, 'active_runtime_changed') - assert_eq(completed.reason, 'active_job_completed') - assert_not_nil(active.completion(component:state(), completed.token), 'completion should be stored before apply starts') - assert_not_nil(component:state().active) - assert_eq(component:state().active.status, 'completed_pending_persist') - - local failed = fibers.perform(service_rx:recv_op()) - assert_eq(failed.kind, 'component_done') - assert_eq(failed.component, 'active_runtime') - assert_eq(failed.status, 'failed') - assert_eq(failed.primary, 'job_runtime_unavailable') - assert_not_nil(active.completion(component:state(), completed.token), 'stored completion should remain accounted for') - assert_not_nil(component:state().active, 'slot should not be silently released when apply cannot start') - end) + fibers.run(function (scope) + local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) + local component = assert(active.start_component(scope, { + service_id = 'update', + done_tx = service_tx, + work_scope = scope, + jobs = {}, + })) + + local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) + assert(component:start_work({ + lease = lease, + job={ job_id='j1', component='cm5' }, + backend=stage_backend({ ok=true }), + })) + + local completed = fibers.perform(service_rx:recv_op()) + assert_eq(completed.kind, 'active_runtime_changed') + assert_eq(completed.reason, 'active_job_completed') + assert_not_nil(active.completion(component:state(), completed.token), 'completion should be stored before apply starts') + assert_not_nil(component:state().active) + assert_eq(component:state().active.status, 'completed_pending_persist') + + local failed = fibers.perform(service_rx:recv_op()) + assert_eq(failed.kind, 'component_done') + assert_eq(failed.component, 'active_runtime') + assert_eq(failed.status, 'failed') + assert_eq(failed.primary, 'job_runtime_unavailable') + assert_not_nil(active.completion(component:state(), completed.token), 'stored completion should remain accounted for') + assert_not_nil(component:state().active, 'slot should not be silently released when apply cannot start') + end) end function tests.test_component_apply_failure_after_start_keeps_completion_and_reports_failure() - fibers.run(function (scope) - local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) - local fake_jobs = { - admit_transition = function () - return transition_handle({ status = 'failed', reason = 'save_failed' }), nil - end, - } - local component = assert(active.start_component(scope, { - service_id = 'update', - done_tx = service_tx, - work_scope = scope, - jobs = fake_jobs, - })) - - local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) - assert(component:start_work({ - lease = lease, - job={ job_id='j1', component='cm5' }, - backend=stage_backend({ ok=true }), - })) - - local completed = fibers.perform(service_rx:recv_op()) - assert_eq(completed.kind, 'active_runtime_changed') - assert_eq(completed.reason, 'active_job_completed') - assert_not_nil(active.completion(component:state(), completed.token), 'completion should be stored before durable apply') - - local apply_failed = fibers.perform(service_rx:recv_op()) - assert_eq(apply_failed.kind, 'active_runtime_changed') - assert_eq(apply_failed.reason, 'active_job_apply_failed') - assert_eq(apply_failed.error, 'save_failed') - assert_eq(component:state().active, nil, 'failed durable apply policy should release completed slot explicitly') - assert_not_nil(active.completion(component:state(), completed.token), 'stored completion should remain available after apply failure') - - local failed = fibers.perform(service_rx:recv_op()) - assert_eq(failed.kind, 'component_done') - assert_eq(failed.component, 'active_runtime') - assert_eq(failed.status, 'failed') - assert_eq(failed.primary, 'save_failed') - end) + fibers.run(function (scope) + local service_tx, service_rx = mailbox.new(8, { full = 'reject_newest' }) + local fake_jobs = { + admit_transition = function () + return transition_handle({ status = 'failed', reason = 'save_failed' }), nil + end, + } + local component = assert(active.start_component(scope, { + service_id = 'update', + done_tx = service_tx, + work_scope = scope, + jobs = fake_jobs, + })) + + local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) + assert(component:start_work({ + lease = lease, + job={ job_id='j1', component='cm5' }, + backend=stage_backend({ ok=true }), + })) + + local completed = fibers.perform(service_rx:recv_op()) + assert_eq(completed.kind, 'active_runtime_changed') + assert_eq(completed.reason, 'active_job_completed') + assert_not_nil(active.completion(component:state(), completed.token), 'completion should be stored before durable apply') + + local apply_failed = fibers.perform(service_rx:recv_op()) + assert_eq(apply_failed.kind, 'active_runtime_changed') + assert_eq(apply_failed.reason, 'active_job_apply_failed') + assert_eq(apply_failed.error, 'save_failed') + assert_eq(component:state().active, nil, 'failed durable apply policy should release completed slot explicitly') + assert_not_nil(active.completion(component:state(), completed.token), 'stored completion should remain available after apply failure') + + local failed = fibers.perform(service_rx:recv_op()) + assert_eq(failed.kind, 'component_done') + assert_eq(failed.component, 'active_runtime') + assert_eq(failed.status, 'failed') + assert_eq(failed.primary, 'save_failed') + end) end function tests.test_component_auto_commit_policy_admits_commit_after_stage_apply() - fibers.run(function (scope) - local service_tx, service_rx = mailbox.new(16, { full = 'reject_newest' }) - local seen = {} - local fake_jobs = { - admit_transition = function (_, cmd) - seen[#seen + 1] = cmd - if cmd.kind == 'apply_active_result' then - return transition_handle({ - status = 'persisted', - job = { job_id = 'j1', component = 'cm5', generation = 1, state = 'awaiting_commit', policy = { commit = 'auto' } }, - }), nil - elseif cmd.kind == 'start_job' then - return transition_handle({ status = 'persisted', job_id = cmd.job_id, phase = cmd.phase, token = 'commit-token', job = { job_id = cmd.job_id, component = 'cm5', state = 'committing' } }), nil - end - return transition_handle({ status = 'rejected', reason = 'unexpected' }), nil - end, - list = function () return {} end, - } - local component = assert(active.start_component(scope, { - service_id = 'update', - done_tx = service_tx, - work_scope = scope, - jobs = fake_jobs, - })) - local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) - assert(component:start_work({ - lease = lease, - job = { job_id='j1', component='cm5', generation = 1, policy = { commit = 'auto' } }, - backend = stage_backend({ ok=true }), - })) - local completed = fibers.perform(service_rx:recv_op()) - assert_eq(completed.reason, 'active_job_completed') - local applied = fibers.perform(service_rx:recv_op()) - assert_eq(applied.reason, 'active_job_applied') - local auto = fibers.perform(service_rx:recv_op()) - assert_eq(auto.reason, 'policy_auto_commit_started') - assert_eq(seen[1].kind, 'apply_active_result') - assert_eq(seen[2].kind, 'start_job') - assert_eq(seen[2].phase, 'commit') - assert_eq(seen[2].reason, 'policy_auto_commit') - component:cancel('test complete') - end) + fibers.run(function (scope) + local service_tx, service_rx = mailbox.new(16, { full = 'reject_newest' }) + local seen = {} + local fake_jobs = { + admit_transition = function (_, cmd) + seen[#seen + 1] = cmd + if cmd.kind == 'apply_active_result' then + return transition_handle({ + status = 'persisted', + job = { job_id = 'j1', component = 'cm5', generation = 1, state = 'awaiting_commit', policy = { commit = 'auto' } }, + }), nil + elseif cmd.kind == 'start_job' then + return transition_handle({ status = 'persisted', job_id = cmd.job_id, phase = cmd.phase, token = 'commit-token', job = { job_id = cmd.job_id, component = 'cm5', state = 'committing' } }), nil + end + return transition_handle({ status = 'rejected', reason = 'unexpected' }), nil + end, + list = function () return {} end, + } + local component = assert(active.start_component(scope, { + service_id = 'update', + done_tx = service_tx, + work_scope = scope, + jobs = fake_jobs, + })) + local lease = assert(component:claim({ job_id = 'j1', generation = 1, phase = 'stage' })) + assert(component:start_work({ + lease = lease, + job = { job_id='j1', component='cm5', generation = 1, policy = { commit = 'auto' } }, + backend = stage_backend({ ok=true }), + })) + local completed = fibers.perform(service_rx:recv_op()) + assert_eq(completed.reason, 'active_job_completed') + local applied = fibers.perform(service_rx:recv_op()) + assert_eq(applied.reason, 'active_job_applied') + local auto = fibers.perform(service_rx:recv_op()) + assert_eq(auto.reason, 'policy_auto_commit_started') + assert_eq(seen[1].kind, 'apply_active_result') + assert_eq(seen[2].kind, 'start_job') + assert_eq(seen[2].phase, 'commit') + assert_eq(seen[2].reason, 'policy_auto_commit') + component:cancel('test complete') + end) end return tests diff --git a/tests/unit/update/test_artifact_store_update_adapters.lua b/tests/unit/update/test_artifact_store_update_adapters.lua index 2d96b3d2..ae0eb37d 100644 --- a/tests/unit/update/test_artifact_store_update_adapters.lua +++ b/tests/unit/update/test_artifact_store_update_adapters.lua @@ -8,156 +8,156 @@ local component_backend = require 'services.update.backends.component' local T = {} local function assert_eq(a, b, msg) - if a ~= b then error(msg or ('expected ' .. tostring(b) .. ', got ' .. tostring(a)), 2) end + if a ~= b then error(msg or ('expected ' .. tostring(b) .. ', got ' .. tostring(a)), 2) end end local function assert_true(v, msg) - if v ~= true then error(msg or ('expected true, got ' .. tostring(v)), 2) end + if v ~= true then error(msg or ('expected true, got ' .. tostring(v)), 2) end end function T.artifact_store_bus_unwraps_hal_reply_envelopes() - runfibers.run(function() - local sink = { terminated = 0 } - function sink:append_op(_) return op.always(true, nil) end - function sink:commit_op() return op.always({ ref = 'artifact-1' }, nil) end - function sink:terminate(reason) self.terminated = self.terminated + 1; self.reason = reason; return true, nil end - - local source = { read_count = 0 } - function source:read_chunk_op() self.read_count = self.read_count + 1; return op.always(nil, nil) end - - local artifact = {} - function artifact:describe() return { artifact_ref = 'artifact-1', size = 10 } end - function artifact:open_source_op() return op.always(true, source) end - - local conn = { - call_op = function(_, topic, payload) - local method = topic[5] - if method == 'create-sink' then - assert_eq(payload.policy, 'prefer_durable') - return op.always({ ok = true, reason = sink }, nil) - elseif method == 'open' then - assert_eq(payload.artifact_ref, 'artifact-1') - return op.always({ ok = true, reason = artifact }, nil) - elseif method == 'delete' then - return op.always({ ok = true, reason = nil }, nil) - elseif method == 'status' then - return op.always({ ok = true, reason = { available = true } }, nil) - end - return op.always({ ok = false, reason = 'bad method' }, nil) - end, - } - - local store = store_bus.new(conn) - local got_sink, sink_err = fibers.perform(store:create_sink_op({ meta = { component = 'mcu' }, policy = 'prefer_durable' })) - assert_eq(got_sink, sink, tostring(sink_err)) - - local got_source, source_err = fibers.perform(store:open_source_op('artifact-1')) - assert_eq(got_source, source, tostring(source_err)) - - local ok_delete, del_err = fibers.perform(store:delete_op('artifact-1')) - assert_true(ok_delete, tostring(del_err)) - - local status, st_err = fibers.perform(store:status_op()) - assert_true(status and status.available, tostring(st_err)) - end) + runfibers.run(function() + local sink = { terminated = 0 } + function sink:append_op(_) return op.always(true, nil) end + function sink:commit_op() return op.always({ ref = 'artifact-1' }, nil) end + function sink:terminate(reason) self.terminated = self.terminated + 1; self.reason = reason; return true, nil end + + local source = { read_count = 0 } + function source:read_chunk_op() self.read_count = self.read_count + 1; return op.always(nil, nil) end + + local artifact = {} + function artifact:describe() return { artifact_ref = 'artifact-1', size = 10 } end + function artifact:open_source_op() return op.always(true, source) end + + local conn = { + call_op = function(_, topic, payload) + local method = topic[5] + if method == 'create-sink' then + assert_eq(payload.policy, 'prefer_durable') + return op.always({ ok = true, reason = sink }, nil) + elseif method == 'open' then + assert_eq(payload.artifact_ref, 'artifact-1') + return op.always({ ok = true, reason = artifact }, nil) + elseif method == 'delete' then + return op.always({ ok = true, reason = nil }, nil) + elseif method == 'status' then + return op.always({ ok = true, reason = { available = true } }, nil) + end + return op.always({ ok = false, reason = 'bad method' }, nil) + end, + } + + local store = store_bus.new(conn) + local got_sink, sink_err = fibers.perform(store:create_sink_op({ meta = { component = 'mcu' }, policy = 'prefer_durable' })) + assert_eq(got_sink, sink, tostring(sink_err)) + + local got_source, source_err = fibers.perform(store:open_source_op('artifact-1')) + assert_eq(got_source, source, tostring(source_err)) + + local ok_delete, del_err = fibers.perform(store:delete_op('artifact-1')) + assert_true(ok_delete, tostring(del_err)) + + local status, st_err = fibers.perform(store:status_op()) + assert_true(status and status.available, tostring(st_err)) + end) end function T.component_backend_commit_timeout_is_uncertain() - runfibers.run(function() - local conn = { - call_op = function(_, topic, payload) - assert_eq(topic[5], 'commit-update') - assert_eq(payload.commit_token, 'tok-1') - return op.always(nil, 'timeout') - end, - } - local backend = component_backend.new({ conn = conn, component = 'mcu', rpc_retry = { commit_attempts = 1 } }) - local result, err = fibers.perform(backend:commit_op({ job_id = 'job-1', component = 'mcu', expected_image_id = 'img-new' }, { commit_token = 'tok-1' })) - assert_eq(err, nil) - assert_true(result and result.accepted, 'timeout commit should be accepted as uncertain') - assert_true(result.uncertain, 'timeout commit should be marked uncertain') - end) + runfibers.run(function() + local conn = { + call_op = function(_, topic, payload) + assert_eq(topic[5], 'commit-update') + assert_eq(payload.commit_token, 'tok-1') + return op.always(nil, 'timeout') + end, + } + local backend = component_backend.new({ conn = conn, component = 'mcu', rpc_retry = { commit_attempts = 1 } }) + local result, err = fibers.perform(backend:commit_op({ job_id = 'job-1', component = 'mcu', expected_image_id = 'img-new' }, { commit_token = 'tok-1' })) + assert_eq(err, nil) + assert_true(result and result.accepted, 'timeout commit should be accepted as uncertain') + assert_true(result.uncertain, 'timeout commit should be marked uncertain') + end) end function T.component_backend_commit_link_not_ready_is_not_uncertain() - runfibers.run(function() - local conn = { - call_op = function(_, topic, payload) - assert_eq(topic[5], 'commit-update') - return op.always(nil, 'link_not_ready') - end, - } - local backend = component_backend.new({ conn = conn, component = 'mcu', rpc_retry = { commit_attempts = 1 } }) - local result, err = fibers.perform(backend:commit_op({ job_id = 'job-1', component = 'mcu', expected_image_id = 'img-new' }, { commit_token = 'tok-1' })) - assert_eq(result, nil) - assert_eq(err, 'link_not_ready') - end) + runfibers.run(function() + local conn = { + call_op = function(_, topic, payload) + assert_eq(topic[5], 'commit-update') + return op.always(nil, 'link_not_ready') + end, + } + local backend = component_backend.new({ conn = conn, component = 'mcu', rpc_retry = { commit_attempts = 1 } }) + local result, err = fibers.perform(backend:commit_op({ job_id = 'job-1', component = 'mcu', expected_image_id = 'img-new' }, { commit_token = 'tok-1' })) + assert_eq(result, nil) + assert_eq(err, 'link_not_ready') + end) end function T.component_backend_stage_op_runs_preflight_prepare_and_stage() - runfibers.run(function() - local source = {} - function source:read_chunk_op() return op.always(nil, nil) end - - local artifact = {} - function artifact:describe() - return { - artifact_ref = 'artifact-1', - size = 12, - digest_alg = 'xxhash32', - digest = 'abcd', - meta = { image_id = 'img-new', format = 'dcmcu-v1' }, - } - end - function artifact:open_source_op() - -- Exercise the direct source,err adapter shape as well as the HAL true,source shape. - return op.always(source, nil) - end - - local artifact_store = { - open_op = function(_, ref) - assert_eq(ref, 'artifact-1') - return op.always(artifact, nil) - end, - open_source_op = function(_, ref) - assert_eq(ref, 'artifact-1') - return op.always(source, nil) - end, - } - - local seen_payload - local seen_prepare - local conn = { - call_op = function(_, topic, payload) - assert_eq(topic[1], 'cap') - assert_eq(topic[2], 'component') - assert_eq(topic[4], 'rpc') - if topic[5] == 'prepare-update' then - seen_prepare = payload - assert_eq(payload.target, 'mcu') - return op.always({ ok = true }, nil) - end - if topic[5] == 'stage-update' then - seen_payload = payload - return op.always({ ok = true, public_status = 'succeeded', value = { transferred = true } }, nil) - end - return op.always({ ok = true }, nil) - end, - } - - local backend = component_backend.new({ conn = conn, artifact_store = artifact_store, component = 'mcu' }) - local job = { job_id = 'job-1', component = 'mcu', artifact_ref = 'artifact-1', expected_image_id = 'img-new', metadata = { format = 'dcmcu-v1' } } - - local staged, serr = fibers.perform(backend:stage_op(job, {})) - assert_eq(type(staged), 'table', tostring(serr)) - assert_true(staged.staged) - assert_eq(seen_prepare.target, 'mcu') - assert_eq(staged.preflight.size, 12) - assert_eq(staged.transfer.size, 12) - assert_eq(seen_payload.source, source) - assert_eq(seen_payload.size, 12) - assert_eq(seen_payload.digest, 'abcd') - end) + runfibers.run(function() + local source = {} + function source:read_chunk_op() return op.always(nil, nil) end + + local artifact = {} + function artifact:describe() + return { + artifact_ref = 'artifact-1', + size = 12, + digest_alg = 'xxhash32', + digest = 'abcd', + meta = { image_id = 'img-new', format = 'dcmcu-v1' }, + } + end + function artifact:open_source_op() + -- Exercise the direct source,err adapter shape as well as the HAL true,source shape. + return op.always(source, nil) + end + + local artifact_store = { + open_op = function(_, ref) + assert_eq(ref, 'artifact-1') + return op.always(artifact, nil) + end, + open_source_op = function(_, ref) + assert_eq(ref, 'artifact-1') + return op.always(source, nil) + end, + } + + local seen_payload + local seen_prepare + local conn = { + call_op = function(_, topic, payload) + assert_eq(topic[1], 'cap') + assert_eq(topic[2], 'component') + assert_eq(topic[4], 'rpc') + if topic[5] == 'prepare-update' then + seen_prepare = payload + assert_eq(payload.target, 'mcu') + return op.always({ ok = true }, nil) + end + if topic[5] == 'stage-update' then + seen_payload = payload + return op.always({ ok = true, public_status = 'succeeded', value = { transferred = true } }, nil) + end + return op.always({ ok = true }, nil) + end, + } + + local backend = component_backend.new({ conn = conn, artifact_store = artifact_store, component = 'mcu' }) + local job = { job_id = 'job-1', component = 'mcu', artifact_ref = 'artifact-1', expected_image_id = 'img-new', metadata = { format = 'dcmcu-v1' } } + + local staged, serr = fibers.perform(backend:stage_op(job, {})) + assert_eq(type(staged), 'table', tostring(serr)) + assert_true(staged.staged) + assert_eq(seen_prepare.target, 'mcu') + assert_eq(staged.preflight.size, 12) + assert_eq(staged.transfer.size, 12) + assert_eq(seen_payload.source, source) + assert_eq(seen_payload.size, 12) + assert_eq(seen_payload.digest, 'abcd') + end) end return T diff --git a/tests/unit/update/test_job_repository.lua b/tests/unit/update/test_job_repository.lua index c8f13ebf..b8807536 100644 --- a/tests/unit/update/test_job_repository.lua +++ b/tests/unit/update/test_job_repository.lua @@ -5,88 +5,88 @@ local function assert_eq(a,b,msg) if a ~= b then fail(msg or ('expected '..tostr local function assert_true(v,msg) if v ~= true then fail(msg or ('expected true, got '..tostring(v))) end end local function assert_not_nil(v,msg) if v == nil then fail(msg or 'expected non-nil') end end function tests.test_new_job_normalises_and_snapshots_are_copies() - local state = assert(repo.new_state()) - local job = assert(repo.new_job({ job_id='j1', component='cm5', artifact_ref='a1' }, { generation=7, seq=1 })) - assert(repo.upsert(state, job)) - assert_eq(job.phase, nil) - assert_eq(job.stage, nil) - local snap = repo.snapshot(state) - assert_eq(snap.count, 1); assert_eq(snap.by_id.j1.component, 'cm5') - assert_eq(snap.by_id.j1.phase, nil) - assert_eq(snap.by_id.j1.stage, nil) - snap.by_id.j1.component = 'mutated' - assert_eq(repo.get(state, 'j1').component, 'cm5') + local state = assert(repo.new_state()) + local job = assert(repo.new_job({ job_id='j1', component='cm5', artifact_ref='a1' }, { generation=7, seq=1 })) + assert(repo.upsert(state, job)) + assert_eq(job.phase, nil) + assert_eq(job.stage, nil) + local snap = repo.snapshot(state) + assert_eq(snap.count, 1); assert_eq(snap.by_id.j1.component, 'cm5') + assert_eq(snap.by_id.j1.phase, nil) + assert_eq(snap.by_id.j1.stage, nil) + snap.by_id.j1.component = 'mutated' + assert_eq(repo.get(state, 'j1').component, 'cm5') end function tests.test_job_repository_rejects_phase_and_stage_aliases() - local state = assert(repo.new_state()) - local job, err = repo.new_job({ job_id='j1', component='cm5' }, { seq=repo.next_sequence(state) }) - assert_not_nil(job, err) - job.phase = 'created' - local ok_job, jerr = repo.upsert(state, job) - assert_eq(ok_job, nil) - assert_not_nil(jerr) + local state = assert(repo.new_state()) + local job, err = repo.new_job({ job_id='j1', component='cm5' }, { seq=repo.next_sequence(state) }) + assert_not_nil(job, err) + job.phase = 'created' + local ok_job, jerr = repo.upsert(state, job) + assert_eq(ok_job, nil) + assert_not_nil(jerr) - job.phase = nil - assert(repo.upsert(state, job)) - local _, perr = repo.patch(state.jobs.j1, { phase = 'stage' }, { seq=repo.next_sequence(state) }) - assert_not_nil(perr) + job.phase = nil + assert(repo.upsert(state, job)) + local _, perr = repo.patch(state.jobs.j1, { phase = 'stage' }, { seq=repo.next_sequence(state) }) + assert_not_nil(perr) end function tests.test_lifecycle_helpers_do_not_perform_work() - local state = assert(repo.new_state()) - local job = assert(repo.new_job({ job_id='j1', component='cm5' }, { seq=repo.next_sequence(state) })) - repo.upsert(state, job); local stored = state.jobs.j1 - repo.mark_staging(stored, { seq=repo.next_sequence(state), reason='start' }); assert_eq(stored.state, 'staging'); assert_eq(stored.phase, nil); assert_eq(stored.stage, nil) - repo.mark_awaiting_commit(stored, { image='ok' }, { seq=repo.next_sequence(state) }); assert_eq(stored.state, 'awaiting_commit'); assert_eq(stored.next_step, 'commit'); assert_eq(stored.phase, nil); assert_eq(stored.stage, nil) - repo.mark_terminal(stored, 'failed', 'boom', nil, { seq=repo.next_sequence(state) }); assert_true(repo.is_terminal(stored.state)); assert_not_nil(stored.last_event) + local state = assert(repo.new_state()) + local job = assert(repo.new_job({ job_id='j1', component='cm5' }, { seq=repo.next_sequence(state) })) + repo.upsert(state, job); local stored = state.jobs.j1 + repo.mark_staging(stored, { seq=repo.next_sequence(state), reason='start' }); assert_eq(stored.state, 'staging'); assert_eq(stored.phase, nil); assert_eq(stored.stage, nil) + repo.mark_awaiting_commit(stored, { image='ok' }, { seq=repo.next_sequence(state) }); assert_eq(stored.state, 'awaiting_commit'); assert_eq(stored.next_step, 'commit'); assert_eq(stored.phase, nil); assert_eq(stored.stage, nil) + repo.mark_terminal(stored, 'failed', 'boom', nil, { seq=repo.next_sequence(state) }); assert_true(repo.is_terminal(stored.state)); assert_not_nil(stored.last_event) end function tests.test_terminal_compaction_drops_operational_internals() - local job = repo.compact_job({ - job_id='j1', component='cm5', state='succeeded', expected_image_id='img', - created_seq=1, updated_seq=2, next_step='commit', generation=9, - active_token='tok', active_intent={ token='tok', phase='stage' }, active={ token='tok', phase='stage' }, - adoption={ action='kept_committable' }, commit_attempt={ token='ct' }, - stage_result={ reply={ transfer={ xfer_id='x1', sent_bytes=12 } }, preflight={ metadata={ large=true } } }, - result={ ok=true, tag='ok' }, history={ { seq=2, state='succeeded', reason='done' } }, - }) - assert_eq(job.next_step, nil) - assert_eq(job.generation, nil) - assert_eq(job.active_token, nil) - assert_eq(job.active_intent, nil) - assert_eq(job.active, nil) - assert_eq(job.adoption, nil) - assert_not_nil(job.commit_attempt) - assert_eq(job.commit_attempt.token, 'ct') - assert_eq(job.stage_result, nil) - assert_not_nil(job.transfer) - assert_eq(job.transfer.xfer_id, 'x1') - assert_eq(job.result.ok, true) - assert_eq(job.last_event.reason, 'done') + local job = repo.compact_job({ + job_id='j1', component='cm5', state='succeeded', expected_image_id='img', + created_seq=1, updated_seq=2, next_step='commit', generation=9, + active_token='tok', active_intent={ token='tok', phase='stage' }, active={ token='tok', phase='stage' }, + adoption={ action='kept_committable' }, commit_attempt={ token='ct' }, + stage_result={ reply={ transfer={ xfer_id='x1', sent_bytes=12 } }, preflight={ metadata={ large=true } } }, + result={ ok=true, tag='ok' }, history={ { seq=2, state='succeeded', reason='done' } }, + }) + assert_eq(job.next_step, nil) + assert_eq(job.generation, nil) + assert_eq(job.active_token, nil) + assert_eq(job.active_intent, nil) + assert_eq(job.active, nil) + assert_eq(job.adoption, nil) + assert_not_nil(job.commit_attempt) + assert_eq(job.commit_attempt.token, 'ct') + assert_eq(job.stage_result, nil) + assert_not_nil(job.transfer) + assert_eq(job.transfer.xfer_id, 'x1') + assert_eq(job.result.ok, true) + assert_eq(job.last_event.reason, 'done') end function tests.test_active_compaction_preserves_policy_for_auto_commit() - local job = repo.compact_job({ - job_id='j1', component='mcu', state='awaiting_commit', expected_image_id='img', - created_seq=1, updated_seq=2, next_step='commit', generation=9, - policy={ - job_id='j1', create_if='image_differs', start='auto', commit='auto', - reconcile='required', supersede='same_job_if_image_changed', - }, - }) - assert_not_nil(job.policy) - assert_eq(job.policy.commit, 'auto') - assert_eq(job.policy.start, 'auto') - assert_eq(job.policy.reconcile, 'required') + local job = repo.compact_job({ + job_id='j1', component='mcu', state='awaiting_commit', expected_image_id='img', + created_seq=1, updated_seq=2, next_step='commit', generation=9, + policy={ + job_id='j1', create_if='image_differs', start='auto', commit='auto', + reconcile='required', supersede='same_job_if_image_changed', + }, + }) + assert_not_nil(job.policy) + assert_eq(job.policy.commit, 'auto') + assert_eq(job.policy.start, 'auto') + assert_eq(job.policy.reconcile, 'required') end function tests.test_new_job_preserves_policy_for_active_lifecycle() - local job = assert(repo.new_job({ - job_id='j1', component='mcu', expected_image_id='img', artifact_ref='a1', - policy={ start='auto', commit='auto', reconcile='required' }, - }, { seq=1 })) - assert_not_nil(job.policy) - assert_eq(job.policy.commit, 'auto') + local job = assert(repo.new_job({ + job_id='j1', component='mcu', expected_image_id='img', artifact_ref='a1', + policy={ start='auto', commit='auto', reconcile='required' }, + }, { seq=1 })) + assert_not_nil(job.policy) + assert_eq(job.policy.commit, 'auto') end return tests diff --git a/tests/unit/update/test_job_store_control_store.lua b/tests/unit/update/test_job_store_control_store.lua index ae703d9e..60601c35 100644 --- a/tests/unit/update/test_job_store_control_store.lua +++ b/tests/unit/update/test_job_store_control_store.lua @@ -5,118 +5,118 @@ local store_mod = require 'services.update.job_store_control_store' local T = {} local function fake_conn() - local data = {} - local calls = {} - return { - calls = calls, - data = data, - call_op = function(_, topic, payload) - calls[#calls + 1] = { topic = topic, payload = payload } - local method = topic[5] - if topic[1] ~= 'cap' or topic[2] ~= 'control-store' or topic[3] ~= 'update' or topic[4] ~= 'rpc' then - return require('fibers.op').always({ ok = false, reason = 'bad_topic' }, nil) - end - if method == 'list' then - local prefix = payload and payload.prefix or '' - local out = {} - for k in pairs(data) do - if prefix == '' or k:sub(1, #prefix) == prefix then out[#out + 1] = k end - end - table.sort(out) - return require('fibers.op').always({ ok = true, reason = out }, nil) - elseif method == 'get' then - if data[payload.key] == nil then return require('fibers.op').always({ ok = false, reason = 'not found' }, nil) end - return require('fibers.op').always({ ok = true, reason = data[payload.key] }, nil) - elseif method == 'put' then - data[payload.key] = payload.data - return require('fibers.op').always({ ok = true, reason = nil }, nil) - elseif method == 'delete' then - data[payload.key] = nil - return require('fibers.op').always({ ok = true, reason = nil }, nil) - end - return require('fibers.op').always({ ok = false, reason = 'bad_method' }, nil) - end, - } + local data = {} + local calls = {} + return { + calls = calls, + data = data, + call_op = function(_, topic, payload) + calls[#calls + 1] = { topic = topic, payload = payload } + local method = topic[5] + if topic[1] ~= 'cap' or topic[2] ~= 'control-store' or topic[3] ~= 'update' or topic[4] ~= 'rpc' then + return require('fibers.op').always({ ok = false, reason = 'bad_topic' }, nil) + end + if method == 'list' then + local prefix = payload and payload.prefix or '' + local out = {} + for k in pairs(data) do + if prefix == '' or k:sub(1, #prefix) == prefix then out[#out + 1] = k end + end + table.sort(out) + return require('fibers.op').always({ ok = true, reason = out }, nil) + elseif method == 'get' then + if data[payload.key] == nil then return require('fibers.op').always({ ok = false, reason = 'not found' }, nil) end + return require('fibers.op').always({ ok = true, reason = data[payload.key] }, nil) + elseif method == 'put' then + data[payload.key] = payload.data + return require('fibers.op').always({ ok = true, reason = nil }, nil) + elseif method == 'delete' then + data[payload.key] = nil + return require('fibers.op').always({ ok = true, reason = nil }, nil) + end + return require('fibers.op').always({ ok = false, reason = 'bad_method' }, nil) + end, + } end function T.save_load_and_delete_round_trip() - runfibers.run(function() - local conn = fake_conn() - local store = store_mod.new(conn) + runfibers.run(function() + local conn = fake_conn() + local store = store_mod.new(conn) - local ok_save, save_err = fibers.perform(store:save_job_op({ - job_id = 'job-1', - component = 'mcu', - expected_image_id = 'mcu-image-test', - state = 'created', - created_seq = 1, - updated_seq = 1, - history = {}, - })) - assert(ok_save == true, tostring(save_err)) - assert(conn.data['update-job-job-1'] ~= nil) + local ok_save, save_err = fibers.perform(store:save_job_op({ + job_id = 'job-1', + component = 'mcu', + expected_image_id = 'mcu-image-test', + state = 'created', + created_seq = 1, + updated_seq = 1, + history = {}, + })) + assert(ok_save == true, tostring(save_err)) + assert(conn.data['update-job-job-1'] ~= nil) - local snapshot, load_err = fibers.perform(store:load_all_op()) - assert(snapshot ~= nil, tostring(load_err)) - assert(snapshot.jobs['job-1'].component == 'mcu') - assert(snapshot.order[1] == 'job-1') + local snapshot, load_err = fibers.perform(store:load_all_op()) + assert(snapshot ~= nil, tostring(load_err)) + assert(snapshot.jobs['job-1'].component == 'mcu') + assert(snapshot.order[1] == 'job-1') - local ok_delete, delete_err = fibers.perform(store:delete_job_op('job-1')) - assert(ok_delete == true, tostring(delete_err)) - assert(conn.data['update-job-job-1'] == nil) - end) + local ok_delete, delete_err = fibers.perform(store:delete_job_op('job-1')) + assert(ok_delete == true, tostring(delete_err)) + assert(conn.data['update-job-job-1'] == nil) + end) end function T.uses_control_store_update_capability() - runfibers.run(function() - local conn = fake_conn() - local store = store_mod.new(conn) - local ok_save = fibers.perform(store:save_job_op({ job_id = 'job-2', component = 'mcu', expected_image_id = 'mcu-image-test' })) - assert(ok_save == true) - local t = conn.calls[1].topic - assert(t[1] == 'cap') - assert(t[2] == 'control-store') - assert(t[3] == 'update') - assert(t[4] == 'rpc') - assert(t[5] == 'put') - end) + runfibers.run(function() + local conn = fake_conn() + local store = store_mod.new(conn) + local ok_save = fibers.perform(store:save_job_op({ job_id = 'job-2', component = 'mcu', expected_image_id = 'mcu-image-test' })) + assert(ok_save == true) + local t = conn.calls[1].topic + assert(t[1] == 'cap') + assert(t[2] == 'control-store') + assert(t[3] == 'update') + assert(t[4] == 'rpc') + assert(t[5] == 'put') + end) end function T.accepts_hal_void_success_replies_for_put_and_delete() - runfibers.run(function() - local stored = {} - local conn = { - call_op = function(_, topic, payload) - local method = topic[5] - if method == 'put' then - stored[payload.key] = payload.data - return require('fibers.op').always({ ok = true, reason = nil }, nil) - elseif method == 'delete' then - stored[payload.key] = nil - return require('fibers.op').always({ ok = true, reason = nil }, nil) - elseif method == 'list' then - local out = {} - for key in pairs(stored) do out[#out + 1] = key end - table.sort(out) - return require('fibers.op').always({ ok = true, reason = out }, nil) - elseif method == 'get' then - return require('fibers.op').always({ ok = true, reason = stored[payload.key] }, nil) - end - return require('fibers.op').always({ ok = false, reason = 'bad method' }, nil) - end, - } + runfibers.run(function() + local stored = {} + local conn = { + call_op = function(_, topic, payload) + local method = topic[5] + if method == 'put' then + stored[payload.key] = payload.data + return require('fibers.op').always({ ok = true, reason = nil }, nil) + elseif method == 'delete' then + stored[payload.key] = nil + return require('fibers.op').always({ ok = true, reason = nil }, nil) + elseif method == 'list' then + local out = {} + for key in pairs(stored) do out[#out + 1] = key end + table.sort(out) + return require('fibers.op').always({ ok = true, reason = out }, nil) + elseif method == 'get' then + return require('fibers.op').always({ ok = true, reason = stored[payload.key] }, nil) + end + return require('fibers.op').always({ ok = false, reason = 'bad method' }, nil) + end, + } - local store = store_mod.new(conn) - local ok_save, save_err = fibers.perform(store:save_job_op({ job_id = 'job-hal', component = 'mcu', expected_image_id = 'mcu-image-test', state = 'created' })) - assert(ok_save == true, tostring(save_err)) + local store = store_mod.new(conn) + local ok_save, save_err = fibers.perform(store:save_job_op({ job_id = 'job-hal', component = 'mcu', expected_image_id = 'mcu-image-test', state = 'created' })) + assert(ok_save == true, tostring(save_err)) - local snapshot, load_err = fibers.perform(store:load_all_op()) - assert(snapshot ~= nil, tostring(load_err)) - assert(snapshot.jobs['job-hal'].component == 'mcu') + local snapshot, load_err = fibers.perform(store:load_all_op()) + assert(snapshot ~= nil, tostring(load_err)) + assert(snapshot.jobs['job-hal'].component == 'mcu') - local ok_delete, delete_err = fibers.perform(store:delete_job_op('job-hal')) - assert(ok_delete == true, tostring(delete_err)) - end) + local ok_delete, delete_err = fibers.perform(store:delete_job_op('job-hal')) + assert(ok_delete == true, tostring(delete_err)) + end) end return T diff --git a/tests/unit/update/test_job_store_memory.lua b/tests/unit/update/test_job_store_memory.lua index 02036fe5..b198957a 100644 --- a/tests/unit/update/test_job_store_memory.lua +++ b/tests/unit/update/test_job_store_memory.lua @@ -5,22 +5,22 @@ local function fail(msg) error(msg or 'assertion failed', 2) end local function assert_eq(a,b,msg) if a ~= b then fail(msg or ('expected '..tostring(b)..', got '..tostring(a))) end end local function assert_true(v,msg) if v ~= true then fail(msg or ('expected true, got '..tostring(v))) end end function tests.test_memory_store_exposes_operation_shaped_api() - fibers.run(function () - local store = store_mod.new() - assert_true(fibers.perform(store:save_job_op({ job_id='j1', component='cm5', state='created' }))) - local loaded = assert(fibers.perform(store:load_all_op())) - assert_eq(loaded.jobs.j1.component, 'cm5') - assert_true(fibers.perform(store:delete_job_op('j1'))) - loaded = assert(fibers.perform(store:load_all_op())) - assert_eq(loaded.jobs.j1, nil) - end) + fibers.run(function () + local store = store_mod.new() + assert_true(fibers.perform(store:save_job_op({ job_id='j1', component='cm5', state='created' }))) + local loaded = assert(fibers.perform(store:load_all_op())) + assert_eq(loaded.jobs.j1.component, 'cm5') + assert_true(fibers.perform(store:delete_job_op('j1'))) + loaded = assert(fibers.perform(store:load_all_op())) + assert_eq(loaded.jobs.j1, nil) + end) end function tests.test_store_snapshots_are_copied() - fibers.run(function () - local store = store_mod.new(); local job = { job_id='j1', component='cm5', nested={a=1} } - assert_true(fibers.perform(store:save_job_op(job))); job.nested.a = 99 - local loaded = assert(fibers.perform(store:load_all_op())) - assert_eq(loaded.jobs.j1.nested.a, 1) - end) + fibers.run(function () + local store = store_mod.new(); local job = { job_id='j1', component='cm5', nested={a=1} } + assert_true(fibers.perform(store:save_job_op(job))); job.nested.a = 99 + local loaded = assert(fibers.perform(store:load_all_op())) + assert_eq(loaded.jobs.j1.nested.a, 1) + end) end return tests diff --git a/tests/unit/update/test_manager_requests.lua b/tests/unit/update/test_manager_requests.lua index 8895e6bf..a4fc5246 100644 --- a/tests/unit/update/test_manager_requests.lua +++ b/tests/unit/update/test_manager_requests.lua @@ -13,272 +13,272 @@ local function assert_nil(v,msg) if v ~= nil then fail(msg or ('expected nil, go local function assert_not_nil(v,msg) if v == nil then fail(msg or 'expected non-nil') end end local function request(payload) - local c = cond.new(); local req = { payload=payload, done=false } - function req:reply(v) self.done=true; self.ok=true; self.value=v; c:signal(); return true end - function req:fail(e) self.done=true; self.ok=false; self.err=e; c:signal(); return true end - function req:wait_op() return op.guard(function() if self.done then return op.always(self.ok,self.value,self.err) end; return c:wait_op():wrap(function() return self.ok,self.value,self.err end) end) end - return req + local c = cond.new(); local req = { payload=payload, done=false } + function req:reply(v) self.done=true; self.ok=true; self.value=v; c:signal(); return true end + function req:fail(e) self.done=true; self.ok=false; self.err=e; c:signal(); return true end + function req:wait_op() return op.guard(function() if self.done then return op.always(self.ok,self.value,self.err) end; return c:wait_op():wrap(function() return self.ok,self.value,self.err end) end) end + return req end local function start_jobs(scope, store, initial) - local jobs = assert(job_runtime.start(scope, { - service_id = 'update', - store = store or store_mod.new(initial), - initial_jobs = initial, - })) - local ready, err = fibers.perform(jobs:ready_op()) - assert_true(ready, err) - return jobs + local jobs = assert(job_runtime.start(scope, { + service_id = 'update', + store = store or store_mod.new(initial), + initial_jobs = initial, + })) + local ready, err = fibers.perform(jobs:ready_op()) + assert_true(ready, err) + return jobs end local function initial_with(job) - return { jobs = { [job.job_id] = job }, order = { job.job_id }, next_seq = 10 } + return { jobs = { [job.job_id] = job }, order = { job.job_id }, next_seq = 10 } end function tests.test_create_job_scope_replies_and_returns_completion_fact() - fibers.run(function () - local req = request({ method='create_job', job_id='j1', component='cm5', artifact_ref='a1' }) - local st, rep, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store_mod.new()) - local out = manager_requests.create_job(scope, { request=req, jobs=jobs, config={ components={ cm5={component='cm5'} } }, generation=3 }) - jobs:cancel('test complete') - return out - end) - assert_eq(st, 'ok') - assert_eq(result.status, 'persisted') - assert_eq(result.tag, 'job_created') - local ok, value = fibers.perform(req:wait_op()); assert_true(ok); assert_eq(value.job.job_id, 'j1') - end) + fibers.run(function () + local req = request({ method='create_job', job_id='j1', component='cm5', artifact_ref='a1' }) + local st, rep, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store_mod.new()) + local out = manager_requests.create_job(scope, { request=req, jobs=jobs, config={ components={ cm5={component='cm5'} } }, generation=3 }) + jobs:cancel('test complete') + return out + end) + assert_eq(st, 'ok') + assert_eq(result.status, 'persisted') + assert_eq(result.tag, 'job_created') + local ok, value = fibers.perform(req:wait_op()); assert_true(ok); assert_eq(value.job.job_id, 'j1') + end) end function tests.test_create_job_rejects_unknown_component_without_throwing() - fibers.run(function () - local req = request({ method='create_job', job_id='j1', component='mcu' }) - local st, rep, result = fibers.run_scope(function (scope) return manager_requests.create_job(scope, { request=req, config={ components={ cm5={component='cm5'} } }, seq=1 }) end) - assert_eq(st, 'ok'); assert_eq(result.tag, 'manager_request_rejected') - local ok, value, err = fibers.perform(req:wait_op()); assert_eq(ok, false); assert_eq(err, 'unknown_component') - end) + fibers.run(function () + local req = request({ method='create_job', job_id='j1', component='mcu' }) + local st, rep, result = fibers.run_scope(function (scope) return manager_requests.create_job(scope, { request=req, config={ components={ cm5={component='cm5'} } }, seq=1 }) end) + assert_eq(st, 'ok'); assert_eq(result.tag, 'manager_request_rejected') + local ok, value, err = fibers.perform(req:wait_op()); assert_eq(ok, false); assert_eq(err, 'unknown_component') + end) end function tests.test_start_job_persists_active_intent_without_starting_active_work() - fibers.run(function () - local saves = {} - local initial = initial_with({ job_id = 'j1', component = 'cm5', state = 'created' }) - local store = { - load_all_op = function () return op.always(initial, nil) end, - save_job_op = function (_, job) saves[#saves + 1] = job; return op.always(true, nil) end, - } + fibers.run(function () + local saves = {} + local initial = initial_with({ job_id = 'j1', component = 'cm5', state = 'created' }) + local store = { + load_all_op = function () return op.always(initial, nil) end, + save_job_op = function (_, job) saves[#saves + 1] = job; return op.always(true, nil) end, + } - local req = request({ method = 'start_job', job_id = 'j1' }) - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store) - local out = manager_requests.start_job(scope, { - request = req, - jobs = jobs, - job_id = 'j1', - phase = 'stage', - generation = 1, - }) - jobs:cancel('test complete') - return out - end) + local req = request({ method = 'start_job', job_id = 'j1' }) + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store) + local out = manager_requests.start_job(scope, { + request = req, + jobs = jobs, + job_id = 'j1', + phase = 'stage', + generation = 1, + }) + jobs:cancel('test complete') + return out + end) - assert_eq(st, 'ok') - assert_eq(result.status, 'persisted') - assert_eq(result.tag, 'job_started') - assert_eq(#saves, 1, 'start request should durably save active intent') - assert_eq(saves[1].state, 'staging') - assert_not_nil(saves[1].active_intent, 'active intent should be durable') - assert_eq(saves[1].active_intent.phase, 'stage') - local ok, value = fibers.perform(req:wait_op()) - assert_eq(ok, true) - assert_eq(value.accepted, true) - assert_eq(value.token, result.token) - end) + assert_eq(st, 'ok') + assert_eq(result.status, 'persisted') + assert_eq(result.tag, 'job_started') + assert_eq(#saves, 1, 'start request should durably save active intent') + assert_eq(saves[1].state, 'staging') + assert_not_nil(saves[1].active_intent, 'active intent should be durable') + assert_eq(saves[1].active_intent.phase, 'stage') + local ok, value = fibers.perform(req:wait_op()) + assert_eq(ok, true) + assert_eq(value.accepted, true) + assert_eq(value.token, result.token) + end) end function tests.test_second_start_rejected_while_durable_active_intent_exists() - fibers.run(function () - local initial = { jobs = { - j1 = { job_id = 'j1', component = 'cm5', state = 'staging', active_token='tok-1', active_intent={ token='tok-1', phase='stage' }, created_seq=1, updated_seq=1 }, - j2 = { job_id = 'j2', component = 'cm5', state = 'created', created_seq=2, updated_seq=2 }, - }, order = { 'j1', 'j2' }, next_seq = 10 } - local req = request({ method = 'start_job', job_id = 'j2' }) - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store_mod.new(initial), initial) - local out = manager_requests.start_job(scope, { - request = req, - jobs = jobs, - job_id = 'j2', - phase = 'stage', - generation = 1, - }) - jobs:cancel('test complete') - return out - end) - assert_eq(st, 'ok') - assert_eq(result.tag, 'manager_request_rejected') - assert_eq(result.reason, 'slot_busy') - local ok, _, err = fibers.perform(req:wait_op()) - assert_eq(ok, false) - assert_eq(err, 'slot_busy') - end) + fibers.run(function () + local initial = { jobs = { + j1 = { job_id = 'j1', component = 'cm5', state = 'staging', active_token='tok-1', active_intent={ token='tok-1', phase='stage' }, created_seq=1, updated_seq=1 }, + j2 = { job_id = 'j2', component = 'cm5', state = 'created', created_seq=2, updated_seq=2 }, + }, order = { 'j1', 'j2' }, next_seq = 10 } + local req = request({ method = 'start_job', job_id = 'j2' }) + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store_mod.new(initial), initial) + local out = manager_requests.start_job(scope, { + request = req, + jobs = jobs, + job_id = 'j2', + phase = 'stage', + generation = 1, + }) + jobs:cancel('test complete') + return out + end) + assert_eq(st, 'ok') + assert_eq(result.tag, 'manager_request_rejected') + assert_eq(result.reason, 'slot_busy') + local ok, _, err = fibers.perform(req:wait_op()) + assert_eq(ok, false) + assert_eq(err, 'slot_busy') + end) end function tests.test_start_job_caller_cancellation_after_transition_admission_does_not_cancel_durable_transition() - fibers.run(function () - local save_entered = cond.new() - local save_release = cond.new() - local saves = {} - local initial = initial_with({ job_id = 'j1', component = 'cm5', state = 'created' }) - local store = { - load_all_op = function () return op.always(initial, nil) end, - save_job_op = function (_, job) - saves[#saves + 1] = job - save_entered:signal() - return save_release:wait_op():wrap(function () return true, nil end) - end, - } + fibers.run(function () + local save_entered = cond.new() + local save_release = cond.new() + local saves = {} + local initial = initial_with({ job_id = 'j1', component = 'cm5', state = 'created' }) + local store = { + load_all_op = function () return op.always(initial, nil) end, + save_job_op = function (_, job) + saves[#saves + 1] = job + save_entered:signal() + return save_release:wait_op():wrap(function () return true, nil end) + end, + } - local req = request({ method = 'start_job', job_id = 'j1' }) - req.reply_count = 0 - req.fail_count = 0 - local original_reply = req.reply - local original_fail = req.fail - function req:reply(v) self.reply_count = self.reply_count + 1; return original_reply(self, v) end - function req:fail(e) self.fail_count = self.fail_count + 1; return original_fail(self, e) end + local req = request({ method = 'start_job', job_id = 'j1' }) + req.reply_count = 0 + req.fail_count = 0 + local original_reply = req.reply + local original_fail = req.fail + function req:reply(v) self.reply_count = self.reply_count + 1; return original_reply(self, v) end + function req:fail(e) self.fail_count = self.fail_count + 1; return original_fail(self, e) end - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store) - local request_scope = assert(scope:child()) - local ok, spawn_err = request_scope:spawn(function (rs) - manager_requests.start_job(rs, { - request = req, - jobs = jobs, - job_id = 'j1', - phase = 'stage', - generation = 1, - }) - end) - assert_true(ok, spawn_err) + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store) + local request_scope = assert(scope:child()) + local ok, spawn_err = request_scope:spawn(function (rs) + manager_requests.start_job(rs, { + request = req, + jobs = jobs, + job_id = 'j1', + phase = 'stage', + generation = 1, + }) + end) + assert_true(ok, spawn_err) - fibers.perform(save_entered:wait_op()) - local transitions_at_admission = jobs:transition_snapshot() - local transition_id = transitions_at_admission.order[1] - local admitted = transitions_at_admission.by_id[transition_id] - assert_eq(admitted.state, 'persisting') - assert_true(admitted.admitted, 'transition should be admitted before caller cancellation') + fibers.perform(save_entered:wait_op()) + local transitions_at_admission = jobs:transition_snapshot() + local transition_id = transitions_at_admission.order[1] + local admitted = transitions_at_admission.by_id[transition_id] + assert_eq(admitted.state, 'persisting') + assert_true(admitted.admitted, 'transition should be admitted before caller cancellation') - request_scope:cancel('caller_cancelled') - local cst = fibers.perform(request_scope:join_op()) - assert_eq(cst, 'cancelled') - assert_eq(req.fail_count, 1) - assert_eq(req.reply_count, 0) - assert_eq(req.err, 'caller_cancelled') + request_scope:cancel('caller_cancelled') + local cst = fibers.perform(request_scope:join_op()) + assert_eq(cst, 'cancelled') + assert_eq(req.fail_count, 1) + assert_eq(req.reply_count, 0) + assert_eq(req.err, 'caller_cancelled') - local seen = jobs:version() - save_release:signal() - for _ = 1, 8 do - local job = jobs:get('j1') - if job and job.state == 'staging' then break end - local version = fibers.perform(jobs:changed_op(seen)) - seen = version or seen - end + local seen = jobs:version() + save_release:signal() + for _ = 1, 8 do + local job = jobs:get('j1') + if job and job.state == 'staging' then break end + local version = fibers.perform(jobs:changed_op(seen)) + seen = version or seen + end - local job = jobs:get('j1') - local transitions = jobs:transition_snapshot() - local outcome = jobs:transition_outcome(transition_id) - jobs:cancel('test complete') - return { job = job, transitions = transitions, transition_id = transition_id, outcome = outcome, saves = saves } - end) + local job = jobs:get('j1') + local transitions = jobs:transition_snapshot() + local outcome = jobs:transition_outcome(transition_id) + jobs:cancel('test complete') + return { job = job, transitions = transitions, transition_id = transition_id, outcome = outcome, saves = saves } + end) - assert_eq(st, 'ok') - assert_eq(result.job.state, 'staging') - assert_not_nil(result.job.active_intent, 'durable active intent should be persisted despite caller cancellation') - assert_eq(result.transitions.by_id[result.transition_id].state, 'persisted') - assert_eq(result.outcome.status, 'persisted') - assert_eq(#result.saves, 1) - end) + assert_eq(st, 'ok') + assert_eq(result.job.state, 'staging') + assert_not_nil(result.job.active_intent, 'durable active intent should be persisted despite caller cancellation') + assert_eq(result.transitions.by_id[result.transition_id].state, 'persisted') + assert_eq(result.outcome.status, 'persisted') + assert_eq(#result.saves, 1) + end) end function tests.test_create_job_requires_artifact_ref() - fibers.run(function () - local req = request({ method='create_job', job_id='j-missing-artifact', component='cm5' }) - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store_mod.new()) - local out = manager_requests.create_job(scope, { - request = req, - jobs = jobs, - config = { components = { cm5 = { component = 'cm5' } } }, - generation = 1, - }) - jobs:cancel('test complete') - return out - end) - assert_eq(st, 'ok') - assert_eq(result.tag, 'manager_request_rejected') - assert_eq(result.reason, 'artifact_ref_required') - local ok, _, err = fibers.perform(req:wait_op()) - assert_eq(ok, false) - assert_eq(err, 'artifact_ref_required') - end) + fibers.run(function () + local req = request({ method='create_job', job_id='j-missing-artifact', component='cm5' }) + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store_mod.new()) + local out = manager_requests.create_job(scope, { + request = req, + jobs = jobs, + config = { components = { cm5 = { component = 'cm5' } } }, + generation = 1, + }) + jobs:cancel('test complete') + return out + end) + assert_eq(st, 'ok') + assert_eq(result.tag, 'manager_request_rejected') + assert_eq(result.reason, 'artifact_ref_required') + local ok, _, err = fibers.perform(req:wait_op()) + assert_eq(ok, false) + assert_eq(err, 'artifact_ref_required') + end) end function tests.test_create_mcu_job_resolves_expected_image_id_from_dcmcu_artifact() - fibers.run(function () - local dcmcu_fixture = require 'tests.support.dcmcu_fixture' - local blob_source = require 'devicecode.blob_source' - local req = request({ method='create_job', job_id='j-mcu', component='mcu', artifact_ref='artifact-mcu' }) - local artifact_store = { - open_source_op = function (_, ref) - assert_eq(ref, 'artifact-mcu') - return op.always(blob_source.from_string(dcmcu_fixture.make('mcu-image-new')), nil) - end, - } - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store_mod.new()) - local out = manager_requests.create_job(scope, { - request = req, - jobs = jobs, - config = { components = { mcu = { component = 'mcu' } } }, - artifact_store = artifact_store, - generation = 3, - }) - jobs:cancel('test complete') - return out - end) - assert_eq(st, 'ok') - assert_eq(result.status, 'persisted') - assert_eq(result.job.expected_image_id, 'mcu-image-new') - local ok, value = fibers.perform(req:wait_op()) - assert_true(ok) - assert_eq(value.job.expected_image_id, 'mcu-image-new') - end) + fibers.run(function () + local dcmcu_fixture = require 'tests.support.dcmcu_fixture' + local blob_source = require 'devicecode.blob_source' + local req = request({ method='create_job', job_id='j-mcu', component='mcu', artifact_ref='artifact-mcu' }) + local artifact_store = { + open_source_op = function (_, ref) + assert_eq(ref, 'artifact-mcu') + return op.always(blob_source.from_string(dcmcu_fixture.make('mcu-image-new')), nil) + end, + } + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store_mod.new()) + local out = manager_requests.create_job(scope, { + request = req, + jobs = jobs, + config = { components = { mcu = { component = 'mcu' } } }, + artifact_store = artifact_store, + generation = 3, + }) + jobs:cancel('test complete') + return out + end) + assert_eq(st, 'ok') + assert_eq(result.status, 'persisted') + assert_eq(result.job.expected_image_id, 'mcu-image-new') + local ok, value = fibers.perform(req:wait_op()) + assert_true(ok) + assert_eq(value.job.expected_image_id, 'mcu-image-new') + end) end function tests.test_create_mcu_job_rejects_caller_supplied_expected_image_id() - fibers.run(function () - local req = request({ method='create_job', job_id='j-mcu', component='mcu', artifact_ref='artifact-mcu', expected_image_id='caller-value' }) - local st, _, result = fibers.run_scope(function (scope) - local jobs = start_jobs(scope, store_mod.new()) - local out = manager_requests.create_job(scope, { - request = req, - jobs = jobs, - config = { components = { mcu = { component = 'mcu' } } }, - artifact_store = { open_source_op = function () error('must not inspect artifact after rejecting caller expected_image_id') end }, - generation = 3, - }) - jobs:cancel('test complete') - return out - end) - assert_eq(st, 'ok') - assert_eq(result.tag, 'manager_request_rejected') - assert_eq(result.reason, 'expected_image_id_must_be_resolved_from_artifact') - local ok, _, err = fibers.perform(req:wait_op()) - assert_eq(ok, false) - assert_eq(err, 'expected_image_id_must_be_resolved_from_artifact') - end) + fibers.run(function () + local req = request({ method='create_job', job_id='j-mcu', component='mcu', artifact_ref='artifact-mcu', expected_image_id='caller-value' }) + local st, _, result = fibers.run_scope(function (scope) + local jobs = start_jobs(scope, store_mod.new()) + local out = manager_requests.create_job(scope, { + request = req, + jobs = jobs, + config = { components = { mcu = { component = 'mcu' } } }, + artifact_store = { open_source_op = function () error('must not inspect artifact after rejecting caller expected_image_id') end }, + generation = 3, + }) + jobs:cancel('test complete') + return out + end) + assert_eq(st, 'ok') + assert_eq(result.tag, 'manager_request_rejected') + assert_eq(result.reason, 'expected_image_id_must_be_resolved_from_artifact') + local ok, _, err = fibers.perform(req:wait_op()) + assert_eq(ok, false) + assert_eq(err, 'expected_image_id_must_be_resolved_from_artifact') + end) end return tests diff --git a/tests/unit/update/test_service_phase2.lua b/tests/unit/update/test_service_phase2.lua index b6d26c36..69e96ab2 100644 --- a/tests/unit/update/test_service_phase2.lua +++ b/tests/unit/update/test_service_phase2.lua @@ -14,504 +14,504 @@ local function assert_true(v,msg) if v ~= true then fail(msg or ('expected true, local function assert_not_nil(v,msg) if v == nil then fail(msg or 'expected non-nil') end end local function assert_contains(s, needle, msg) if tostring(s or ''):find(tostring(needle), 1, true) == nil then fail(msg or ('expected '..tostring(s)..' to contain '..tostring(needle))) end end local function start_service(root_scope, params) - params = params or {} - local bus = params.bus or busmod.new(); local svc_conn = bus:connect(); local caller = bus:connect(); local child = assert(root_scope:child()) - local ok, err = child:spawn(function (scope) params=params or {}; params.conn=svc_conn; params.service_id=params.service_id or 'update'; params.watch_config=false; if params.job_store == nil and params.job_store_kind == nil then params.job_store_kind='memory' end; service.run(scope, params) end) - assert_true(ok, err); fibers.perform(sleep.sleep_op(0.02)); return child, caller, bus + params = params or {} + local bus = params.bus or busmod.new(); local svc_conn = bus:connect(); local caller = bus:connect(); local child = assert(root_scope:child()) + local ok, err = child:spawn(function (scope) params=params or {}; params.conn=svc_conn; params.service_id=params.service_id or 'update'; params.watch_config=false; if params.job_store == nil and params.job_store_kind == nil then params.job_store_kind='memory' end; service.run(scope, params) end) + assert_true(ok, err); fibers.perform(sleep.sleep_op(0.02)); return child, caller, bus end function tests.test_manager_create_job_is_scoped_and_updates_status_model() - fibers.run(function (root_scope) - local child, caller = start_service(root_scope, { config={ schema='devicecode.update/1', components={ { component='cm5' } } } }) - local reply, err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-1' }, { timeout=0.5 }) - assert_not_nil(reply, err); assert_eq(reply.ok, true); assert_eq(reply.job.job_id, 'j1') - local status - local ok_wait = probe.wait_until(function() status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }); return status and status.snapshot and status.snapshot.jobs and status.snapshot.jobs.by_id.j1 ~= nil end, { timeout=0.5, interval=0.01 }) - assert_true(ok_wait, 'expected created job to appear in service status'); assert_eq(status.snapshot.jobs.by_id.j1.component, 'cm5') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { config={ schema='devicecode.update/1', components={ { component='cm5' } } } }) + local reply, err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-1' }, { timeout=0.5 }) + assert_not_nil(reply, err); assert_eq(reply.ok, true); assert_eq(reply.job.job_id, 'j1') + local status + local ok_wait = probe.wait_until(function() status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }); return status and status.snapshot and status.snapshot.jobs and status.snapshot.jobs.by_id.j1 ~= nil end, { timeout=0.5, interval=0.01 }) + assert_true(ok_wait, 'expected created job to appear in service status'); assert_eq(status.snapshot.jobs.by_id.j1.component, 'cm5') + child:cancel('test complete') + end) end function tests.test_single_job_policy_replaces_previous_created_job() - fibers.run(function (root_scope) - local child, caller = start_service(root_scope, { config={ schema='devicecode.update/1', components={ { component='cm5' } } } }) - assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 })) - assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j2', component='cm5', artifact_ref='artifact-j2' }, { timeout=0.5 })) - local status - assert_true(probe.wait_until(function() - status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.jobs.by_id.j2 ~= nil - end, { timeout=0.5, interval=0.01 }), 'expected newest job to be visible') - assert_eq(status.snapshot.jobs.count, 1) - assert_eq(status.snapshot.jobs.by_id.j1, nil) - assert_eq(status.snapshot.jobs.by_id.j2.state, 'created') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { config={ schema='devicecode.update/1', components={ { component='cm5' } } } }) + assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 })) + assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j2', component='cm5', artifact_ref='artifact-j2' }, { timeout=0.5 })) + local status + assert_true(probe.wait_until(function() + status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.jobs.by_id.j2 ~= nil + end, { timeout=0.5, interval=0.01 }), 'expected newest job to be visible') + assert_eq(status.snapshot.jobs.count, 1) + assert_eq(status.snapshot.jobs.by_id.j1, nil) + assert_eq(status.snapshot.jobs.by_id.j2.state, 'created') + child:cancel('test complete') + end) end function tests.test_commit_job_persists_awaiting_return_before_reconcile() - fibers.run(function (root_scope) - local save_log = {} - local backend = { jobs = {} } - function backend:load_all_op() return op.always({ jobs = self.jobs, order = {} }, nil) end - function backend:save_job_op(job) - save_log[#save_log + 1] = { job_id = job.job_id, state = job.state } - self.jobs[job.job_id] = job - return op.always(true, nil) - end - - local active_backend = {} - function active_backend:stage_op(job) return op.always({ job_id=job.job_id }, nil) end - function active_backend:commit_capabilities() return { policy = 'idempotent_by_token' } end - function active_backend:commit_op(job, ctx) return op.always({ accepted=true, token=ctx.commit_token, job_id=job.job_id }, nil) end - function active_backend:evaluate_reconcile() return { done=true, tag='reconciled_success', observed={ ok=true } } end - - local child, caller = start_service(root_scope, { - config={ schema='devicecode.update/1', components={ { component='cm5' } } }, - job_store = backend, - backend = active_backend, - }) - assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 })) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot.jobs.by_id.j1 ~= nil - end, { timeout=0.5, interval=0.01 }), 'expected created job') - assert(caller:call(topics.update_manager_rpc('start-job'), { job_id='j1' }, { timeout=0.5 })) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'awaiting_commit' - end, { timeout=0.5, interval=0.01 }), 'expected staged job') - - local reply = assert(caller:call(topics.update_manager_rpc('commit-job'), { job_id='j1' }, { timeout=0.5 })) - assert_eq(reply.accepted, true) - local status - assert_true(probe.wait_until(function() - status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' - end, { timeout=0.8, interval=0.01 }), 'expected reconcile success after commit') - - local saw_awaiting_return, saw_succeeded = false, false - for _, row in ipairs(save_log) do - if row.job_id == 'j1' and row.state == 'awaiting_return' then saw_awaiting_return = true end - if row.job_id == 'j1' and row.state == 'succeeded' then saw_succeeded = true end - end - assert_true(saw_awaiting_return, 'commit accepted boundary must be durably saved') - assert_true(saw_succeeded, 'reconcile result must be saved') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local save_log = {} + local backend = { jobs = {} } + function backend:load_all_op() return op.always({ jobs = self.jobs, order = {} }, nil) end + function backend:save_job_op(job) + save_log[#save_log + 1] = { job_id = job.job_id, state = job.state } + self.jobs[job.job_id] = job + return op.always(true, nil) + end + + local active_backend = {} + function active_backend:stage_op(job) return op.always({ job_id=job.job_id }, nil) end + function active_backend:commit_capabilities() return { policy = 'idempotent_by_token' } end + function active_backend:commit_op(job, ctx) return op.always({ accepted=true, token=ctx.commit_token, job_id=job.job_id }, nil) end + function active_backend:evaluate_reconcile() return { done=true, tag='reconciled_success', observed={ ok=true } } end + + local child, caller = start_service(root_scope, { + config={ schema='devicecode.update/1', components={ { component='cm5' } } }, + job_store = backend, + backend = active_backend, + }) + assert(caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 })) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot.jobs.by_id.j1 ~= nil + end, { timeout=0.5, interval=0.01 }), 'expected created job') + assert(caller:call(topics.update_manager_rpc('start-job'), { job_id='j1' }, { timeout=0.5 })) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'awaiting_commit' + end, { timeout=0.5, interval=0.01 }), 'expected staged job') + + local reply = assert(caller:call(topics.update_manager_rpc('commit-job'), { job_id='j1' }, { timeout=0.5 })) + assert_eq(reply.accepted, true) + local status + assert_true(probe.wait_until(function() + status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' + end, { timeout=0.8, interval=0.01 }), 'expected reconcile success after commit') + + local saw_awaiting_return, saw_succeeded = false, false + for _, row in ipairs(save_log) do + if row.job_id == 'j1' and row.state == 'awaiting_return' then saw_awaiting_return = true end + if row.job_id == 'j1' and row.state == 'succeeded' then saw_succeeded = true end + end + assert_true(saw_awaiting_return, 'commit accepted boundary must be durably saved') + assert_true(saw_succeeded, 'reconcile result must be saved') + child:cancel('test complete') + end) end function tests.test_restart_adoption_keeps_awaiting_commit_committable() - fibers.run(function (root_scope) - local initial_jobs = { jobs = { j1 = { job_id='j1', component='cm5', state='awaiting_commit', created_seq=1, updated_seq=1 } } } - local child, caller = start_service(root_scope, { - config={ schema='devicecode.update/1', components={ { component='cm5' } } }, - initial_jobs = initial_jobs, - backend = { - stage_op = function () return op.always({}, nil) end, - commit_capabilities = function () return { policy = 'idempotent_by_token' } end, - commit_op = function (_, job, ctx) return op.always({ accepted=true, token=ctx.commit_token }, nil) end, - evaluate_reconcile = function () return { done=true, tag='reconciled_success', observed={ ok=true } } end, - }, - }) - local reply = assert(caller:call(topics.update_manager_rpc('commit-job'), { job_id='j1' }, { timeout=0.5 })) - assert_eq(reply.accepted, true) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' - end, { timeout=0.8, interval=0.01 }), 'awaiting_commit should remain committable after adoption') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local initial_jobs = { jobs = { j1 = { job_id='j1', component='cm5', state='awaiting_commit', created_seq=1, updated_seq=1 } } } + local child, caller = start_service(root_scope, { + config={ schema='devicecode.update/1', components={ { component='cm5' } } }, + initial_jobs = initial_jobs, + backend = { + stage_op = function () return op.always({}, nil) end, + commit_capabilities = function () return { policy = 'idempotent_by_token' } end, + commit_op = function (_, job, ctx) return op.always({ accepted=true, token=ctx.commit_token }, nil) end, + evaluate_reconcile = function () return { done=true, tag='reconciled_success', observed={ ok=true } } end, + }, + }) + local reply = assert(caller:call(topics.update_manager_rpc('commit-job'), { job_id='j1' }, { timeout=0.5 })) + assert_eq(reply.accepted, true) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' + end, { timeout=0.8, interval=0.01 }), 'awaiting_commit should remain committable after adoption') + child:cancel('test complete') + end) end function tests.test_restart_adoption_starts_reconcile_for_awaiting_return() - fibers.run(function (root_scope) - local ran_reconcile = false - local initial_jobs = { jobs = { j1 = { job_id='j1', component='cm5', state='awaiting_return', created_seq=1, updated_seq=1 } } } - local child, caller = start_service(root_scope, { - config={ schema='devicecode.update/1', components={ { component='cm5' } } }, - initial_jobs = initial_jobs, - backend = { - stage_op = function () return op.always({}, nil) end, - evaluate_reconcile = function () ran_reconcile = true; return { done=true, tag='reconciled_success', observed={ ok=true } } end, - }, - }) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return ran_reconcile and status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' - end, { timeout=0.8, interval=0.01 }), 'awaiting_return should start reconcile after adoption') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local ran_reconcile = false + local initial_jobs = { jobs = { j1 = { job_id='j1', component='cm5', state='awaiting_return', created_seq=1, updated_seq=1 } } } + local child, caller = start_service(root_scope, { + config={ schema='devicecode.update/1', components={ { component='cm5' } } }, + initial_jobs = initial_jobs, + backend = { + stage_op = function () return op.always({}, nil) end, + evaluate_reconcile = function () ran_reconcile = true; return { done=true, tag='reconciled_success', observed={ ok=true } } end, + }, + }) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return ran_reconcile and status and status.snapshot.jobs.by_id.j1 and status.snapshot.jobs.by_id.j1.state == 'succeeded' + end, { timeout=0.8, interval=0.01 }), 'awaiting_return should start reconcile after adoption') + child:cancel('test complete') + end) end function tests.test_slow_job_runtime_load_keeps_public_service_responsive() - fibers.run(function (root_scope) - local load_gate = cond.new() - local backend = { loaded = false, jobs = {} } - function backend:load_all_op() - return load_gate:wait_op():wrap(function () - self.loaded = true - return { jobs = self.jobs, order = {} }, nil - end) - end - function backend:save_job_op(job) - self.jobs[job.job_id] = job - return op.always(true, nil) - end - local child, caller = start_service(root_scope, { - config={ schema='devicecode.update/1', components={ { component='cm5' } } }, - job_store = backend, - }) - local status = assert(caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.5 })) - assert_eq(status.ok, true) - assert_eq(status.snapshot.state, 'starting') - local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 }) - assert_eq(created, nil) - assert_eq(create_err, 'job_runtime_not_ready') - load_gate:signal() - assert_true(probe.wait_until(function() - local s = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return s and s.snapshot and s.snapshot.state == 'running' - end, { timeout=0.6, interval=0.01 }), 'service should become running after job load') - child:cancel('test complete') - end) + fibers.run(function (root_scope) + local load_gate = cond.new() + local backend = { loaded = false, jobs = {} } + function backend:load_all_op() + return load_gate:wait_op():wrap(function () + self.loaded = true + return { jobs = self.jobs, order = {} }, nil + end) + end + function backend:save_job_op(job) + self.jobs[job.job_id] = job + return op.always(true, nil) + end + local child, caller = start_service(root_scope, { + config={ schema='devicecode.update/1', components={ { component='cm5' } } }, + job_store = backend, + }) + local status = assert(caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.5 })) + assert_eq(status.ok, true) + assert_eq(status.snapshot.state, 'starting') + local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j1', component='cm5', artifact_ref='artifact-j1' }, { timeout=0.5 }) + assert_eq(created, nil) + assert_eq(create_err, 'job_runtime_not_ready') + load_gate:signal() + assert_true(probe.wait_until(function() + local s = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return s and s.snapshot and s.snapshot.state == 'running' + end, { timeout=0.6, interval=0.01 }), 'service should become running after job load') + child:cancel('test complete') + end) end local function bind_fake_control_store(scope, bus, backing, opts) - backing = backing or {} - opts = opts or {} - local conn = bus:connect() - local methods = { 'list', 'get', 'put', 'delete' } - for _, method in ipairs(methods) do - local loop_method = method - local ep = assert(conn:bind({ 'cap', 'control-store', 'update', 'rpc', loop_method })) - scope:spawn(function () - while true do - local req = fibers.perform(ep:recv_op()) - if req == nil then return end - local p = req.payload or {} - if loop_method == 'list' then - if opts.list_calls then opts.list_calls.count = (opts.list_calls.count or 0) + 1 end - if opts.on_list then opts.on_list(p) end - if opts.fail_list then - req:reply({ ok = false, reason = opts.fail_list_reason or 'backend_failed' }) - else - local keys = {} - local prefix = p.prefix or '' - for k in pairs(backing) do - if k:sub(1, #prefix) == prefix then keys[#keys + 1] = k end - end - table.sort(keys) - req:reply({ ok = true, reason = keys }) - end - elseif loop_method == 'get' then - if backing[p.key] == nil then req:reply({ ok = false, reason = 'not found' }) else req:reply({ ok = true, reason = backing[p.key] }) end - elseif loop_method == 'put' then - backing[p.key] = p.data - req:reply({ ok = true, reason = nil }) - elseif loop_method == 'delete' then - backing[p.key] = nil - req:reply({ ok = true, reason = nil }) - end - end - end) - end - conn:retain({ 'cap', 'control-store', 'update', 'status' }, { schema='devicecode.cap.status/1', state='available', available=true }) - return backing, conn + backing = backing or {} + opts = opts or {} + local conn = bus:connect() + local methods = { 'list', 'get', 'put', 'delete' } + for _, method in ipairs(methods) do + local loop_method = method + local ep = assert(conn:bind({ 'cap', 'control-store', 'update', 'rpc', loop_method })) + scope:spawn(function () + while true do + local req = fibers.perform(ep:recv_op()) + if req == nil then return end + local p = req.payload or {} + if loop_method == 'list' then + if opts.list_calls then opts.list_calls.count = (opts.list_calls.count or 0) + 1 end + if opts.on_list then opts.on_list(p) end + if opts.fail_list then + req:reply({ ok = false, reason = opts.fail_list_reason or 'backend_failed' }) + else + local keys = {} + local prefix = p.prefix or '' + for k in pairs(backing) do + if k:sub(1, #prefix) == prefix then keys[#keys + 1] = k end + end + table.sort(keys) + req:reply({ ok = true, reason = keys }) + end + elseif loop_method == 'get' then + if backing[p.key] == nil then req:reply({ ok = false, reason = 'not found' }) else req:reply({ ok = true, reason = backing[p.key] }) end + elseif loop_method == 'put' then + backing[p.key] = p.data + req:reply({ ok = true, reason = nil }) + elseif loop_method == 'delete' then + backing[p.key] = nil + req:reply({ ok = true, reason = nil }) + end + end + end) + end + conn:retain({ 'cap', 'control-store', 'update', 'status' }, { schema='devicecode.cap.status/1', state='available', available=true }) + return backing, conn end local function retain_fake_artifact_store_status(bus, status) - local conn = bus:connect() - conn:retain({ 'cap', 'artifact-store', 'main', 'status' }, { - schema='devicecode.cap.status/1', state=status or 'available', available=(status or 'available') == 'available' - }) - return conn + local conn = bus:connect() + conn:retain({ 'cap', 'artifact-store', 'main', 'status' }, { + schema='devicecode.cap.status/1', state=status or 'available', available=(status or 'available') == 'available' + }) + return conn end function tests.test_control_store_dependency_waits_until_available() - fibers.run(function (root_scope) - local bus = busmod.new() - retain_fake_artifact_store_status(bus, 'available') - local child, caller = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - - local waiting - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - waiting = status and status.snapshot - return waiting and waiting.state == 'waiting_for_job_store' - end, { timeout=0.5, interval=0.01 }), 'expected update to wait for control-store capability') - assert_eq(waiting.ready, false) - assert_eq(waiting.reason, 'job_store_unavailable') - - local view = caller:retained_view(topics.update_summary()) - local retained_waiting = probe.wait_versioned_until('update retained summary includes dependencies while waiting', - function () return view:version() end, - function (seen) return view:changed_op(seen) end, - function () - local msg = view:get(topics.update_summary()) - local payload = msg and msg.payload - return payload and payload.state == 'waiting_for_job_store' - and payload.dependencies and payload.dependencies.job_store - and payload.dependencies.job_store.available == false - and payload or nil - end, - { timeout = 0.5 }) - assert_eq(retained_waiting.dependencies.job_store.status, 'configured') - view:close() - - local control_scope = assert(root_scope:child()) - bind_fake_control_store(control_scope, bus, {}) - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'running' - end, { timeout=0.7, interval=0.01 }), 'expected update to start after control-store becomes available') - - child:cancel('test complete') - fibers.perform(child:join_op()) - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + retain_fake_artifact_store_status(bus, 'available') + local child, caller = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + + local waiting + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + waiting = status and status.snapshot + return waiting and waiting.state == 'waiting_for_job_store' + end, { timeout=0.5, interval=0.01 }), 'expected update to wait for control-store capability') + assert_eq(waiting.ready, false) + assert_eq(waiting.reason, 'job_store_unavailable') + + local view = caller:retained_view(topics.update_summary()) + local retained_waiting = probe.wait_versioned_until('update retained summary includes dependencies while waiting', + function () return view:version() end, + function (seen) return view:changed_op(seen) end, + function () + local msg = view:get(topics.update_summary()) + local payload = msg and msg.payload + return payload and payload.state == 'waiting_for_job_store' + and payload.dependencies and payload.dependencies.job_store + and payload.dependencies.job_store.available == false + and payload or nil + end, + { timeout = 0.5 }) + assert_eq(retained_waiting.dependencies.job_store.status, 'configured') + view:close() + + local control_scope = assert(root_scope:child()) + bind_fake_control_store(control_scope, bus, {}) + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'running' + end, { timeout=0.7, interval=0.01 }), 'expected update to start after control-store becomes available') + + child:cancel('test complete') + fibers.perform(child:join_op()) + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end function tests.test_control_store_no_route_returns_to_waiting_not_failed() - fibers.run(function (root_scope) - local bus = busmod.new() - retain_fake_artifact_store_status(bus, 'available') - local status_conn = bus:connect() - status_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { schema='devicecode.cap.status/1', state='available', available=true }) - - local child, caller = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'waiting_for_job_store' - end, { timeout=0.7, interval=0.01 }), 'expected no_route job load to return update to waiting') - - local control_scope = assert(root_scope:child()) - bind_fake_control_store(control_scope, bus, {}) - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'running' - end, { timeout=0.7, interval=0.01 }), 'expected update to restart job runtime after route returns') - - child:cancel('test complete') - fibers.perform(child:join_op()) - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + retain_fake_artifact_store_status(bus, 'available') + local status_conn = bus:connect() + status_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { schema='devicecode.cap.status/1', state='available', available=true }) + + local child, caller = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'waiting_for_job_store' + end, { timeout=0.7, interval=0.01 }), 'expected no_route job load to return update to waiting') + + local control_scope = assert(root_scope:child()) + bind_fake_control_store(control_scope, bus, {}) + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'running' + end, { timeout=0.7, interval=0.01 }), 'expected update to restart job runtime after route returns') + + child:cancel('test complete') + fibers.perform(child:join_op()) + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end function tests.test_default_job_store_uses_control_store_and_reloads_after_restart() - fibers.run(function (root_scope) - local bus = busmod.new() - retain_fake_artifact_store_status(bus, 'available') - local control_scope = assert(root_scope:child()) - local backing = bind_fake_control_store(control_scope, bus, {}) - local child, caller = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'running' - end, { timeout=0.5, interval=0.01 }), 'expected control-store job runtime to become ready') - local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j-persist', component='cm5', artifact_ref='artifact-j-persist' }, { timeout=0.5 }) - assert_not_nil(created, create_err) - assert_eq(created.ok, true) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.jobs.by_id['j-persist'] ~= nil - end, { timeout=0.5, interval=0.01 }), 'expected first service to persist job') - child:cancel('first service complete') - fibers.perform(child:join_op()) - - local saw_key_after_first = false - for k in pairs(backing) do if k:sub(1, 11) == 'update-job-' then saw_key_after_first = true end end - assert_true(saw_key_after_first, 'expected first service to persist job in control-store keyspace') - - local child2, caller2 = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - assert_true(probe.wait_until(function() - local status = caller2:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.jobs.by_id['j-persist'] ~= nil - end, { timeout=0.5, interval=0.01 }), 'expected restarted service to reload persisted job') - child2:cancel('test complete') - fibers.perform(child2:join_op()) - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + retain_fake_artifact_store_status(bus, 'available') + local control_scope = assert(root_scope:child()) + local backing = bind_fake_control_store(control_scope, bus, {}) + local child, caller = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'running' + end, { timeout=0.5, interval=0.01 }), 'expected control-store job runtime to become ready') + local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { job_id='j-persist', component='cm5', artifact_ref='artifact-j-persist' }, { timeout=0.5 }) + assert_not_nil(created, create_err) + assert_eq(created.ok, true) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.jobs.by_id['j-persist'] ~= nil + end, { timeout=0.5, interval=0.01 }), 'expected first service to persist job') + child:cancel('first service complete') + fibers.perform(child:join_op()) + + local saw_key_after_first = false + for k in pairs(backing) do if k:sub(1, 11) == 'update-job-' then saw_key_after_first = true end end + assert_true(saw_key_after_first, 'expected first service to persist job in control-store keyspace') + + local child2, caller2 = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + assert_true(probe.wait_until(function() + local status = caller2:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.jobs.by_id['j-persist'] ~= nil + end, { timeout=0.5, interval=0.01 }), 'expected restarted service to reload persisted job') + child2:cancel('test complete') + fibers.perform(child2:join_op()) + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end function tests.test_control_store_backend_failure_still_fails_update() - fibers.run(function (root_scope) - local bus = busmod.new() - retain_fake_artifact_store_status(bus, 'available') - local control_scope = assert(root_scope:child()) - bind_fake_control_store(control_scope, bus, {}, { - fail_list = true, - fail_list_reason = 'backend_failed', - }) - - local child = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - - local which, st, _, primary = fibers.perform(fibers.named_choice { - done = child:join_op(), - timeout = sleep.sleep_op(0.8), - }) - if which == 'timeout' then - child:cancel('test timeout') - fibers.perform(child:join_op()) - fail('expected update service to fail on real control-store backend failure') - end - assert_eq(st, 'failed') - assert_contains(primary, 'backend_failed') - - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + retain_fake_artifact_store_status(bus, 'available') + local control_scope = assert(root_scope:child()) + bind_fake_control_store(control_scope, bus, {}, { + fail_list = true, + fail_list_reason = 'backend_failed', + }) + + local child = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + + local which, st, _, primary = fibers.perform(fibers.named_choice { + done = child:join_op(), + timeout = sleep.sleep_op(0.8), + }) + if which == 'timeout' then + child:cancel('test timeout') + fibers.perform(child:join_op()) + fail('expected update service to fail on real control-store backend failure') + end + assert_eq(st, 'failed') + assert_contains(primary, 'backend_failed') + + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end function tests.test_job_store_dependency_loss_cancels_and_reloads_runtime() - fibers.run(function (root_scope) - local bus = busmod.new() - retain_fake_artifact_store_status(bus, 'available') - local control_scope = assert(root_scope:child()) - local list_calls = { count = 0 } - local _, control_conn = bind_fake_control_store(control_scope, bus, {}, { list_calls = list_calls }) - local child, caller = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'running' - end, { timeout=0.7, interval=0.01 }), 'expected service running before dependency loss') - assert_true(list_calls.count >= 1, 'expected initial job runtime load') - - local created0, create_err0 = caller:call(topics.update_manager_rpc('create-job'), { - job_id='j-before-loss', component='cm5', artifact_ref='artifact-before-loss', - }, { timeout=0.5 }) - assert_not_nil(created0, create_err0) - assert_eq(created0.ok, true) - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.jobs.by_id['j-before-loss'] ~= nil - end, { timeout=0.5, interval=0.01 }), 'expected job visible before dependency loss') - local first_load_count = list_calls.count - - control_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { - schema='devicecode.cap.status/1', state='unavailable', available=false, - }) - - local waiting - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - waiting = status and status.snapshot - return waiting and waiting.state == 'waiting_for_job_store' - end, { timeout=0.7, interval=0.01 }), 'expected service to wait when job-store dependency is lost') - assert_eq(waiting.ready, false) - assert_eq(waiting.reason, 'job_store_unavailable') - assert_eq(waiting.dependencies.job_store.available, false) - - local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { - job_id='j-while-unavailable', component='cm5', artifact_ref='artifact-unavailable', - }, { timeout=0.2 }) - assert_eq(created, nil) - assert_eq(create_err, 'job_store_unavailable') - - control_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { - schema='devicecode.cap.status/1', state='available', available=true, - }) - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot - and status.snapshot.state == 'running' - and list_calls.count > first_load_count - end, { timeout=0.8, interval=0.01 }), 'expected service to reload job runtime from store after job-store returns') - - local created2, create_err2 = caller:call(topics.update_manager_rpc('create-job'), { - job_id='j-after-recovery', component='cm5', artifact_ref='artifact-after-recovery', - }, { timeout=0.5 }) - assert_not_nil(created2, create_err2) - assert_eq(created2.ok, true) - - child:cancel('test complete') - fibers.perform(child:join_op()) - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + retain_fake_artifact_store_status(bus, 'available') + local control_scope = assert(root_scope:child()) + local list_calls = { count = 0 } + local _, control_conn = bind_fake_control_store(control_scope, bus, {}, { list_calls = list_calls }) + local child, caller = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'running' + end, { timeout=0.7, interval=0.01 }), 'expected service running before dependency loss') + assert_true(list_calls.count >= 1, 'expected initial job runtime load') + + local created0, create_err0 = caller:call(topics.update_manager_rpc('create-job'), { + job_id='j-before-loss', component='cm5', artifact_ref='artifact-before-loss', + }, { timeout=0.5 }) + assert_not_nil(created0, create_err0) + assert_eq(created0.ok, true) + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.jobs.by_id['j-before-loss'] ~= nil + end, { timeout=0.5, interval=0.01 }), 'expected job visible before dependency loss') + local first_load_count = list_calls.count + + control_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { + schema='devicecode.cap.status/1', state='unavailable', available=false, + }) + + local waiting + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + waiting = status and status.snapshot + return waiting and waiting.state == 'waiting_for_job_store' + end, { timeout=0.7, interval=0.01 }), 'expected service to wait when job-store dependency is lost') + assert_eq(waiting.ready, false) + assert_eq(waiting.reason, 'job_store_unavailable') + assert_eq(waiting.dependencies.job_store.available, false) + + local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { + job_id='j-while-unavailable', component='cm5', artifact_ref='artifact-unavailable', + }, { timeout=0.2 }) + assert_eq(created, nil) + assert_eq(create_err, 'job_store_unavailable') + + control_conn:retain({ 'cap', 'control-store', 'update', 'status' }, { + schema='devicecode.cap.status/1', state='available', available=true, + }) + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot + and status.snapshot.state == 'running' + and list_calls.count > first_load_count + end, { timeout=0.8, interval=0.01 }), 'expected service to reload job runtime from store after job-store returns') + + local created2, create_err2 = caller:call(topics.update_manager_rpc('create-job'), { + job_id='j-after-recovery', component='cm5', artifact_ref='artifact-after-recovery', + }, { timeout=0.5 }) + assert_not_nil(created2, create_err2) + assert_eq(created2.ok, true) + + child:cancel('test complete') + fibers.perform(child:join_op()) + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end function tests.test_artifact_store_dependency_gates_generation_after_job_store_ready() - fibers.run(function (root_scope) - local bus = busmod.new() - local control_scope = assert(root_scope:child()) - bind_fake_control_store(control_scope, bus, {}) - - local child, caller = start_service(root_scope, { - bus = bus, - job_store_kind = 'control-store', - config = { schema='devicecode.update/1', components={ { component='cm5' } } }, - }) - - local waiting - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - waiting = status and status.snapshot - return waiting and waiting.state == 'waiting_for_artifact_store' - end, { timeout=0.7, interval=0.01 }), 'expected service to wait for artifact-store after job runtime is available') - assert_eq(waiting.reason, 'artifact_store_unavailable') - assert_eq(waiting.dependencies.artifact_store.available, false) - assert_true(waiting.pending and waiting.pending.runtime and waiting.pending.runtime.dependency == 'artifact_store', - 'expected artifact-store pending runtime projection') - - local listed, list_err = caller:call(topics.update_manager_rpc('list-jobs'), {}, { timeout=0.2 }) - assert_not_nil(listed, list_err) - assert_eq(listed.ok, true) - - local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { - job_id='j-artifact-wait', component='cm5', artifact_ref='artifact-wait', - }, { timeout=0.2 }) - assert_eq(created, nil) - assert_eq(create_err, 'artifact_store_unavailable') - - retain_fake_artifact_store_status(bus, 'available') - - assert_true(probe.wait_until(function() - local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) - return status and status.snapshot and status.snapshot.state == 'running' - end, { timeout=0.8, interval=0.01 }), 'expected update to admit generation after artifact-store becomes available') - - child:cancel('test complete') - fibers.perform(child:join_op()) - control_scope:cancel('test complete') - fibers.perform(control_scope:join_op()) - end) + fibers.run(function (root_scope) + local bus = busmod.new() + local control_scope = assert(root_scope:child()) + bind_fake_control_store(control_scope, bus, {}) + + local child, caller = start_service(root_scope, { + bus = bus, + job_store_kind = 'control-store', + config = { schema='devicecode.update/1', components={ { component='cm5' } } }, + }) + + local waiting + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + waiting = status and status.snapshot + return waiting and waiting.state == 'waiting_for_artifact_store' + end, { timeout=0.7, interval=0.01 }), 'expected service to wait for artifact-store after job runtime is available') + assert_eq(waiting.reason, 'artifact_store_unavailable') + assert_eq(waiting.dependencies.artifact_store.available, false) + assert_true(waiting.pending and waiting.pending.runtime and waiting.pending.runtime.dependency == 'artifact_store', + 'expected artifact-store pending runtime projection') + + local listed, list_err = caller:call(topics.update_manager_rpc('list-jobs'), {}, { timeout=0.2 }) + assert_not_nil(listed, list_err) + assert_eq(listed.ok, true) + + local created, create_err = caller:call(topics.update_manager_rpc('create-job'), { + job_id='j-artifact-wait', component='cm5', artifact_ref='artifact-wait', + }, { timeout=0.2 }) + assert_eq(created, nil) + assert_eq(create_err, 'artifact_store_unavailable') + + retain_fake_artifact_store_status(bus, 'available') + + assert_true(probe.wait_until(function() + local status = caller:call(topics.update_manager_rpc('status'), {}, { timeout=0.05 }) + return status and status.snapshot and status.snapshot.state == 'running' + end, { timeout=0.8, interval=0.01 }), 'expected update to admit generation after artifact-store becomes available') + + child:cancel('test complete') + fibers.perform(child:join_op()) + control_scope:cancel('test complete') + fibers.perform(control_scope:join_op()) + end) end return tests diff --git a/tools/check_lua_indentation.lua b/tools/check_lua_indentation.lua new file mode 100644 index 00000000..c244f8a0 --- /dev/null +++ b/tools/check_lua_indentation.lua @@ -0,0 +1,26 @@ +local indentation = require 'tools.lua_indentation' + +local paths = {} + +for i = 1, #arg do + if arg[i]:sub(1, 1) == '-' then + io.stderr:write(('unknown option: %s\n'):format(arg[i])) + os.exit(2) + else + paths[#paths + 1] = arg[i] + end +end + +if #paths == 0 then paths = { 'src', 'tests', 'examples', 'tools' } end + +local violations, total_or_err = indentation.check_paths(paths) +if violations == nil then + io.stderr:write(tostring(total_or_err) .. '\n') + os.exit(2) +end +if violations > 0 then + io.stderr:write(('Lua indentation failed: %d violation(s) in %d files\n'):format(violations, total_or_err)) + os.exit(1) +end + +io.write(('Lua indentation OK (%d files)\n'):format(total_or_err)) diff --git a/tools/lua_indentation.lua b/tools/lua_indentation.lua new file mode 100644 index 00000000..06324955 --- /dev/null +++ b/tools/lua_indentation.lua @@ -0,0 +1,211 @@ +local lfs = require 'lfs' + +local M = {} + +local function long_bracket_open(line, pos) + if line:sub(pos, pos) ~= '[' then return nil end + + local cursor = pos + 1 + while line:sub(cursor, cursor) == '=' do cursor = cursor + 1 end + if line:sub(cursor, cursor) ~= '[' then return nil end + + return line:sub(pos + 1, cursor - 1), cursor + 1 +end + +local function scan_short_string(line, pos, quote, state) + local cursor = pos + while cursor <= #line do + local ch = line:sub(cursor, cursor) + if ch == '\\' then + if cursor == #line then + state.kind = 'short' + state.quote = quote + return #line + 1 + end + cursor = cursor + 2 + elseif ch == quote then + state.kind = nil + state.quote = nil + return cursor + 1 + else + cursor = cursor + 1 + end + end + + return cursor +end + +local function scan_line(line, state) + local cursor = 1 + while cursor <= #line do + if state.kind == 'long' then + local close = ']' .. state.equals .. ']' + local close_at = line:find(close, cursor, true) + if not close_at then return end + + state.kind = nil + state.equals = nil + cursor = close_at + #close + elseif state.kind == 'short' then + cursor = scan_short_string(line, cursor, state.quote, state) + else + local ch = line:sub(cursor, cursor) + local next_ch = line:sub(cursor + 1, cursor + 1) + if ch == '-' and next_ch == '-' then + local equals, after_open = long_bracket_open(line, cursor + 2) + if not equals then return end + + state.kind = 'long' + state.equals = equals + cursor = after_open + elseif ch == '[' then + local equals, after_open = long_bracket_open(line, cursor) + if equals then + state.kind = 'long' + state.equals = equals + cursor = after_open + else + cursor = cursor + 1 + end + elseif ch == "'" or ch == '"' then + state.kind = 'short' + state.quote = ch + cursor = scan_short_string(line, cursor + 1, ch, state) + else + cursor = cursor + 1 + end + end + end +end + +local function split_lines(content) + local lines = {} + local cursor = 1 + + while cursor <= #content do + local newline_at = content:find('\n', cursor, true) + if newline_at then + lines[#lines + 1] = { + text = content:sub(cursor, newline_at - 1), + eol = '\n', + } + cursor = newline_at + 1 + else + lines[#lines + 1] = { + text = content:sub(cursor), + eol = '', + } + break + end + end + + return lines +end + +local function invalid_prefix(line) + if line:match('^[ \t]*$') then return nil end + + local prefix = line:match('^[ \t]+') + if not prefix then return nil end + if prefix:sub(1, 1) == ' ' or prefix:find(' \t', 1, true) then + return prefix + end + + return nil +end + +local function analyse(content) + local state = {} + local rows = split_lines(content) + + for line_number = 1, #rows do + local row = rows[line_number] + local protected_at_start = state.kind ~= nil + if not protected_at_start then + row.invalid_prefix = invalid_prefix(row.text) + end + scan_line(row.text, state) + end + + return rows +end + +function M.check_content(content) + local violations = {} + local rows = analyse(content) + + for line_number = 1, #rows do + if rows[line_number].invalid_prefix then + violations[#violations + 1] = { + line = line_number, + prefix = rows[line_number].invalid_prefix, + } + end + end + + return violations +end + +local function read_file(path) + local file, open_err = io.open(path, 'rb') + if not file then return nil, open_err end + local content = file:read('*a') + file:close() + return content +end + +local function collect_path(path, files) + local attrs, attr_err = lfs.attributes(path) + if not attrs then return nil, attr_err end + + if attrs.mode == 'file' then + if path:match('%.lua$') then files[#files + 1] = path end + return true + end + if attrs.mode ~= 'directory' then return true end + + local entries = {} + for entry in lfs.dir(path) do + if entry ~= '.' and entry ~= '..' then entries[#entries + 1] = entry end + end + table.sort(entries) + + for i = 1, #entries do + local ok, walk_err = collect_path(path .. '/' .. entries[i], files) + if not ok then return nil, walk_err end + end + return true +end + +function M.collect_files(paths) + local files = {} + for i = 1, #paths do + local ok, collect_err = collect_path(paths[i], files) + if not ok then return nil, ('%s: %s'):format(paths[i], tostring(collect_err)) end + end + table.sort(files) + return files +end + +function M.check_paths(paths, output) + output = output or io.stdout + local files, collect_err = M.collect_files(paths) + if not files then return nil, collect_err end + + local violation_count = 0 + for i = 1, #files do + local content, read_err = read_file(files[i]) + if not content then return nil, ('%s: %s'):format(files[i], tostring(read_err)) end + + local violations = M.check_content(content) + for j = 1, #violations do + violation_count = violation_count + 1 + output:write(('%s:%d: Lua indentation must start with tabs; spaces are allowed only for alignment after tabs\n') + :format(files[i], violations[j].line)) + end + end + + return violation_count, #files +end + +return M