From 1b5fad0c98acb97a288c31fdd13c1377ef9cd652 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Thu, 5 Feb 2026 16:23:40 +0000 Subject: [PATCH 01/33] Modem backend, implemented mmcli functions and half done qmi functions --- src/services/hal/backends/modem/iface.lua | 0 .../hal/backends/modem/models/fibocom.lua | 0 .../hal/backends/modem/models/quectel.lua | 0 .../hal/backends/modem/modes/mbim.lua | 0 src/services/hal/backends/modem/modes/qmi.lua | 150 +++++++ src/services/hal/backends/modem/provider.lua | 53 +++ .../modem/providers/linux_mm/getters.lua | 154 +++++++ .../modem/providers/linux_mm/impl.lua | 392 ++++++++++++++++++ .../modem/providers/linux_mm/init.lua | 46 ++ 9 files changed, 795 insertions(+) create mode 100644 src/services/hal/backends/modem/iface.lua create mode 100644 src/services/hal/backends/modem/models/fibocom.lua create mode 100644 src/services/hal/backends/modem/models/quectel.lua create mode 100644 src/services/hal/backends/modem/modes/mbim.lua create mode 100644 src/services/hal/backends/modem/modes/qmi.lua create mode 100644 src/services/hal/backends/modem/provider.lua create mode 100644 src/services/hal/backends/modem/providers/linux_mm/getters.lua create mode 100644 src/services/hal/backends/modem/providers/linux_mm/impl.lua create mode 100644 src/services/hal/backends/modem/providers/linux_mm/init.lua diff --git a/src/services/hal/backends/modem/iface.lua b/src/services/hal/backends/modem/iface.lua new file mode 100644 index 00000000..e69de29b diff --git a/src/services/hal/backends/modem/models/fibocom.lua b/src/services/hal/backends/modem/models/fibocom.lua new file mode 100644 index 00000000..e69de29b diff --git a/src/services/hal/backends/modem/models/quectel.lua b/src/services/hal/backends/modem/models/quectel.lua new file mode 100644 index 00000000..e69de29b diff --git a/src/services/hal/backends/modem/modes/mbim.lua b/src/services/hal/backends/modem/modes/mbim.lua new file mode 100644 index 00000000..e69de29b diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua new file mode 100644 index 00000000..12c6022f --- /dev/null +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -0,0 +1,150 @@ +local fibers = require "fibers" +local exec = require "fibers.io.exec" +local sleep = require "fibers.sleep" +local scope = require "fibers.scope" +local op = require "fibers.op" + +--- Parses the output of a sim slot status line +---@param status string? +---@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)' +end + + +local function add_mode_funcs(ModemBackend) + --- Make an op to listen for sim prescence + ---@return Op + function ModemBackend:wait_for_sim_present_op() + return op.guard(function() + return scope.run_op(function(s) + -- Start QMI monitor command + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.primary_port, "--uim-monitor-slot-status", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + + -- Get stdout stream (starts command automatically) + local stdout, err = cmd:stdout_stream() + if not stdout then + -- Return error result from scope - won't propagate to caller + return false, "Failed to start QMI monitor: " .. tostring(err) + end + + -- Command handles stream cleanup automatically via scope finalizer + + -- Read chunks until we find "Slot status: active" + while true do + local chunk = s:perform(stdout:read_line_op({ + terminator = "Slot status: active", + keep_terminator = true, + })) + + if not chunk then + -- EOF - return error result from scope + return false, "QMI monitor stream closed" + end + + -- Parse the chunk (includes Card status + Slot status lines) + local card_status, parse_err = parse_slot_status(chunk) + + if parse_err == "" and card_status ~= "" then + -- Valid parse - return true if present, false if absent + return card_status == "present" + end + + -- If parse failed, continue reading next chunk + end + end) + :wrap(function(st, _, ...) + if st == 'ok' then + return ... -- Returns the boolean (and optional error string) + elseif st == 'cancelled' then + return false, "cancelled" + else + -- Scope failed - return the error from scope body + return false, (... or "QMI monitor failed") + end + end) + end) + end + + --- Wait for sim to become present + ---@return boolean sim_present + function ModemBackend:wait_for_sim_present() + return fibers.perform(self:wait_for_sim_present_op()) + end + + --- Poll for sim presence, returns true if sim is present, false if not, or error if an error occurs + ---@return boolean sim_present + ---@return string error + function ModemBackend:is_sim_present() + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.primary_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 + return false, "Failed to execute qmicli command: " .. tostring(err) + end + + local state, parse_err = parse_slot_status(out) + if parse_err ~= "" then + return false, "Failed to parse qmicli output: " .. tostring(parse_err) + end + return state == 'present', "" + end + + --- Check for sim presence, will cause a sim_present_op to emit if present + ---@param cooldown number? time to wait between power off and on commands, default 1 second + ---@return boolean ok + ---@return string error + function ModemBackend:trigger_sim_presence_check(cooldown) + cooldown = cooldown or 1 + --- Set power low + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.primary_port, "--uim-sim-power-off=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + return false, "Failed to execute qmicli power off command: " .. tostring(err) + end + + sleep.sleep(cooldown) + + --- Set power high + local cmd_on = exec.command { + "qmicli", "-p", "-d", self.identity.primary_port, "--uim-sim-power-on=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status_on, code_on, _, err_on = fibers.perform(cmd_on:combined_output_op()) + if status_on ~= "exited" or code_on ~= 0 then + return false, "Failed to execute qmicli power on command: " .. tostring(err_on) + end + + return true, "" + end +end + +return { + add_mode_funcs = add_mode_funcs +} diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua new file mode 100644 index 00000000..3d28d83c --- /dev/null +++ b/src/services/hal/backends/modem/provider.lua @@ -0,0 +1,53 @@ +local iface = require "services.hal.backends.modem.iface" + +local backends = { + "linux_mm" +} + +local backend = nil +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.is_supported) == "function" and mod.is_supported() then + backend = mod + break + end +end + +if backend == nil then + error("No supported modem backend found") +end + +--- Apply mode specific functions +---@param mode string +---@return boolean ok +function backend:override_mode(mode) + local ok, mod = pcall(require, "services.hal.backends.modem.modes." .. mode) + if ok and type(mod) == "function" then + mod(self) + return true + end + return false +end + +--- Apply model specific functions +---@param manufacturer string +---@param model string +---@param variant string +---@return boolean ok +function backend:override_model(manufacturer, model, variant) + local ok, mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) + if ok and type(mod) == "function" then + mod(self, model, variant) + return true + end + return false +end + +--- Check that all required backend functions are implemented +---@return boolean ok +---@return string? error +function backend:validate() + return iface.validate_backend(self) +end + +return backend diff --git a/src/services/hal/backends/modem/providers/linux_mm/getters.lua b/src/services/hal/backends/modem/providers/linux_mm/getters.lua new file mode 100644 index 00000000..48e4c00d --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -0,0 +1,154 @@ +--- Modem backend getter methods +--- This module defines all the attribute accessor methods for ModemBackend + +--- Default try to get value from cache, if not present fetch modem info and try again +---@param identity ModemIdentity +---@param key string +---@param cache Cache +---@param ret_type string +---@param timeout number? +---@return any value +---@return string error +local function get_cached_value(identity, key, cache, ret_type, timeout, fetch_fn) + local cached = cache:get(key, timeout) + if cached then + return cached, "" + end + + local err = fetch_fn(identity, cache) + if err ~= "" then + return nil, "Failed to fetch modem info: " .. tostring(err) + end + + local value = cache:get(key, timeout) + if not value then + return nil, "Value not found in cache after refresh: " .. tostring(key) + end + + if type(value) == ret_type then + return value, "" + else + return nil, "Cached value has wrong type: expected " .. ret_type .. ", got " .. type(value) + end +end + +--- Adds all getter methods to the ModemBackend class +---@param ModemBackend table The ModemBackend class table +---@param fetch_modem_info function The fetch function for modem info +local function add_getters(ModemBackend, fetch_modem_info) + --- Gets the modem's IMEI number + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string imei + ---@return string error + function ModemBackend:imei(timeout) + return get_cached_value(self.identity, "imei", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modem's device path + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string device + ---@return string error + function ModemBackend:device(timeout) + return get_cached_value(self.identity, "device", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modem's primary port + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string primary_port + ---@return string error + function ModemBackend:primary_port(timeout) + return get_cached_value(self.identity, "primary_port", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modem's AT ports + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table at_ports + ---@return string error + function ModemBackend:at_ports(timeout) + return get_cached_value(self.identity, "at_ports", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's QMI ports + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table qmi_ports + ---@return string error + function ModemBackend:qmi_ports(timeout) + return get_cached_value(self.identity, "qmi_ports", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's GPS ports + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table gps_ports + ---@return string error + function ModemBackend:gps_ports(timeout) + return get_cached_value(self.identity, "gps_ports", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's network ports + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table net_ports + ---@return string error + function ModemBackend:net_ports(timeout) + return get_cached_value(self.identity, "net_ports", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's ignored ports + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table ignored_ports + ---@return string error + function ModemBackend:ignored_ports(timeout) + return get_cached_value(self.identity, "ignored_ports", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's access technologies + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table access_techs + ---@return string error + function ModemBackend:access_techs(timeout) + return get_cached_value(self.identity, "access_techs", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's SIM path + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string sim + ---@return string error + function ModemBackend:sim(timeout) + return get_cached_value(self.identity, "sim", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modem's drivers + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table drivers + ---@return string error + function ModemBackend:drivers(timeout) + return get_cached_value(self.identity, "drivers", self.cache, "table", timeout, fetch_modem_info) + end + + --- Gets the modem's plugin + ---@param timeout number? Cache timeout in seconds (optional) + ---@return boolean plugin + ---@return string error + function ModemBackend:plugin(timeout) + return get_cached_value(self.identity, "plugin", self.cache, "boolean", timeout, fetch_modem_info) + end + + --- Gets the modem's model + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string model + ---@return string error + function ModemBackend:model(timeout) + return get_cached_value(self.identity, "model", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modem's revision + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string revision + ---@return string error + function ModemBackend:revision(timeout) + return get_cached_value(self.identity, "revision", self.cache, "string", timeout, fetch_modem_info) + end +end + +return { + add_getters = add_getters +} diff --git a/src/services/hal/backends/modem/providers/linux_mm/impl.lua b/src/services/hal/backends/modem/providers/linux_mm/impl.lua new file mode 100644 index 00000000..edd065d3 --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -0,0 +1,392 @@ +-- service modules +local modem_types = require "services.hal.types.modem" +local log = require "services.log" + +-- Fiber modules +local fibers = require "fibers" +local exec = require "fibers.io.exec" +local op = require "fibers.op" +local fiber = require "fibers.fiber" +local scope = require "fibers.scope" + +-- Other modules +local cache_mod = require "shared.cache" +local json = require "cjson.safe" +local getters = require "services.hal.backends.modem.providers.linux_mm.getters" + + +---- Constants ---- +local CACHE_TIMEOUT = 10 -- seconds + +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" }, +} + + +---- Private functions ---- + +--- Format nested table into k-v pairs +---@param nested table +---@param key_paths table +---@return table +---@return string[] errors +local function nested_to_flat(nested, key_paths) + local flat = {} + local errors = {} + for key, path in pairs(key_paths) do + local value = nested + for _, p in ipairs(path) do + if type(value) ~= 'table' then + table.insert(errors, "Expected table at path " .. table.concat(path, ".") .. ", got " .. type(value)) + break + end + value = value[p] + if value == nil then + table.insert(errors, "Missing value at path " .. table.concat(path, ".")) + break + end + end + if value ~= nil then + flat[key] = value + end + end + return flat, errors +end + +--- Formats each port from a string list of "name (type)" to a map of type_ports:name[] +--- Returns a table of cache keys to be stored separately +---@param ports string[] +---@return table cache_entries Map of cache keys to values +local function format_ports(ports) + local cache_entries = {} + for _, port in ipairs(ports) do + local name, type = port:match("^(.*) %((.*)%)$") + if name and type then + local key = type .. "_ports" + if not cache_entries[key] then + cache_entries[key] = {} + end + table.insert(cache_entries[key], name) + else + log.warn("Failed to parse port string: " .. tostring(port)) + end + end + return cache_entries +end + +-- Post-processors transform field values before caching +-- Each processor must return a table of {key = value} pairs to cache +-- Example: ports -> {at_ports = [...], qmi_ports = [...]} +local FIELD_POST_PROCESSORS = { + ports = format_ports, -- Expands to at_ports, qmi_ports, etc. +} + + +--- Fetches modem info using mmcli and caches it +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_modem_info(identity, cache) + local cmd = exec.command { + "mmcli", "-J", "-m", identity.address, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + return "mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output) + end + + local data, json_err = json.decode(output) + if not data then + return "Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) + end + + local modem = data.modem + + local flat, errors = nested_to_flat(modem, MODEM_INFO_PATHS) + if #errors > 0 then + log.warn("Errors formatting modem info: " .. table.concat(errors, ";\n\t")) + end + + -- Apply post-processors to transform fields before caching + for field_name, processor in pairs(FIELD_POST_PROCESSORS) do + if flat[field_name] then + local cache_entries = processor(flat[field_name]) + for k, v in pairs(cache_entries) do + cache:set(k, v) + end + flat[field_name] = nil -- Remove original field since we cached the processed entries + end + end + + for k, v in pairs(flat) do + cache:set(k, v) + end + return "" +end + +--- Returns all attributes needed for modem identity +---@param address string +---@param cache Cache +---@return ModemIdentity identity +local function get_identity(address, cache) + -- Build a temp id + local fake_id = assert(modem_types.new.ModemIdentity("unknown", address, "unknown", "unknown", "unknown")) + local err = fetch_modem_info(fake_id, cache) -- We need modem info to build the identity + if err ~= "" then + error("Failed to fetch modem info for identity: " .. tostring(err)) -- Fatal error if we cannot get modem info + end + + local imei = cache:get("imei") + local primary_port = cache:get("primary_port") + local device = cache:get("device") + local at_ports = cache:get("at_ports") + local at_port + if at_ports and type(at_ports) == "table" then + at_port = at_ports[1] + end + + local id, id_err = modem_types.new.ModemIdentity( + imei, + address, + primary_port, + at_port, + device + ) + if not id then + error("Failed to get modem identity: " .. tostring(id_err)) -- Fatal error if we cannot build the identity + end + + return id +end + +--- Parses the output of a modem state line +---@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 + + -- Remove leading/trailing whitespace + line = line:match("^%s*(.-)%s*$") + + -- Pattern 1: Initial state, 'state' + local initial_state = line:match(": Initial state, '([^']+)'") + if initial_state then + return modem_types.new.ModemStateInitialEvent(initial_state, "initial") + end + + -- Pattern 2: State changed, 'old' --> 'new' (Reason: reason) + 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 + + -- Pattern 3: Removed + if line:match(": Removed") then + return modem_types.new.ModemStateRemovedEvent("removed") + end + + -- Unknown format + return nil, "Unknown modem state line format: " .. line +end + + +---- Public backend interface ---- + +---@class ModemBackend +---@field identity ModemIdentity +---@field cache Cache +---@field inhibit_cmd Command|nil +---@field imei fun(timeout: number?): string, string +---@field device fun(timeout: number?): string, string +---@field primary_port fun(timeout: number?): string, string +---@field at_ports fun(timeout: number?): table, string +---@field qmi_ports fun(timeout: number?): table, string +---@field gps_ports fun(timeout: number?): table, string +---@field net_ports fun(timeout: number?): table, string +---@field ignored_ports fun(timeout: number?): table, string +---@field access_techs fun(timeout: number?): table, string +---@field sim fun(timeout: number?): string, string +---@field drivers fun(timeout: number?): table, string +---@field plugin fun(timeout: number?): boolean, string +---@field model fun(timeout: number?): string, string +---@field revision fun(timeout: number?): string, string +local ModemBackend = {} +ModemBackend.__index = ModemBackend + +-- Add all getter methods to ModemBackend +getters.add_getters(ModemBackend, fetch_modem_info) + +--- Enable the modem +---@return string result +---@return number exit_code +function ModemBackend:enable() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-e", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + return "mmcli command failed to execute: " .. tostring(err), code + end + return output, code +end + +--- Disable the modem +---@return string result +---@return number exit_code +function ModemBackend:disable() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-d", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + return "mmcli command failed to execute: " .. tostring(err), code + end + return output, code +end + +--- Reset the modem +---@return string result +---@return number exit_code +function ModemBackend:reset() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--reset", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + return "mmcli command failed to execute: " .. tostring(err), code + end + return output, code +end + +--- Connect the modem +---@param conn_string string +---@return string result +---@return number exit_code +function ModemBackend:connect(conn_string) + local full_conn_string = "--simple-connect=" .. conn_string + local cmd = exec.command { + "mmcli", "-m", self.identity.address, full_conn_string, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + return "mmcli command failed to execute: " .. tostring(err), code + end + return output, code +end + +--- Disconnect the modem +---@return string result +---@return number exit_code +function ModemBackend:disconnect() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--simple-disconnect", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + return "mmcli command failed to execute: " .. tostring(err), code + end + return output, code +end + +--- Inhibit the modem +---@return string result +---@return number exit_code +function ModemBackend:inhibit() + if self.inhibit_cmd then + return "Modem is already inhibited", 1 + end + + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--inhibit", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + + -- Accessing stdout_stream() triggers the command to start + -- The command will run in the background, managed by the scope + local stream, err = cmd:stdout_stream() + if not stream then + return "Failed to start inhibit command: " .. tostring(err), 1 + end + + self.inhibit_cmd = cmd + return "Modem inhibit started", 0 +end + +--- Uninhibit the modem +---@return string result +---@return number exit_code +function ModemBackend:uninhibit() + if not self.inhibit_cmd then + return "Modem is not inhibited", 0 + end + + self.inhibit_cmd:kill() + self.inhibit_cmd = nil + + return "Modem uninhibited", 0 +end + +--- Listen for modem state changes +---@return Op +function ModemBackend:monitor_state_op() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-w", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local stream, err = cmd:stdout_stream() + if not stream then + error("Failed to start monitor state command: " .. tostring(err)) + end + return stream:read_line_op():wrap(parse_modem_state_line) +end + + + +--- Builds the backend instance +--- @return ModemBackend +local function new(address) + local cache = cache_mod.new(CACHE_TIMEOUT, nil, '.') + local self = { + cache = cache, + identity = get_identity(address, cache), + } + return setmetatable(self, ModemBackend) +end + +return { + new = new +} diff --git a/src/services/hal/backends/modem/providers/linux_mm/init.lua b/src/services/hal/backends/modem/providers/linux_mm/init.lua new file mode 100644 index 00000000..c3e4781b --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -0,0 +1,46 @@ +local file = require "fibers.io.file" +local exec = require "fibers.io.exec" +local op = require "fibers.op" + +local function is_linux() + local fh = file.open("/proc/version", "r") + if not fh then + return false + end + + local content = fh:read("*a") + fh:close() + if not content 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, _, err = op.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() + return is_linux() and has_mmcli() +end + +return { + is_supported = is_supported, + backend = backend +} + From 8d39f7c3e2f530e0c4ebada90d04b3703b7a70c6 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Thu, 5 Feb 2026 16:24:57 +0000 Subject: [PATCH 02/33] modem driver initial design --- src/services/hal/drivers/modem.lua | 553 +++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 src/services/hal/drivers/modem.lua diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua new file mode 100644 index 00000000..9d39b4b9 --- /dev/null +++ b/src/services/hal/drivers/modem.lua @@ -0,0 +1,553 @@ +-- Modem modules +local modem_types = require "services.hal.types.modem" +local attr_paths = require "services.hal.drivers.modem.attr_paths" + +-- HAL modules +local hal_types = require "services.hal.types.core" +local cap_types = require "services.hal.types.capabilities" +local external_types = require "services.hal.types.external" + +-- Service modules +local log = require "services.log" + +-- Fibers modules +local fibers = require "fibers" +local scope_mod = require "fibers.scope" +local op = require "fibers.op" +local channel = require "fibers.channel" +local sleep = require "fibers.sleep" + +-- Other modules +local cache = require "shared.cache" + +---@class Modem +---@field address ModemAddress +---@field control_ch Channel +---@field cap_emit_ch Channel +---@field scope Scope +---@field identity ModemIdentity +---@field model string +---@field model_variant string +---@field mode string +---@field initialised boolean +---@field caps_applied boolean +---@field cache Cache +local Modem = {} +Modem.__index = Modem + +---- Constant Definitions ---- + +local DEFAULT_STOP_TIMEOUT = 5 +local DEFAULT_CACHE_TIMEOUT = 10 + +local CONTROL_Q_LEN = 8 + +local ATTR_ACCESSOR = attr_paths.build_paths { + get_modem_info = get_modem_info, + get_modem_firmware = get_modem_firmware, + get_sim_info = get_sim_info, + get_home_network = get_home_network, + get_gid1 = get_gid1, + get_rf_band_info = get_rf_band_info, + get_operator_info = get_operator_info, + get_signal_info = get_signal_info, + get_net_stats = get_net_stats, +} + +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" } + -- more quectel models here + }, + fibocom = {} +} + + +---- Modem Utility Functions ---- + +--- Emit from the modem capability +---@param emit_ch Channel +---@param imei string +---@param mode EmitMode +---@param key string +---@param data any +---@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 +end + +--- Emit a set of key-value pairs from the modem capability +---@param emit_ch Channel +---@param imei string +---@param kv_data table +---@return boolean ok +---@return string? error +local function emit_kv(emit_ch, imei, kv_data) + local all_ok = true + local first_err = nil + for key, value in pairs(kv_data) do + local ok, err = emit(emit_ch, imei, 'state', key, value) + if not ok then + all_ok = false + if not first_err then + first_err = err + end + end + end + return all_ok, first_err +end + +--- Utility function to throw a ControlError +---@param err string? +---@param code integer? +local function throw_error(err, code) + if err == nil then + err = "unknown error" + end + error(cap_types.new.ControlError(err, code), 2) +end + +--- Emit an event. +---@param key string +---@param data any +---@return boolean ok +---@return string? error +function Modem:_emit_event(key, data) + return emit(self.cap_emit_ch, self.identity.imei, 'event', key, data) +end + +--- Emit a state. +---@param key string +---@param data any +---@return boolean ok +---@return string? error +function Modem:_emit_state(key, data) + return emit(self.cap_emit_ch, self.identity.imei, 'state', key, data) +end + +--- Emit meta information. +---@param key string +---@param data any +---@return boolean ok +---@return string? error +function Modem:_emit_meta(key, data) + return emit(self.cap_emit_ch, self.identity.imei, 'meta', key, data) +end + +--- Emit a set of key-value events. +---@param kv_data table +---@return boolean ok +---@return string? error +function Modem:_emit_kv(kv_data) + return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) +end + +--- Emit a set of key-value states. +---@param kv_data table +---@return boolean ok +---@return string? error +function Modem:_emit_kv_state(kv_data) + return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) +end + +--- Emit a set of key-value meta information. +---@param kv_data table +---@return boolean ok +---@return string? error +function Modem:_emit_kv_meta(kv_data) + return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) +end + +--- Validate that a function is implemented +---@param fn any +---@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 +end + +---- Modem Capabilities ---- + +--- Get a modem attribute. +---@param opts ModemGetOpts? +---@return any value +---@return string? error +function Modem:get(opts) + if opts == nil or getmetatable(opts) ~= external_types.ModemGetOpts then + return nil, "invalid options" + end + local field = opts.field + local timescale = opts.timescale or math.huge + + local accessor = ATTR_ACCESSOR[field] + if not accessor then + throw_error("unknown field: " .. tostring(field)) + end + + -- try to find value in cache + local method = accessor.method + local key = method + if accessor.path ~= '' then + key = key .. "." .. accessor.path + end + local value, err = self.cache:get(key, timescale) + if err then throw_error(err) end + + -- if not found in cache, or stale + if value == nil then + local valid, validation_err = validate_fn(accessor.gettr) + if not valid then + return throw_error(validation_err) + end + + local info, info_err = accessor.gettr(self.identity) + if not info then throw_error(info_err) end + local values = attr_paths.flatten_table(info) + for k, v in pairs(values) do + self.cache:set(k, v) + end + + local ok, emit_err = self:_emit_kv_meta(values) + if not ok then + log.debug("Modem Driver", self.identity.imei, "emit_kv_meta error:", emit_err) + end + + value = values[key] + end + + if value == nil then + throw_error("no value for field: " .. tostring(field)) + end + + return value +end + +---- Long Running Fibers ---- + +--- Get reason and code from a ControlError +---@param control_err any +---@param verb string +local function get_reason_and_code(control_err, verb) + if getmetatable(control_err) == cap_types.ControlError then + ---@cast control_err ControlError + return control_err.reason, control_err.code + end + + local reason = "function " .. tostring(verb) .. " did not return a valid ControlError" + return reason, 1 +end + +function Modem:state_monitor() +end + +function Modem:control_manager() + if self.cap_emit_ch == nil then + log.error("Modem Driver", self.identity.imei, "control_manager: cap_emit_ch is nil") + return + end + if self.control_ch == nil then + log.error("Modem Driver", self.identity.imei, "control_manager: control_ch is nil") + return + end + + log.trace("Modem Driver", self.identity.imei, "control_manager: started") + + while true do + local request, req_err = self.control_ch:get() + if not request then + log.error("Modem Driver", self.identity.imei, "control_manager: control_ch get error:", 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 status, _, primary_or_val = fibers.run_scope(fn, self, request.opts) + -- reason field holds the error in case of failure, or the return value in case of success + if status == 'ok' then + ok = true + reason = primary_or_val + else + reason, code = get_reason_and_code(primary_or_val, request.verb) + ok = false + end + end + + local reply, reply_err = hal_types.new.Reply(ok, reason, code) + if not reply then + log.error("Modem Driver", self.identity.imei, "control_manager: failed to create reply:", reply_err) + else + request.reply_ch:put(reply) + end + end + + log.trace("Modem Driver", self.identity.imei, "control_manager: exiting") +end + +---- Driver Functions ---- + +local function format_ports(ports) + local port_list = {} + + -- ports is now a comma-separated string + if type(ports) == "string" then + for port in ports:gmatch("[^,]+") do + port = port:match("^%s*(.-)%s*$") -- trim whitespace + local port_name, port_type = string.match(port, "^([%w%-]+)%s*%(([%w%-]+)%)") + if port_name and port_type then + if port_list[port_type] == nil then + port_list[port_type] = { port_name } + else + table.insert(port_list[port_type], port_name) + end + end + end + end + + return port_list +end + +--- Utility function to check if a string starts with a given prefix (case-insensitive) +---@param str string +---@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 +end + + +--- Get driver identity +---@return ModemIdentity? identity +---@return string error +function Modem:get_identity() + if not self.initialised then + return nil, "modem not initialised" + end + return self.identity, "" +end + +--- Spawn driver services +---@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 + + self.scope:spawn(self.state_monitor, self) + self.scope:spawn(self.control_manager, self) + + return true, "" +end + +--- Closes down the modem driver +---@param timeout number? Timeout in seconds +---@return boolean ok +---@return string error +function Modem: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, "modem stop timeout" + end + return true, "" +end + +--- Apply capabilities to HAL and start monitoring state +--- Modem must be initialised first +---@param emit_ch Channel +---@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 + + self.cap_emit_ch = emit_ch + + local modem_cap, mod_cap_err = cap_types.new.ModemCapability( + 'modem', + self.identity.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 + + 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 + -- determine mode (qmi/mbim) from drivers to apply mode-specific overrides + local drivers, drivers_err = self:get { field = 'drivers' } + if not drivers then + return "failed to get drivers: " .. tostring(drivers_err) + end + + if drivers:match("qmi_wwan") then + self.mode = 'qmi' + elseif drivers:match("cdc_mbim") then + self.mode = 'mbim' + end + + assert(mode_overrides.add_mode_funcs(self)) + + -- identify model to apply model-specific overrides + local plugin, plugin_err = self:get { field = 'plugin' } + if not plugin then + return "failed to get plugin: " .. tostring(plugin_err) + end + + local model, model_err = self:get { field = 'model' } + if not model then + return "failed to get model: " .. tostring(model_err) + end + + local revision, revision_err = self:get { field = 'revision' } + if not revision then + return "failed to get revision: " .. tostring(revision_err) + end + + 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 + log.info("Modem Driver", self.identity.imei, + "identified model as", details.model, + "variant", details.model_variant) + self.model = details.model + self.model_variant = details.model_variant + break + end + end + end + end + + model_overrides.add_model_funcs(self) + + -- obtain essential identity information + local imei, imei_err = self:get { field = 'imei' } + if not imei then + return "failed to get imei: " .. tostring(imei_err) + end + + local primary_port, primary_port_err = self:get { field = 'primary_port' } + if not primary_port then + return "failed to get primary_port: " .. tostring(primary_port_err) + end + + local ports, ports_err = self:get { field = 'ports' } + if not ports then + return "failed to get ports: " .. tostring(ports_err) + end + + local fmt_ports = format_ports(ports) + if (not fmt_ports.at) or (not fmt_ports.at[1]) then + return "no AT port found" + end + + local device, device_err = self:get { field = 'device' } + if not device then + return "failed to get device: " .. tostring(device_err) + end + + local id, id_err = modem_types.new.ModemIdentity( + imei, + self.address, + primary_port, + fmt_ports.at[1], + device + ) + + if not id then + return "failed to create modem identity: " .. tostring(id_err) + end + + self.identity = id + + self.initialised = true + + return "" +end + +--- Create a new Modem driver. +---@param scope Scope +---@param address ModemAddress +---@return Modem? modem +---@return string error +local function new(scope, address) + if getmetatable(scope) ~= scope_mod.Scope then + return nil, "invalid scope" + end + if type(address) ~= 'string' or address == '' then + return nil, "invalid address" + end + + local control_ch = channel.new(CONTROL_Q_LEN) + + return setmetatable({ + scope = scope, + address = address, + initialised = false, -- modem cannot apply capabilities until initialised + caps_applied = false, -- modem cannot start until capabilities applied + control_ch = control_ch, + cache = cache.new(DEFAULT_CACHE_TIMEOUT, nil, '.') + }, Modem), "" +end + +return { + new +} From 80e20bf3cb416d1ab4dbb4f994f6c57fc4a055c0 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Thu, 5 Feb 2026 16:25:25 +0000 Subject: [PATCH 03/33] modemcard manager initial design --- src/services/hal/managers/modemcard.lua | 285 ++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/services/hal/managers/modemcard.lua diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua new file mode 100644 index 00000000..7d2bf54d --- /dev/null +++ b/src/services/hal/managers/modemcard.lua @@ -0,0 +1,285 @@ +-- HAL modules +local mmcli = require "services.hal.backends.mmcli" +local hal_types = require "services.hal.types.core" +local modem_types = require "services.hal.types.modem" +local modem_driver = require "services.hal.drivers.modem" + +-- Fiber modules +local fibers = require "fibers" +local scope_mod = require "fibers.scope" +local op = require "fibers.op" +local channel = require "fibers.channel" +local sleep = require "fibers.sleep" + +-- Other modules +local log = require "services.log" + + +-- Constants + +local STOP_TIMEOUT = 5.0 -- seconds + +---@class ModemDriver + +---@class ModemcardManager +---@field spawned boolean +---@field modem_remove_ch Channel +---@field modem_detect_ch Channel +---@field driver_ch Channel +---@field modems table +local ModemcardManager = { + spawned = false, + modem_remove_ch = channel.new(), + modem_detect_ch = channel.new(), + driver_ch = channel.new(), + modems = {}, +} + +---Get a modem status and address from a monitor line +---@param line string +---@return boolean? is_added +---@return string? address +---@return string? err +local function parse_monitor(line) + local status, address = line:match("^(.-)(/org%S+)") + if address then + return not status:match("-"), address, nil + else + return nil, nil, 'line could not be parsed' + end +end + +---Continuously monitors modem add/remove events and publishes them onto +---`ModemcardManager.modem_detect_ch` and `ModemcardManager.modem_remove_ch`. +local function detector() + log.trace("Modem Detector: started") + + local monitor_cmd = mmcli.monitor_modems() + local stdout, err = monitor_cmd:stdout_stream() + if not stdout then + log.error("Modem Detector: failed to open stdout stream:", err) + log.trace("Modem Detector: closed") + return + end + + while true do + local line, rerr = stdout:read_line() + if rerr then + log.error("Modem Detector: stdout read error:", rerr) + break + end + if line == nil then + break + end + + local is_added, address, parse_err = parse_monitor(line) + + if is_added == nil or address == nil then + log.error("Modem Detector: failed to parse line:", parse_err) + elseif is_added == true then + log.info("Modem Detector: detected at", address) + ModemcardManager.modem_detect_ch:put(address) + elseif is_added == false then + log.info("Modem Detector: removed at", address) + ModemcardManager.modem_remove_ch:put(address) + end + end + + log.trace("Modem Detector: closed") +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("Modemcard Manager: invalid address on removal") + return + end + + log.info("Modemcard Manager: removing modem at", address) + + local driver = ModemcardManager.modems[address] + if driver == nil then + log.error("Modemcard Manager: modem not found for removal at", address) + return + end + + local identity, id_err = driver:get_identity() + if not identity then + log.error("Modemcard Manager: failed to get identity for removal at", address, id_err) + return + end + local device = identity.device + + fibers.spawn(function() + local ok, stop_err = driver:stop(STOP_TIMEOUT) + if not ok then + log.error("Modemcard Manager: failed to stop driver:", 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("Modemcard Manager: failed to create device event:", 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("Modemcard Manager: invalid address on detection") + return + end + + log.info("Modemcard Manager: creating modem at", address) + + -- Create a child scope for the driver + local child_scope = fibers.current_scope():child() + local driver, drv_err = modem_driver.new(child_scope, address) + if not driver then + log.error("Modemcard Manager: failed to create modem driver:", drv_err) + return + end + + fibers.spawn(function() + local init_err = driver:init() + if init_err ~= "" then + log.error("Modemcard Manager: failed to init modem driver:", init_err) + return + end + ModemcardManager.driver_ch:put(driver) + end) +end + +---Handle a fully initialized driver by creating the modem device, applying +---capabilities, and emitting a HAL device event. +---@param dev_ev_ch Channel Device event channel (DeviceEvent messages) +---@param cap_emit_ch Channel Capability emit channel (Emit messages) +---@param driver Modem +---@return nil +local function on_driver(dev_ev_ch, cap_emit_ch, driver) + if getmetatable(driver) ~= modem_driver.ModemDriver then + log.error("Modemcard Manager: invalid driver received") + return + end + + local identity, id_err = driver:get_identity() + if not identity then + log.error("Modemcard Manager: failed to get driver identity:", id_err) + return + end + + ModemcardManager.modems[identity.address] = driver + + -- Build capabilities + local capabilities, cap_err = driver:capabilities(cap_emit_ch) + if cap_err then + log.error("Modemcard Manager: failed to apply capabilities:", cap_err) + return + end + + -- Start the driver + local ok, start_err = driver:start() + if not ok then + log.error("Modemcard Manager: failed to start driver:", start_err) + return + end + + local device_event, ev_err = hal_types.new.DeviceEvent( + "added", + "modemcard", + identity.device, + { + address = identity.address, + port = identity.device -- the device field holds the usb or pcie port info + }, + capabilities + ) + if not device_event then + log.error("Modemcard Manager: failed to create device event:", ev_err) + return + end + + -- Notify HAL of new modem device + dev_ev_ch:put(device_event) +end + +---Modemcard Manager notifies HAL of modem additions/removals. +---@param dev_ev_ch Channel Device event channel (DeviceEvent messages) +---@param cap_emit_ch Channel Capability emit channel (Emit messages) +---@return nil +local function manager(dev_ev_ch, cap_emit_ch) + log.trace("Modemcard Manager: started") + while true do + 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(), + }) + + if not msg then + log.error("Modemcard Manager: operation failed:", 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) + end + end + + log.trace("Modemcard Manager: closed") +end + +---Starts the Modemcard Manager's detector and manager fibers. +---@param scope Scope +---@param dev_ev_ch Channel +---@param cap_emit_ch Channel +function ModemcardManager.start(scope, dev_ev_ch, cap_emit_ch) + if ModemcardManager.spawned then + log.warn("Modemcard Manager: already spawned") + return + end + + ModemcardManager.scope = scope + + scope:spawn(detector) + scope:spawn(manager, dev_ev_ch, cap_emit_ch) + + ModemcardManager.spawned = true + log.trace("Modemcard Manager: spawned") +end + +---Stops the Modemcard Manager. +---@param timeout number? Timeout in seconds +---@return boolean ok +---@return string error +function ModemcardManager.stop(timeout) + timeout = timeout or STOP_TIMEOUT + ModemcardManager.scope:cancel() + + 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 + return true, "" +end From 6e3f345ef6d4f198de74ac0f5aca0611f15b02f5 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:54:46 +0000 Subject: [PATCH 04/33] added qmi functions --- src/services/hal/backends/modem/modes/qmi.lua | 122 +++++++++++++++++- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua index 12c6022f..b1cf8d46 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -4,6 +4,8 @@ local sleep = require "fibers.sleep" local scope = require "fibers.scope" local op = require "fibers.op" +local fetch = require "services.hal.backends.fetch" + --- Parses the output of a sim slot status line ---@param status string? ---@return string card_status @@ -21,16 +23,94 @@ local function parse_slot_status(status) return "", 'could not parse (no active slot or invalid string format)' end +--- Gets the home network info (MCC/MNC) for a modem, with caching +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_home_network_info(identity, cache) + 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 + return "Failed to execute qmicli command: " .. tostring(err) + end + + local mcc = out:match("MCC:%s+'(%d+)'") + local mnc = out:match("MNC:%s+'(%d+)'") + if not mcc or not mnc then + return "Failed to parse qmicli output: " .. tostring(out) + end + + cache:set("mcc", mcc) + cache:set("mnc", mnc) + return "" +end + +--- Gets gid1 for a sim card and caches it +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_gid1(identity, cache) + 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 + return "Failed to execute qmicli command: " .. tostring(err) + end + + -- Parse the hex string after "Read result:" + local gid1 = out:match("Read result:%s*([%x:]+)") + if not gid1 then + return "Failed to parse qmicli output: " .. tostring(out) + end + + cache:set("gid1", gid1) + return "" +end + +--- Gets the RF band info for a modem and caches active_band_class +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_rf_band_info(identity, cache) + 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 + return "Failed to execute qmicli command: " .. tostring(err) + end + + local active_band_class = out:match("Active Band Class:%s*'([^']+)'") + if not active_band_class then + return "Failed to parse qmicli output: " .. tostring(out) + end + + cache:set("active_band_class", active_band_class) + return "" +end + local function add_mode_funcs(ModemBackend) - --- Make an op to listen for sim prescence + --- Make an op to listen for sim presence ---@return Op function ModemBackend:wait_for_sim_present_op() return op.guard(function() return scope.run_op(function(s) -- Start QMI monitor command local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.primary_port, "--uim-monitor-slot-status", + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-monitor-slot-status", stdin = "null", stdout = "pipe", stderr = "stdout" @@ -92,7 +172,7 @@ local function add_mode_funcs(ModemBackend) ---@return string error function ModemBackend:is_sim_present() local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.primary_port, "--uim-get-card-status", + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-get-card-status", stdin = "null", stdout = "pipe", stderr = "stdout" @@ -117,7 +197,7 @@ local function add_mode_funcs(ModemBackend) cooldown = cooldown or 1 --- Set power low local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.primary_port, "--uim-sim-power-off=1", + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-off=1", stdin = "null", stdout = "pipe", stderr = "stdout" @@ -131,7 +211,7 @@ local function add_mode_funcs(ModemBackend) --- Set power high local cmd_on = exec.command { - "qmicli", "-p", "-d", self.identity.primary_port, "--uim-sim-power-on=1", + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-on=1", stdin = "null", stdout = "pipe", stderr = "stdout" @@ -143,6 +223,38 @@ local function add_mode_funcs(ModemBackend) return true, "" end + + --- Gets a simcards MCC + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string mcc + ---@return string error + function ModemBackend:mcc(timeout) + return fetch.get_cached_value(self.identity, "mcc", self.cache, "string", timeout, fetch_home_network_info) + end + + --- Gets a simcards MNC + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string mnc + ---@return string error + function ModemBackend:mnc(timeout) + return fetch.get_cached_value(self.identity, "mnc", self.cache, "string", timeout, fetch_home_network_info) + end + + --- Gets a simcards GID1 value + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string gid1 + ---@return string error + function ModemBackend:gid1(timeout) + return fetch.get_cached_value(self.identity, "gid1", self.cache, "string", timeout, fetch_gid1) + end + + --- Gets the active band class for the modem + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string active_band_class + ---@return string error + function ModemBackend:active_band_class(timeout) + return fetch.get_cached_value(self.identity, "active_band_class", self.cache, "string", timeout, fetch_rf_band_info) + end end return { From 5b4eea4aac79ba7cda130d1e8c16d0ba47ed5b47 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:55:28 +0000 Subject: [PATCH 05/33] Defined initial interface spec, checks for provided functions and nothing more --- src/services/hal/backends/modem/iface.lua | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/services/hal/backends/modem/iface.lua b/src/services/hal/backends/modem/iface.lua index e69de29b..294b00bc 100644 --- a/src/services/hal/backends/modem/iface.lua +++ b/src/services/hal/backends/modem/iface.lua @@ -0,0 +1,69 @@ +local function list_to_map(list) + local map = {} + for _, item in ipairs(list) do + map[item] = true + end + return map +end + +local BACKEND_FUNCTIONS = list_to_map { + -- Getters + "imei", + "device", + "primary_port", + "at_ports", + "qmi_ports", + "gps_ports", + "net_ports", + "access_techs", + "sim", + "drivers", + "plugin", + "model", + "revision", + "operator", + "rx_bytes", + "tx_bytes", + "signal", + "mcc", + "mnc", + "gid1", + "active_band_class", + + -- others + "enable", + "disable", + "reset", + "connect", + "disconnect", + "inhibit", + "uninhibit", + "monitor_state_op", + "wait_for_sim_present_op", + "wait_for_sim_present", + "is_sim_present", + "trigger_sim_presence_check", +} + +--- 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 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 "" +end + +return { + validate = validate +} From 37271775ef7d0a132e4f0e967eb1d67c1ba67605 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:56:06 +0000 Subject: [PATCH 06/33] Added signal getting and changed function signatures --- .../modem/providers/linux_mm/impl.lua | 214 +++++++++++++----- 1 file changed, 160 insertions(+), 54 deletions(-) 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 edd065d3..d3c6be26 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/impl.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -15,6 +15,14 @@ local json = require "cjson.safe" local getters = require "services.hal.backends.modem.providers.linux_mm.getters" +local function list_to_map(list) + local map = {} + for _, item in ipairs(list) do + map[item] = true + end + return map +end + ---- Constants ---- local CACHE_TIMEOUT = 10 -- seconds @@ -29,6 +37,20 @@ local MODEM_INFO_PATHS = { plugin = { "generic", "plugin" }, model = { "generic", "model" }, revision = { "generic", "revision" }, + operator = { "3gpp", "operator-name" }, +} + +local SIGNAL_TECHNOLOGIES = list_to_map { + "5g", + "cdma1x", + "evdo", + "gsm", + "lte", + "umts" +} + +local SIGNAL_IGNORE_FIELDS = list_to_map { + "error-rate" } @@ -136,32 +158,110 @@ local function fetch_modem_info(identity, cache) return "" end +--- Get the table values from the active signal tech +---@param signal_techs table +---@return table signals +---@return string error +local function get_active_signal(signal_techs) + for tech, signals in pairs(signal_techs) do + if SIGNAL_TECHNOLOGIES[tech] then + local active_signal = false + local filtered_fields = {} + for signal_name, signal_value in pairs(signals) do + if not SIGNAL_IGNORE_FIELDS[signal_name] then + filtered_fields[signal_name] = signal_value + active_signal = true + end + end + if active_signal then + return filtered_fields, "" + end + end + end + return {}, "No active signal found" +end + +--- Fetches signal info using mmcli and caches it +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_signal_info(identity, cache) + local cmd = exec.command { + "mmcli", "-J", "-m", identity.address, "--signal-get", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + return "mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output) + end + + local data, json_err = json.decode(output) + if not data then + return "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 not signal_techs then + return "No signal info found in mmcli output: " .. tostring(output) + end + + local active_signal, active_err = get_active_signal(signal_techs) + if active_err ~= "" then + return "Failed to get active signal: " .. tostring(active_err) + end + cache:set("signal", active_signal) + return "" +end + + --- Returns all attributes needed for modem identity ---@param address string ---@param cache Cache ---@return ModemIdentity identity local function get_identity(address, cache) -- Build a temp id - local fake_id = assert(modem_types.new.ModemIdentity("unknown", address, "unknown", "unknown", "unknown")) + local fake_id = assert(modem_types.new.ModemIdentity( + "unknown", + address, + "unknown", + "unknown", + "unknown", + "unknown" + )) local err = fetch_modem_info(fake_id, cache) -- We need modem info to build the identity if err ~= "" then error("Failed to fetch modem info for identity: " .. tostring(err)) -- Fatal error if we cannot get modem info end local imei = cache:get("imei") - local primary_port = cache:get("primary_port") local device = cache:get("device") + + local qmi_ports = cache:get("qmi_ports") + local qmi_port + if qmi_ports and type(qmi_ports) == "table" then + qmi_port = qmi_ports[1] + end + local at_ports = cache:get("at_ports") local at_port if at_ports and type(at_ports) == "table" then at_port = at_ports[1] end + local net_ports = cache:get("net_ports") + local net_port + if net_ports and type(net_ports) == "table" then + net_port = net_ports[1] + end + local id, id_err = modem_types.new.ModemIdentity( imei, address, - primary_port, + qmi_port, at_port, + net_port, device ) if not id then @@ -211,29 +311,37 @@ end ---@field identity ModemIdentity ---@field cache Cache ---@field inhibit_cmd Command|nil ----@field imei fun(timeout: number?): string, string ----@field device fun(timeout: number?): string, string ----@field primary_port fun(timeout: number?): string, string ----@field at_ports fun(timeout: number?): table, string ----@field qmi_ports fun(timeout: number?): table, string ----@field gps_ports fun(timeout: number?): table, string ----@field net_ports fun(timeout: number?): table, string ----@field ignored_ports fun(timeout: number?): table, string ----@field access_techs fun(timeout: number?): table, string ----@field sim fun(timeout: number?): string, string ----@field drivers fun(timeout: number?): table, string ----@field plugin fun(timeout: number?): boolean, string ----@field model fun(timeout: number?): string, string ----@field revision fun(timeout: number?): string, string +---@field imei fun(self: ModemBackend, timeout: number?): string, string +---@field device fun(self: ModemBackend, timeout: number?): string, string +---@field primary_port fun(self: ModemBackend, timeout: number?): string, string +---@field at_ports fun(self: ModemBackend, timeout: number?): table, string +---@field qmi_ports fun(self: ModemBackend, timeout: number?): table, string +---@field gps_ports fun(self: ModemBackend, timeout: number?): table, string +---@field net_ports fun(self: ModemBackend, timeout: number?): table, string +---@field ignored_ports fun(self: ModemBackend, timeout: number?): table, string +---@field access_techs fun(self: ModemBackend, timeout: number?): table, string +---@field sim fun(self: ModemBackend, timeout: number?): string, string +---@field drivers fun(self: ModemBackend, timeout: number?): table, string +---@field plugin fun(self: ModemBackend, timeout: number?): string, string +---@field model fun(self: ModemBackend, timeout: number?): string, string +---@field revision fun(self: ModemBackend, timeout: number?): string, string +---@field enable fun(self: ModemBackend): boolean, string +---@field disable fun(self: ModemBackend): boolean, string +---@field reset fun(self: ModemBackend): boolean, string +---@field connect fun(self: ModemBackend, conn_string: string): boolean, string +---@field disconnect fun(self: ModemBackend): boolean, string +---@field inhibit fun(self: ModemBackend): boolean, string +---@field uninhibit fun(self: ModemBackend): boolean, string +---@field monitor_state_op fun(self: ModemBackend): Op local ModemBackend = {} ModemBackend.__index = ModemBackend -- Add all getter methods to ModemBackend -getters.add_getters(ModemBackend, fetch_modem_info) +getters.add_getters(ModemBackend, fetch_modem_info, fetch_signal_info) --- Enable the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:enable() local cmd = exec.command { "mmcli", "-m", self.identity.address, "-e", @@ -241,16 +349,16 @@ function ModemBackend:enable() stdout = "pipe", stderr = "stdout" } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - return "mmcli command failed to execute: " .. tostring(err), code + return false, "mmcli command failed to execute: " .. tostring(err) end - return output, code + return true, "" end --- Disable the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:disable() local cmd = exec.command { "mmcli", "-m", self.identity.address, "-d", @@ -258,16 +366,16 @@ function ModemBackend:disable() stdout = "pipe", stderr = "stdout" } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - return "mmcli command failed to execute: " .. tostring(err), code + return false, "mmcli command failed to execute: " .. tostring(err) end - return output, code + return true, "" end --- Reset the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:reset() local cmd = exec.command { "mmcli", "-m", self.identity.address, "--reset", @@ -275,17 +383,17 @@ function ModemBackend:reset() stdout = "pipe", stderr = "stdout" } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - return "mmcli command failed to execute: " .. tostring(err), code + return false, "mmcli command failed to execute: " .. tostring(err) end - return output, code + return true, "" end --- Connect the modem ---@param conn_string string ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:connect(conn_string) local full_conn_string = "--simple-connect=" .. conn_string local cmd = exec.command { @@ -294,16 +402,16 @@ function ModemBackend:connect(conn_string) stdout = "pipe", stderr = "stdout" } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - return "mmcli command failed to execute: " .. tostring(err), code + return false, "mmcli command failed to execute: " .. tostring(err) end - return output, code + return true, "" end --- Disconnect the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:disconnect() local cmd = exec.command { "mmcli", "-m", self.identity.address, "--simple-disconnect", @@ -311,19 +419,19 @@ function ModemBackend:disconnect() stdout = "pipe", stderr = "stdout" } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - return "mmcli command failed to execute: " .. tostring(err), code + return false, "mmcli command failed to execute: " .. tostring(err) end - return output, code + return true, "" end --- Inhibit the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:inhibit() if self.inhibit_cmd then - return "Modem is already inhibited", 1 + return false, "Modem is already inhibited" end local cmd = exec.command { @@ -337,25 +445,25 @@ function ModemBackend:inhibit() -- The command will run in the background, managed by the scope local stream, err = cmd:stdout_stream() if not stream then - return "Failed to start inhibit command: " .. tostring(err), 1 + return false, "Failed to start inhibit command: " .. tostring(err) end self.inhibit_cmd = cmd - return "Modem inhibit started", 0 + return true, "Modem inhibit started" end --- Uninhibit the modem ----@return string result ----@return number exit_code +---@return boolean ok +---@return string error function ModemBackend:uninhibit() if not self.inhibit_cmd then - return "Modem is not inhibited", 0 + return false, "Modem is not inhibited" end self.inhibit_cmd:kill() self.inhibit_cmd = nil - return "Modem uninhibited", 0 + return true, "Modem uninhibited" end --- Listen for modem state changes @@ -374,8 +482,6 @@ function ModemBackend:monitor_state_op() return stream:read_line_op():wrap(parse_modem_state_line) end - - --- Builds the backend instance --- @return ModemBackend local function new(address) From 646ab1ae2d1623b3f8eda2d3443da5f214a6aa7d Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:56:43 +0000 Subject: [PATCH 07/33] Finished model and mode loading and fixed singleton issues --- src/services/hal/backends/modem/provider.lua | 127 ++++++++++++++----- 1 file changed, 93 insertions(+), 34 deletions(-) diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua index 3d28d83c..697aca0c 100644 --- a/src/services/hal/backends/modem/provider.lua +++ b/src/services/hal/backends/modem/provider.lua @@ -1,53 +1,112 @@ local iface = require "services.hal.backends.modem.iface" -local backends = { +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" } + -- more quectel models here + }, + fibocom = {} +} + +local BACKENDS = { "linux_mm" } -local backend = nil -for _, backend_name in ipairs(backends) do +--- Utility function to check if a string starts with a given prefix (case-insensitive) +---@param str string +---@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 +end + +local backend_impl = nil +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.is_supported) == "function" and mod.is_supported() then - backend = mod + backend_impl = mod.backend break end end -if backend == nil then +if backend_impl == nil then error("No supported modem backend found") end ---- Apply mode specific functions ----@param mode string ----@return boolean ok -function backend:override_mode(mode) - local ok, mod = pcall(require, "services.hal.backends.modem.modes." .. mode) - if ok and type(mod) == "function" then - mod(self) - return true +local function new(address) + local backend = backend_impl.new(address) + ---@cast backend ModemBackend + local drivers, dr_err = backend:drivers() + if dr_err ~= "" then + error("Failed to get modem drivers: " .. tostring(dr_err)) end - return false -end ---- Apply model specific functions ----@param manufacturer string ----@param model string ----@param variant string ----@return boolean ok -function backend:override_model(manufacturer, model, variant) - local ok, mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) - if ok and type(mod) == "function" then - mod(self, model, variant) - return true - end - return false -end + local drivers_str = table.concat(drivers, ",") + local mode + if drivers_str:match("qmi_wwan") then + mode = "qmi" + elseif drivers_str:match("cdc_mbim") then + mode = "mbim" + end + + if mode then + local ok, mod = pcall(require, "services.hal.backends.modem.modes." .. mode) + if ok and mod and mod.add_mode_funcs and type(mod.add_mode_funcs) == "function" then + mod.add_mode_funcs(backend) + end + end + + local plugin, pl_err = backend:plugin() + if pl_err ~= "" then + error("Failed to get modem plugin status: " .. tostring(pl_err)) + end + + local model, model_err = backend:model() + if model_err ~= "" then + error("Failed to get modem model: " .. tostring(model_err)) + end + + local revision, rev_err = backend:revision() + if rev_err ~= "" then + error("Failed to get modem revision: " .. tostring(rev_err)) + end ---- Check that all required backend functions are implemented ----@return boolean ok ----@return string? error -function backend:validate() - return iface.validate_backend(self) + 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 + local ok, mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) + if ok and mod and mod.add_model_funcs and type(mod.add_model_funcs) == "function" then + mod.add_model_funcs(backend, model, model_variant) + model_funcs_loaded = true + end + break + end + end + end + if model_funcs_loaded then break end + end + + local iface_err = iface.validate(backend) + if iface_err ~= "" then + error("Modem backend does not implement required interface: " .. tostring(iface_err)) + end + + return backend end -return backend +return { + new = new +} From 1c8fbf4e2f5b4cc19d75a8b741bd1869b8a49c18 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:57:30 +0000 Subject: [PATCH 08/33] added more get methods --- .../modem/providers/linux_mm/getters.lua | 119 ++++++++++-------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/services/hal/backends/modem/providers/linux_mm/getters.lua b/src/services/hal/backends/modem/providers/linux_mm/getters.lua index 48e4c00d..112a48f9 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/getters.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -1,47 +1,41 @@ --- Modem backend getter methods --- This module defines all the attribute accessor methods for ModemBackend ---- Default try to get value from cache, if not present fetch modem info and try again ----@param identity ModemIdentity ----@param key string ----@param cache Cache ----@param ret_type string ----@param timeout number? ----@return any value ----@return string error -local function get_cached_value(identity, key, cache, ret_type, timeout, fetch_fn) - local cached = cache:get(key, timeout) - if cached then - return cached, "" - end - - local err = fetch_fn(identity, cache) - if err ~= "" then - return nil, "Failed to fetch modem info: " .. tostring(err) - end +local fetch = require "services.hal.backends.fetch" - local value = cache:get(key, timeout) - if not value then - return nil, "Value not found in cache after refresh: " .. tostring(key) - end - - if type(value) == ret_type then - return value, "" - else - return nil, "Cached value has wrong type: expected " .. ret_type .. ", got " .. type(value) - end +--- Read a net stat +---@param net_port string +---@return integer rx_bytes +---@return string error +local function read_net_stat(net_port, stat) + local path = '/sys/class/net/' .. net_port .. '/statistics/' .. stat + local file = io.open(path, "r") + if not file then + return -1, "Failed to open file: " .. tostring(path) + end + local content = file:read("*a") + file:close() + if not content then + return -1, "Failed to read file: " .. tostring(path) + end + local rx_bytes = tonumber(content) + if not rx_bytes then + return -1, "Failed to parse rx_bytes: " .. tostring(content) + end + return rx_bytes, "" end --- Adds all getter methods to the ModemBackend class ---@param ModemBackend table The ModemBackend class table ---@param fetch_modem_info function The fetch function for modem info -local function add_getters(ModemBackend, fetch_modem_info) +---@param fetch_signal_info function The fetch function for signal info +local function add_getters(ModemBackend, fetch_modem_info, fetch_signal_info) --- Gets the modem's IMEI number ---@param timeout number? Cache timeout in seconds (optional) ---@return string imei ---@return string error function ModemBackend:imei(timeout) - return get_cached_value(self.identity, "imei", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "imei", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's device path @@ -49,7 +43,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return string device ---@return string error function ModemBackend:device(timeout) - return get_cached_value(self.identity, "device", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "device", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's primary port @@ -57,7 +51,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return string primary_port ---@return string error function ModemBackend:primary_port(timeout) - return get_cached_value(self.identity, "primary_port", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "primary_port", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's AT ports @@ -65,7 +59,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table at_ports ---@return string error function ModemBackend:at_ports(timeout) - return get_cached_value(self.identity, "at_ports", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "at_ports", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's QMI ports @@ -73,7 +67,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table qmi_ports ---@return string error function ModemBackend:qmi_ports(timeout) - return get_cached_value(self.identity, "qmi_ports", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "qmi_ports", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's GPS ports @@ -81,7 +75,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table gps_ports ---@return string error function ModemBackend:gps_ports(timeout) - return get_cached_value(self.identity, "gps_ports", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "gps_ports", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's network ports @@ -89,15 +83,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table net_ports ---@return string error function ModemBackend:net_ports(timeout) - return get_cached_value(self.identity, "net_ports", self.cache, "table", timeout, fetch_modem_info) - end - - --- Gets the modem's ignored ports - ---@param timeout number? Cache timeout in seconds (optional) - ---@return table ignored_ports - ---@return string error - function ModemBackend:ignored_ports(timeout) - return get_cached_value(self.identity, "ignored_ports", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "net_ports", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's access technologies @@ -105,7 +91,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table access_techs ---@return string error function ModemBackend:access_techs(timeout) - return get_cached_value(self.identity, "access_techs", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "access_techs", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's SIM path @@ -113,7 +99,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return string sim ---@return string error function ModemBackend:sim(timeout) - return get_cached_value(self.identity, "sim", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "sim", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's drivers @@ -121,15 +107,15 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return table drivers ---@return string error function ModemBackend:drivers(timeout) - return get_cached_value(self.identity, "drivers", self.cache, "table", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "drivers", self.cache, "table", timeout, fetch_modem_info) end --- Gets the modem's plugin ---@param timeout number? Cache timeout in seconds (optional) - ---@return boolean plugin + ---@return string plugin ---@return string error function ModemBackend:plugin(timeout) - return get_cached_value(self.identity, "plugin", self.cache, "boolean", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "plugin", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's model @@ -137,7 +123,7 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return string model ---@return string error function ModemBackend:model(timeout) - return get_cached_value(self.identity, "model", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "model", self.cache, "string", timeout, fetch_modem_info) end --- Gets the modem's revision @@ -145,8 +131,39 @@ local function add_getters(ModemBackend, fetch_modem_info) ---@return string revision ---@return string error function ModemBackend:revision(timeout) - return get_cached_value(self.identity, "revision", self.cache, "string", timeout, fetch_modem_info) + return fetch.get_cached_value(self.identity, "revision", self.cache, "string", timeout, fetch_modem_info) end + + --- Gets the modem's operator name + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string operator + ---@return string error + function ModemBackend:operator(timeout) + return fetch.get_cached_value(self.identity, "operator", self.cache, "string", timeout, fetch_modem_info) + end + + --- Gets the modems rx bytes + ---@return integer rx_bytes + ---@return string error + function ModemBackend:rx_bytes() + return read_net_stat(self.identity.net_port, "rx_bytes") + end + + --- Gets the modems tx bytes + ---@return integer tx_bytes + ---@return string error + function ModemBackend:tx_bytes() + return read_net_stat(self.identity.net_port, "tx_bytes") + end + + --- Gets the modems signal + ---@param timeout number? Cache timeout in seconds (optional) + ---@return table signal_info + ---@return string error + function ModemBackend:signal(timeout) + return fetch.get_cached_value(self.identity, "signal", self.cache, "table", timeout, fetch_signal_info) + end + end return { From 379c65e338d80cc8131182a5bd4022adaeac2a81 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 6 Feb 2026 17:57:59 +0000 Subject: [PATCH 09/33] proper export of backend --- src/services/hal/backends/modem/providers/linux_mm/init.lua | 2 ++ 1 file changed, 2 insertions(+) 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 c3e4781b..9a40487b 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/init.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -39,6 +39,8 @@ local function is_supported() return is_linux() and has_mmcli() end +local backend = require "services.hal.backends.modem.providers.linux_mm.impl" + return { is_supported = is_supported, backend = backend From 5d26b2588cc5001384b4ecc76eb4238019527bcf Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 10 Feb 2026 12:31:48 +0000 Subject: [PATCH 10/33] monitor proposal --- docs/services/hal.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/services/hal.md b/docs/services/hal.md index 7c227930..0bdcb4cb 100644 --- a/docs/services/hal.md +++ b/docs/services/hal.md @@ -45,3 +45,70 @@ - resources - bigbox_v1_cm.lua - bigbox_ss.lua + +## HAl monitor acticheture + +```mermaid +sequenceDiagram + participant Config + participant Bus + participant Core + participant Monitors + Core->>Core: Init storage driver and cap (for config loading) + Config->>Core: Request config blob + Core->>StoreCap: Parrot Request + StoreCap->>Core: Return blob + Core->>Config: Response + Config->>Core: Load config + Core->>Core: Create monitors + Core->>Monitors: op choice over monitors (and bus etc) + Monitors->>Core: (UART) use config to build uart driver, return driver in connected event + Core->>Core: Init driver and get capabilities + Core->>Bus: Broadcast uart device and capability +``` + +Monitors are scope protected modules that provide a event based op which returns when a device is either connected or disconnected. +```lua +local device_event = perform(uart_monitor:monitor_op()) +if device_event.connected then + -- somthing +else + --somthing else +end +``` + +Monitors can scale for complexity, for example the simplest case of a monitor would be uart, where the monitor would recieve a config and build drivers immediately based on that config, the monitor_op would iterate over the drivers and then block forever +```lua +function monitor:monitor_op() + if self._progress >= #self._drivers then + return op.never() + end + return op.always( + -- device connected event goes here + ) +end +``` + +Some devices are not defined at config time, and appear dynamically. These monitors can return a scope_op which listens to an underlying command to build device events +```lua +function monitor.new() + return setmetatable({ + scope = -- child scope to protect HAL from montior based failure + cmd = -- monitoring command here + }, monitor) +end + +-- A monitor op that is dependant on command line output +function monitor:monitor_op() + return scope:scope_op(function () + return self.cmd:read_line_op():wrap(parser) + end) +end +``` + +Both of these examples are fiberless + +Monitors may also spawn fibers in the background, although the majority won't need to, to mediate command output between drivers + + + From ae71a3160bad9f68d7a7cec690a7d1de1bf8802e Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 17 Feb 2026 14:20:27 +0000 Subject: [PATCH 11/33] modem with backend useage; state monitor, emitter and warm swap fibers --- src/services/hal/drivers/modem.lua | 504 +++++++++++++++++------------ 1 file changed, 295 insertions(+), 209 deletions(-) diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index 9d39b4b9..9b95a632 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -1,6 +1,5 @@ -- Modem modules -local modem_types = require "services.hal.types.modem" -local attr_paths = require "services.hal.drivers.modem.attr_paths" +local modem_backend_provider = require "services.hal.backends.modem.provider" -- HAL modules local hal_types = require "services.hal.types.core" @@ -12,29 +11,36 @@ local log = require "services.log" -- Fibers modules local fibers = require "fibers" -local scope_mod = require "fibers.scope" local op = require "fibers.op" local channel = require "fibers.channel" local sleep = require "fibers.sleep" - --- Other modules -local cache = require "shared.cache" +local cond = require "fibers.cond" +local pulse = require "fibers.pulse" ---@class Modem ---@field address ModemAddress ---@field control_ch Channel ---@field cap_emit_ch Channel ---@field scope Scope ----@field identity ModemIdentity +---@field imei string ---@field model string ---@field model_variant string ---@field mode string ---@field initialised boolean ---@field caps_applied boolean +---@field state_pulse Pulse ---@field cache Cache local Modem = {} Modem.__index = Modem +local function list_to_table(list) + local t = {} + for _, v in ipairs(list) do + t[v] = true + end + return t +end + ---- Constant Definitions ---- local DEFAULT_STOP_TIMEOUT = 5 @@ -42,29 +48,22 @@ local DEFAULT_CACHE_TIMEOUT = 10 local CONTROL_Q_LEN = 8 -local ATTR_ACCESSOR = attr_paths.build_paths { - get_modem_info = get_modem_info, - get_modem_firmware = get_modem_firmware, - get_sim_info = get_sim_info, - get_home_network = get_home_network, - get_gid1 = get_gid1, - get_rf_band_info = get_rf_band_info, - get_operator_info = get_operator_info, - get_signal_info = get_signal_info, - get_net_stats = get_net_stats, -} - -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" } - -- more quectel models here - }, - fibocom = {} +local GET_METHODS = list_to_table { + "imei", + "device", + "primary_port", + "ports", + "at_ports", + "qmi_ports", + "gps_ports", + "net_ports", + "access_techs", + "sim", + "drivers", + "plugin", + "model", + "revision", + "operator" } @@ -130,7 +129,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_event(key, data) - return emit(self.cap_emit_ch, self.identity.imei, 'event', key, data) + return emit(self.cap_emit_ch, self.imei, 'event', key, data) end --- Emit a state. @@ -139,7 +138,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_state(key, data) - return emit(self.cap_emit_ch, self.identity.imei, 'state', key, data) + return emit(self.cap_emit_ch, self.imei, 'state', key, data) end --- Emit meta information. @@ -148,7 +147,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_meta(key, data) - return emit(self.cap_emit_ch, self.identity.imei, 'meta', key, data) + return emit(self.cap_emit_ch, self.imei, 'meta', key, data) end --- Emit a set of key-value events. @@ -156,7 +155,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_kv(kv_data) - return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) + return emit_kv(self.cap_emit_ch, self.imei, kv_data) end --- Emit a set of key-value states. @@ -164,7 +163,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_kv_state(kv_data) - return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) + return emit_kv(self.cap_emit_ch, self.imei, kv_data) end --- Emit a set of key-value meta information. @@ -172,7 +171,7 @@ end ---@return boolean ok ---@return string? error function Modem:_emit_kv_meta(kv_data) - return emit_kv(self.cap_emit_ch, self.identity.imei, kv_data) + return emit_kv(self.cap_emit_ch, self.imei, kv_data) end --- Validate that a function is implemented @@ -194,55 +193,170 @@ end --- Get a modem attribute. ---@param opts ModemGetOpts? ---@return any value ----@return string? error function Modem:get(opts) if opts == nil or getmetatable(opts) ~= external_types.ModemGetOpts then - return nil, "invalid options" + throw_error("invalid options") + return end local field = opts.field local timescale = opts.timescale or math.huge - local accessor = ATTR_ACCESSOR[field] - if not accessor then - throw_error("unknown field: " .. tostring(field)) + -- Check that the field is supported + if not GET_METHODS[field] then + throw_error("unsupported field: " .. tostring(field)) end - -- try to find value in cache - local method = accessor.method - local key = method - if accessor.path ~= '' then - key = key .. "." .. accessor.path + -- Call the corresponding backend function to get the value + local get_fn = self.backend[field] + if not get_fn then + throw_error("field " .. tostring(field) .. " is not implemented by backend") end - local value, err = self.cache:get(key, timescale) - if err then throw_error(err) end + local value, err = get_fn(self.backend, timescale) + if err ~= "" then + throw_error("error getting field " .. tostring(field) .. ": " .. tostring(err)) + end + return value +end - -- if not found in cache, or stale - if value == nil then - local valid, validation_err = validate_fn(accessor.gettr) - if not valid then - return throw_error(validation_err) - end +--- Enable the modem +function Modem:enable() + local ok, err = self.backend:enable() + if not ok then + throw_error(err) + end +end - local info, info_err = accessor.gettr(self.identity) - if not info then throw_error(info_err) end - local values = attr_paths.flatten_table(info) - for k, v in pairs(values) do - self.cache:set(k, v) - end +--- Disable the modem +function Modem:disable() + local ok, err = self.backend:disable() + if not ok then + throw_error(err) + end +end - local ok, emit_err = self:_emit_kv_meta(values) - if not ok then - log.debug("Modem Driver", self.identity.imei, "emit_kv_meta error:", emit_err) +--- Reset the modem +function Modem:reset() + local ok, err = self.backend:reset() + if not ok then + throw_error(err) + end +end + +--- Connect the modem +---@param opts ModemConnectOpts? +function Modem:connect(opts) + if opts == nil or getmetatable(opts) ~= external_types.ModemConnectOpts then + throw_error("invalid options") + return + end + local ok, err = self.backend:connect(opts.connection_string) + if not ok then + throw_error(err) + end +end + +--- Disconnect the modem +function Modem:disconnect() + local ok, err = self.backend:disconnect() + if not ok then + throw_error(err) + end +end + +--- Inhibit the modem +function Modem:inhibit() + 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) + + if not ok then + throw_error("failed to spawn inhibit fiber: " .. tostring(err)) + end + + 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 + throw_error(msg.err) end + return + elseif source == "failed" then + throw_error("modem inhibit failed: " .. tostring(primary)) + end + throw_error("unexpected error during modem inhibit") +end - value = values[key] +--- Uninhibit the modem +function Modem:uninhibit() + local ok, err = self.backend:uninhibit() + if not ok then + throw_error(err) end +end - if value == nil then - throw_error("no value for field: " .. tostring(field)) +--- Start listening for a sim insertion +function Modem:listen_for_sim() + if self.listening_for_sim then + throw_error("already listening for SIM") + end + self.listening_for_sim = true + local ok, err = fibers.current_scope():spawn(function() + fibers.run_scope(function() + self:_emit_state("sim_listener", "open") + + fibers.current_scope():finally(function() + self.listening_for_sim = false + self:_emit_state("sim_listener", "closed") + end) + + while true do + --- out returns true if SIM is present, false if not + local source, out, err = fibers.perform(op.named_choice { + sim_present = self.backend:wait_for_sim_present_op(), + timeout = sleep.sleep_op(DEFAULT_CACHE_TIMEOUT) + }) + if source == "sim_present" then + if err ~= "" then + log.error("Modem Driver", self.imei, + "listen_for_sim: error waiting for SIM presence:", err) + self:_emit_event("sim_listen_error", err) + end + if out then + self.state_pulse:signal() + break + end + elseif source == "timeout" then + local ok, check_err = self.backend:trigger_sim_presence_check() + if not ok then + log.error("Modem Driver", self.imei, + "listen_for_sim: failed to trigger SIM presence check:", check_err) + end + end + end + end) + end) + if not ok then + throw_error("listen_for_sim spawn failed: " .. tostring(err)) end +end - return value +--- Set the signal update period +---@param opts ModemSignalUpdateOpts +function Modem:set_signal_update_freq(opts) + if opts == nil or getmetatable(opts) ~= external_types.ModemSignalUpdateOpts then + throw_error("invalid options") + return + end + local ok, err = self.backend:set_signal_update_interval(opts.frequency) + if not ok then + throw_error(err) + end end ---- Long Running Fibers ---- @@ -256,29 +370,87 @@ local function get_reason_and_code(control_err, verb) return control_err.reason, control_err.code end - local reason = "function " .. tostring(verb) .. " did not return a valid ControlError" + local reason = "function " .. tostring(verb) .. " failed with error: " .. tostring(control_err) return reason, 1 end +function Modem:emitter() + log.trace("Modem Driver", self.imei, "emitter: started") + + fibers.current_scope():finally(function() + log.trace("Modem Driver", self.imei, "emitter: exiting") + end) + + while true do + self.state_pulse:next() -- wait for a pulse indicating state change + + for method, _ in pairs(GET_METHODS) do + local st, _, primary = fibers.run_scope(function() self:get(external_types.new.ModemGetOpts(method, 0)) end) + + if st ~= 'ok' then + local err_msg = primary + if type(err_msg) == "table" and err_msg.reason then + err_msg = err_msg.reason + end + log.warn("Modem Driver", self.imei, + "emitter: error getting field " .. tostring(method) .. ": " .. tostring(err_msg)) + else + local ok, emit_err = self:_emit_meta(method, primary) + if not ok then + log.warn("Modem Driver", self.imei, + "emitter: failed to emit meta for field " .. tostring(method) .. ": " + .. tostring(emit_err)) + end + end + end + end +end + function Modem:state_monitor() + log.trace("Modem Driver", self.imei, "state_monitor: started") + + fibers.current_scope():finally(function() + log.trace("Modem Driver", self.imei, "state_monitor: exiting") + end) + + while true do + local state_update, err = fibers.perform(self.backend:monitor_state_op()) + ---@cast state_update ModemStateEvent + if err == 'Command closed' then + log.error("Modem Driver", self.imei, "state_monitor: backend command closed, exiting monitor") + break + elseif err ~= "" then + log.error("Modem Driver", self.imei, "state_monitor: error monitoring state:", err) + elseif state_update then + self.state_pulse:signal() -- signal that modem state has changed + local ok, emit_err = self:_emit_state('card', state_update) + if not ok then + log.error("Modem Driver", self.imei, "state_monitor: failed to emit state update:", emit_err) + end + end + end end function Modem:control_manager() if self.cap_emit_ch == nil then - log.error("Modem Driver", self.identity.imei, "control_manager: cap_emit_ch is nil") + log.error("Modem Driver", self.imei, "control_manager: cap_emit_ch is nil") return end if self.control_ch == nil then - log.error("Modem Driver", self.identity.imei, "control_manager: control_ch is nil") + log.error("Modem Driver", self.imei, "control_manager: control_ch is nil") return end - log.trace("Modem Driver", self.identity.imei, "control_manager: started") + log.trace("Modem Driver", self.imei, "control_manager: started") + + fibers.current_scope():finally(function() + log.trace("Modem Driver", self.imei, "control_manager: exiting") + end) while true do local request, req_err = self.control_ch:get() if not request then - log.error("Modem Driver", self.identity.imei, "control_manager: control_ch get error:", req_err) + log.error("Modem Driver", self.imei, "control_manager: control_ch get error:", req_err) break end @@ -305,60 +477,15 @@ function Modem:control_manager() local reply, reply_err = hal_types.new.Reply(ok, reason, code) if not reply then - log.error("Modem Driver", self.identity.imei, "control_manager: failed to create reply:", reply_err) + log.error("Modem Driver", self.imei, "control_manager: failed to create reply:", reply_err) else request.reply_ch:put(reply) end end - - log.trace("Modem Driver", self.identity.imei, "control_manager: exiting") end ---- Driver Functions ---- -local function format_ports(ports) - local port_list = {} - - -- ports is now a comma-separated string - if type(ports) == "string" then - for port in ports:gmatch("[^,]+") do - port = port:match("^%s*(.-)%s*$") -- trim whitespace - local port_name, port_type = string.match(port, "^([%w%-]+)%s*%(([%w%-]+)%)") - if port_name and port_type then - if port_list[port_type] == nil then - port_list[port_type] = { port_name } - else - table.insert(port_list[port_type], port_name) - end - end - end - end - - return port_list -end - ---- Utility function to check if a string starts with a given prefix (case-insensitive) ----@param str string ----@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 -end - - ---- Get driver identity ----@return ModemIdentity? identity ----@return string error -function Modem:get_identity() - if not self.initialised then - return nil, "modem not initialised" - end - return self.identity, "" -end - --- Spawn driver services ---@return boolean ok ---@return string error @@ -370,8 +497,9 @@ function Modem:start() return false, "capabilities not applied" end - self.scope:spawn(self.state_monitor, self) - self.scope:spawn(self.control_manager, self) + self.scope:spawn(function() self:state_monitor() end) + self.scope:spawn(function() self:control_manager() end) + self.scope:spawn(function() self:emitter() end) return true, "" end @@ -412,7 +540,7 @@ function Modem:capabilities(emit_ch) local modem_cap, mod_cap_err = cap_types.new.ModemCapability( 'modem', - self.identity.imei, + self.imei, self.control_ch ) if not modem_cap then @@ -430,124 +558,82 @@ function Modem:init() if self.initialised then return "already initialised" end - -- determine mode (qmi/mbim) from drivers to apply mode-specific overrides - local drivers, drivers_err = self:get { field = 'drivers' } - if not drivers then - return "failed to get drivers: " .. tostring(drivers_err) - end - if drivers:match("qmi_wwan") then - self.mode = 'qmi' - elseif drivers:match("cdc_mbim") then - self.mode = 'mbim' - end + local backend_built_sig = cond.new() - assert(mode_overrides.add_mode_funcs(self)) + local ok, err = self.scope:spawn(function() + self.backend = modem_backend_provider.new(self.address) - -- identify model to apply model-specific overrides - local plugin, plugin_err = self:get { field = 'plugin' } - if not plugin then - return "failed to get plugin: " .. tostring(plugin_err) - end - - local model, model_err = self:get { field = 'model' } - if not model then - return "failed to get model: " .. tostring(model_err) - end + self.backend:start_sim_presence_monitor() + self.backend:start_state_monitor() - local revision, revision_err = self:get { field = 'revision' } - if not revision then - return "failed to get revision: " .. tostring(revision_err) - end - - 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 - log.info("Modem Driver", self.identity.imei, - "identified model as", details.model, - "variant", details.model_variant) - self.model = details.model - self.model_variant = details.model_variant - break - end - end + -- Get IMEI from backend + local imei, imei_err = self.backend:imei() + if imei_err == "" then + self.imei = imei + else + error("failed to get IMEI: " .. tostring(imei_err)) end - end - - model_overrides.add_model_funcs(self) - - -- obtain essential identity information - local imei, imei_err = self:get { field = 'imei' } - if not imei then - return "failed to get imei: " .. tostring(imei_err) - end - - local primary_port, primary_port_err = self:get { field = 'primary_port' } - if not primary_port then - return "failed to get primary_port: " .. tostring(primary_port_err) - end - local ports, ports_err = self:get { field = 'ports' } - if not ports then - return "failed to get ports: " .. tostring(ports_err) - end - - local fmt_ports = format_ports(ports) - if (not fmt_ports.at) or (not fmt_ports.at[1]) then - return "no AT port found" - end + backend_built_sig:signal() + end) - local device, device_err = self:get { field = 'device' } - if not device then - return "failed to get device: " .. tostring(device_err) + if not ok then + return "failed to spawn modem backend fiber: " .. tostring(err) end - local id, id_err = modem_types.new.ModemIdentity( - imei, - self.address, - primary_port, - fmt_ports.at[1], - device - ) + local source, _, primary = fibers.perform(op.named_choice { + backend_ready = backend_built_sig:wait_op(), + failed = self.scope:fault_op() + }) - if not id then - return "failed to create modem identity: " .. tostring(id_err) + 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 - - self.identity = id - - self.initialised = true - - return "" end --- Create a new Modem driver. ----@param scope Scope ---@param address ModemAddress ---@return Modem? modem ---@return string error -local function new(scope, address) - if getmetatable(scope) ~= scope_mod.Scope then - return nil, "invalid scope" - end +local function new(address) 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 + log.error(("Modem Driver %s: error - %s"):format(tostring(address), tostring(primary))) + log.trace("Modem Driver %s: scope exiting with status %s", tostring(address), st) + end + log.trace("Modem Driver %s: stopped", tostring(address)) + end) + return setmetatable({ scope = scope, address = address, initialised = false, -- modem cannot apply capabilities until initialised caps_applied = false, -- modem cannot start until capabilities applied - control_ch = control_ch, - cache = cache.new(DEFAULT_CACHE_TIMEOUT, nil, '.') + listening_for_sim = false, + state_pulse = pulse.new(), + control_ch = control_ch }, Modem), "" end return { - new + new = new } From f0daa5fa3be78769b98dd1d389bf295a7569b64d Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 17 Feb 2026 14:22:20 +0000 Subject: [PATCH 12/33] modem backend finished for linux_mm --- src/services/hal/backends/fetch.lua | 34 ++ .../modem/{iface.lua => contract.lua} | 20 +- .../hal/backends/modem/models/fibocom.lua | 6 + .../hal/backends/modem/models/quectel.lua | 94 +++++ .../hal/backends/modem/modes/mbim.lua | 6 + src/services/hal/backends/modem/modes/qmi.lua | 316 +++++++++------- src/services/hal/backends/modem/provider.lua | 54 ++- .../modem/providers/linux_mm/getters.lua | 25 +- .../modem/providers/linux_mm/impl.lua | 353 +++++++++++------- .../modem/providers/linux_mm/init.lua | 18 +- 10 files changed, 585 insertions(+), 341 deletions(-) create mode 100644 src/services/hal/backends/fetch.lua rename src/services/hal/backends/modem/{iface.lua => contract.lua} (88%) diff --git a/src/services/hal/backends/fetch.lua b/src/services/hal/backends/fetch.lua new file mode 100644 index 00000000..8633904a --- /dev/null +++ b/src/services/hal/backends/fetch.lua @@ -0,0 +1,34 @@ +--- Default try to get value from cache, if not present fetch modem info and try again +---@param identity ModemIdentity +---@param key string +---@param cache Cache +---@param ret_type string +---@param timeout number? +---@return any value +---@return string error +local function get_cached_value(identity, key, cache, ret_type, timeout, fetch_fn) + local cached = cache:get(key, timeout) + if cached then + return cached, "" + end + + local err = fetch_fn(identity, cache) + if err ~= "" then + return nil, "Failed to fetch modem info: " .. tostring(err) + end + + local value = cache:get(key, timeout) + if not value then + return nil, "Value not found in cache after refresh: " .. tostring(key) + end + + if type(value) == ret_type then + return value, "" + else + return nil, "Cached value has wrong type: expected " .. ret_type .. ", got " .. type(value) + end +end + +return { + get_cached_value = get_cached_value, +} diff --git a/src/services/hal/backends/modem/iface.lua b/src/services/hal/backends/modem/contract.lua similarity index 88% rename from src/services/hal/backends/modem/iface.lua rename to src/services/hal/backends/modem/contract.lua index 294b00bc..c1efbbab 100644 --- a/src/services/hal/backends/modem/iface.lua +++ b/src/services/hal/backends/modem/contract.lua @@ -29,8 +29,20 @@ local BACKEND_FUNCTIONS = list_to_map { "mnc", "gid1", "active_band_class", + "firmware", - -- others + -- State monitoring + "start_state_monitor", + "monitor_state_op", + + -- SIM monitoring + "start_sim_presence_monitor", + "wait_for_sim_present_op", + "wait_for_sim_present", + "is_sim_present", + "trigger_sim_presence_check", + + -- Control operations "enable", "disable", "reset", @@ -38,11 +50,7 @@ local BACKEND_FUNCTIONS = list_to_map { "disconnect", "inhibit", "uninhibit", - "monitor_state_op", - "wait_for_sim_present_op", - "wait_for_sim_present", - "is_sim_present", - "trigger_sim_presence_check", + "set_signal_update_interval" } --- Check that a modem backend provides all required functions and no more diff --git a/src/services/hal/backends/modem/models/fibocom.lua b/src/services/hal/backends/modem/models/fibocom.lua index e69de29b..978660da 100644 --- a/src/services/hal/backends/modem/models/fibocom.lua +++ b/src/services/hal/backends/modem/models/fibocom.lua @@ -0,0 +1,6 @@ +local function add_model_funcs(_, _, _) +end + +return { + 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 e69de29b..28e6764e 100644 --- a/src/services/hal/backends/modem/models/quectel.lua +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -0,0 +1,94 @@ +local exec = require "fibers.io.exec" +local fibers = require "fibers" + +local at = require "services.hal.backends.modem.at" + +local funcs = { + -- This is a special case for the RM520N, it has a initial bearer which will always cause a + -- multiple PDN failure unless set to the APN we want to use OR set to empty. + { + name = 'connect', + conditionals = { + function(backend, model, _) + 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, "--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, "" + else + return false, result_or_err or "Command failed" + end + end + }, + { + name = 'firmware', + conditionals = { + function(_, model, _) + return model == 'rm520n' or model == 'eg25' or model == 'ec25' or model == 'em12' + end + }, + func = function(backend) + ---@cast backend ModemBackend + local firmware = backend.cache:get("firmware") + if firmware then return firmware, "" end + local lines, err = at.send(backend.identity.at_port, "AT+QGMR") + if err ~= "" then + return nil, "Failed to get firmware version: " .. err + end + if lines then + for _, line in ipairs(lines) do + local fw = line:match("Revision:%s*(%S+)") + if fw then + backend.cache:set("firmware", fw) + return fw, "" + end + end + end + return nil, "Firmware version not found in AT response" + end + } + -- Other functions +} + +--- Add model specific functions to the backend +---@param backend ModemBackend +---@param model string +---@param variant string +local function add_model_funcs(backend, model, variant) + for _, f in ipairs(funcs) do + for _, cond in ipairs(f.conditionals) do + if cond(backend, model, variant) then + backend[f.name] = f.func + break + end + end + end +end + +return { + 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 e69de29b..b783107f 100644 --- a/src/services/hal/backends/modem/modes/mbim.lua +++ b/src/services/hal/backends/modem/modes/mbim.lua @@ -0,0 +1,6 @@ +local function add_mode_funcs(_) +end + +return { + 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 b1cf8d46..c9f9b20a 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -28,26 +28,28 @@ end ---@param cache Cache ---@return string error local function fetch_home_network_info(identity, cache) - 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 - return "Failed to execute qmicli command: " .. tostring(err) - end + local st, _, 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: " .. tostring(err)) + end - local mcc = out:match("MCC:%s+'(%d+)'") - local mnc = out:match("MNC:%s+'(%d+)'") - if not mcc or not mnc then - return "Failed to parse qmicli output: " .. tostring(out) - 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 - cache:set("mcc", mcc) - cache:set("mnc", mnc) - return "" + cache:set("mcc", mcc) + cache:set("mnc", mnc) + end) + return st ~= "ok" and err or "" end --- Gets gid1 for a sim card and caches it @@ -55,25 +57,27 @@ end ---@param cache Cache ---@return string error local function fetch_gid1(identity, cache) - 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 - return "Failed to execute qmicli command: " .. tostring(err) - end + local st, _, 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: " .. tostring(err)) + end - -- Parse the hex string after "Read result:" - local gid1 = out:match("Read result:%s*([%x:]+)") - if not gid1 then - return "Failed to parse qmicli output: " .. tostring(out) - end + -- Parse the hex string after "Read result:" + local gid1 = out:match("Read result:%s*([%x:]+)$") + if not gid1 then + error("Failed to parse qmicli output: " .. tostring(out)) + end - cache:set("gid1", gid1) - return "" + cache:set("gid1", gid1) + end) + return st ~= "ok" and err or "" end --- Gets the RF band info for a modem and caches active_band_class @@ -81,83 +85,97 @@ end ---@param cache Cache ---@return string error local function fetch_rf_band_info(identity, cache) - 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 - return "Failed to execute qmicli command: " .. tostring(err) - end + local st, _, 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: " .. tostring(err)) + end - local active_band_class = out:match("Active Band Class:%s*'([^']+)'") - if not active_band_class then - return "Failed to parse qmicli output: " .. tostring(out) - 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 - cache:set("active_band_class", active_band_class) - return "" + cache:set("active_band_class", active_band_class) + end) + return st ~= "ok" and err or "" end local function add_mode_funcs(ModemBackend) + --- Start command to read sim presence + ---@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" + } + + 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 + --- Make an op to listen for sim presence ---@return Op function ModemBackend:wait_for_sim_present_op() return op.guard(function() return scope.run_op(function(s) - -- Start QMI monitor command - local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-monitor-slot-status", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - - -- Get stdout stream (starts command automatically) - local stdout, err = cmd:stdout_stream() - if not stdout then - -- Return error result from scope - won't propagate to caller - return false, "Failed to start QMI monitor: " .. tostring(err) - end - - -- Command handles stream cleanup automatically via scope finalizer - - -- Read chunks until we find "Slot status: active" - while true do - local chunk = s:perform(stdout:read_line_op({ - terminator = "Slot status: active", - keep_terminator = true, - })) - - if not chunk then - -- EOF - return error result from scope - return false, "QMI monitor stream closed" + if not self.sim_present then + return false, "Sim presence monitor not started" end - - -- Parse the chunk (includes Card status + Slot status lines) - local card_status, parse_err = parse_slot_status(chunk) - - if parse_err == "" and card_status ~= "" then - -- Valid parse - return true if present, false if absent - return card_status == "present" + -- Read chunks until we find "Slot status: active" + 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 + -- EOF - return error result from scope + return false, "Stream closed" + end + + -- Parse the chunk (includes Card status + Slot status lines) + local card_status, parse_err = parse_slot_status(chunk) + + if parse_err == "" and card_status ~= "" then + -- Valid parse - return true if present, false if absent + return card_status == "present", "" + end + + -- If parse failed, continue reading next chunk end - - -- If parse failed, continue reading next chunk - end - end) - :wrap(function(st, _, ...) - if st == 'ok' then - return ... -- Returns the boolean (and optional error string) - elseif st == 'cancelled' then - return false, "cancelled" - else - -- Scope failed - return the error from scope body - return false, (... or "QMI monitor failed") - end - end) + end) + :wrap(function(st, _, ...) + if st == 'ok' then + return ... -- Returns the boolean (and optional error string) + elseif st == 'cancelled' then + return false, "cancelled" + else + -- Scope failed - return the error from scope body + return false, (... or "QMI monitor failed") + end + end) end) end @@ -171,22 +189,30 @@ local function add_mode_funcs(ModemBackend) ---@return boolean sim_present ---@return string error function ModemBackend:is_sim_present() - local cmd = exec.command { - "qmicli", "-p", "-d", self.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 - return false, "Failed to execute qmicli command: " .. tostring(err) - end + local st, _, present_or_err = fibers.run_scope(function() + local cmd = exec.command { + "qmicli", "-p", "-d", self.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 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) - local state, parse_err = parse_slot_status(out) - if parse_err ~= "" then - return false, "Failed to parse qmicli output: " .. tostring(parse_err) + if st == "ok" then + return present_or_err, "" + else + return false, present_or_err or "Unknown error" end - return state == 'present', "" end --- Check for sim presence, will cause a sim_present_op to emit if present @@ -194,34 +220,41 @@ local function add_mode_funcs(ModemBackend) ---@return boolean ok ---@return string error function ModemBackend:trigger_sim_presence_check(cooldown) - cooldown = cooldown or 1 - --- Set power low - local cmd = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-off=1", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - return false, "Failed to execute qmicli power off command: " .. tostring(err) - end - - sleep.sleep(cooldown) - - --- Set power high - local cmd_on = exec.command { - "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-on=1", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status_on, code_on, _, err_on = fibers.perform(cmd_on:combined_output_op()) - if status_on ~= "exited" or code_on ~= 0 then - return false, "Failed to execute qmicli power on command: " .. tostring(err_on) - end + local st, _, err = fibers.run_scope(function() + local errors = {} + cooldown = cooldown or 1 + --- Set power low + local cmd = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-off=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, code, _, 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(err)) + end + + sleep.sleep(cooldown) + + --- Set power high + local cmd_on = exec.command { + "qmicli", "-p", "-d", self.identity.mode_port, "--uim-sim-power-on=1", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + 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 true, "" + return st == "ok", err or "" end --- Gets a simcards MCC @@ -253,7 +286,8 @@ local function add_mode_funcs(ModemBackend) ---@return string active_band_class ---@return string error function ModemBackend:active_band_class(timeout) - return fetch.get_cached_value(self.identity, "active_band_class", self.cache, "string", timeout, fetch_rf_band_info) + return fetch.get_cached_value(self.identity, "active_band_class", self.cache, "string", timeout, + fetch_rf_band_info) end end diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua index 697aca0c..9cd19020 100644 --- a/src/services/hal/backends/modem/provider.lua +++ b/src/services/hal/backends/modem/provider.lua @@ -1,4 +1,4 @@ -local iface = require "services.hal.backends.modem.iface" +local contract = require "services.hal.backends.modem.contract" local MODEL_INFO = { quectel = { @@ -7,7 +7,8 @@ local MODEL_INFO = { { 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 = "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 = {} @@ -28,21 +29,32 @@ local function starts_with(str, start) return string.sub(str, 1, string.len(start)) == start end -local backend_impl = nil -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.is_supported) == "function" and mod.is_supported() then - backend_impl = mod.backend - break +-- local backend_impl = nil + +--- 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 + print("Checking backend: services.hal.backends.modem.providers." .. backend_name .. ".init") + local ok, backend_mod = pcall(require, "services.hal.backends.modem.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 + print("Selected backend:", backend_name) + break + end end -end -if backend_impl == nil then - error("No supported modem backend found") + if backend_impl == nil then + error("No supported modem backend found") + end + + return backend_impl end local function new(address) - local backend = backend_impl.new(address) + local impl = get_backend_impl() + local backend = impl.new(address) ---@cast backend ModemBackend local drivers, dr_err = backend:drivers() if dr_err ~= "" then @@ -50,17 +62,19 @@ local function new(address) end local drivers_str = table.concat(drivers, ",") + print(drivers_str) local mode if drivers_str:match("qmi_wwan") then mode = "qmi" elseif drivers_str:match("cdc_mbim") then mode = "mbim" end + print(mode) if mode then - local ok, mod = pcall(require, "services.hal.backends.modem.modes." .. mode) - if ok and mod and mod.add_mode_funcs and type(mod.add_mode_funcs) == "function" then - mod.add_mode_funcs(backend) + 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 @@ -79,6 +93,8 @@ local function new(address) error("Failed to get modem revision: " .. tostring(rev_err)) end + print("Plugin:", plugin, "Model:", model, "Revision:", revision) + local model_funcs_loaded = false for manufacturer, models in pairs(MODEL_INFO) do if string.match(plugin:lower(), manufacturer) then @@ -87,9 +103,9 @@ local function new(address) or starts_with(revision, details.rev_string) then model = details.model local model_variant = details.model_variant - local ok, mod = pcall(require, "services.hal.backends.modem.models." .. manufacturer) - if ok and mod and mod.add_model_funcs and type(mod.add_model_funcs) == "function" then - mod.add_model_funcs(backend, model, 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, model, model_variant) model_funcs_loaded = true end break @@ -99,7 +115,7 @@ local function new(address) if model_funcs_loaded then break end end - local iface_err = iface.validate(backend) + local iface_err = contract.validate(backend) if iface_err ~= "" then error("Modem backend does not implement required interface: " .. tostring(iface_err)) end diff --git a/src/services/hal/backends/modem/providers/linux_mm/getters.lua b/src/services/hal/backends/modem/providers/linux_mm/getters.lua index 112a48f9..5c0d17fd 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/getters.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -3,33 +3,12 @@ local fetch = require "services.hal.backends.fetch" ---- Read a net stat ----@param net_port string ----@return integer rx_bytes ----@return string error -local function read_net_stat(net_port, stat) - local path = '/sys/class/net/' .. net_port .. '/statistics/' .. stat - local file = io.open(path, "r") - if not file then - return -1, "Failed to open file: " .. tostring(path) - end - local content = file:read("*a") - file:close() - if not content then - return -1, "Failed to read file: " .. tostring(path) - end - local rx_bytes = tonumber(content) - if not rx_bytes then - return -1, "Failed to parse rx_bytes: " .. tostring(content) - end - return rx_bytes, "" -end - --- Adds all getter methods to the ModemBackend class ---@param ModemBackend table The ModemBackend class table ---@param fetch_modem_info function The fetch function for modem info ---@param fetch_signal_info function The fetch function for signal info -local function add_getters(ModemBackend, fetch_modem_info, fetch_signal_info) +---@param read_net_stat function The function to read network statistics +local function add_getters(ModemBackend, fetch_modem_info, fetch_signal_info, read_net_stat) --- Gets the modem's IMEI number ---@param timeout number? Cache timeout in seconds (optional) ---@return string imei 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 d3c6be26..fc6f6128 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/impl.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -6,8 +6,6 @@ local log = require "services.log" local fibers = require "fibers" local exec = require "fibers.io.exec" local op = require "fibers.op" -local fiber = require "fibers.fiber" -local scope = require "fibers.scope" -- Other modules local cache_mod = require "shared.cache" @@ -118,44 +116,75 @@ local FIELD_POST_PROCESSORS = { ---@param cache Cache ---@return string error local function fetch_modem_info(identity, cache) - local cmd = exec.command { - "mmcli", "-J", "-m", identity.address, - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - return "mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output) - end + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-J", "-m", identity.address, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output)) + end - local data, json_err = json.decode(output) - if not data then - return "Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) - end + local data, json_err = json.decode(output) + if not data then + error("Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output)) + end - local modem = data.modem + local modem = data.modem - local flat, errors = nested_to_flat(modem, MODEM_INFO_PATHS) - if #errors > 0 then - log.warn("Errors formatting modem info: " .. table.concat(errors, ";\n\t")) - end + local flat, errors = nested_to_flat(modem, MODEM_INFO_PATHS) + if #errors > 0 then + log.warn("Errors formatting modem info: " .. table.concat(errors, ";\n\t")) + end - -- Apply post-processors to transform fields before caching - for field_name, processor in pairs(FIELD_POST_PROCESSORS) do - if flat[field_name] then - local cache_entries = processor(flat[field_name]) - for k, v in pairs(cache_entries) do - cache:set(k, v) + -- Apply post-processors to transform fields before caching + for field_name, processor in pairs(FIELD_POST_PROCESSORS) do + if flat[field_name] then + local cache_entries = processor(flat[field_name]) + for k, v in pairs(cache_entries) do + cache:set(k, v) + end + flat[field_name] = nil -- Remove original field since we cached the processed entries end - flat[field_name] = nil -- Remove original field since we cached the processed entries end - end - for k, v in pairs(flat) do - cache:set(k, v) + for k, v in pairs(flat) do + cache:set(k, v) + end + end) + return st ~= "ok" and err or "" +end + +--- Read a net stat +---@param net_port string +---@return integer rx_bytes +---@return string error +local function read_net_stat(net_port, stat) + local st, _, rx_bytes_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 rx_bytes = tonumber(content) + if not rx_bytes then + error("Failed to parse rx_bytes: " .. tostring(content)) + end + return rx_bytes + end) + + if st == "ok" then + return rx_bytes_or_err, "" end - return "" + return -1, rx_bytes_or_err or "Unknown error" end --- Get the table values from the active signal tech @@ -186,33 +215,35 @@ end ---@param cache Cache ---@return string error local function fetch_signal_info(identity, cache) - local cmd = exec.command { - "mmcli", "-J", "-m", identity.address, "--signal-get", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" or code ~= 0 then - return "mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output) - end + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-J", "-m", identity.address, "--signal-get", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output)) + end - local data, json_err = json.decode(output) - if not data then - return "Failed to decode mmcli output as JSON: " .. tostring(json_err) .. ", output: " .. tostring(output) - end + local data, json_err = json.decode(output) + if not data then + error("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 not signal_techs then - return "No signal info found in mmcli output: " .. tostring(output) - end + local signal_techs = data.modem and data.modem.signal or nil + if not signal_techs then + error("No signal info found in mmcli output: " .. tostring(output)) + end - local active_signal, active_err = get_active_signal(signal_techs) - if active_err ~= "" then - return "Failed to get active signal: " .. tostring(active_err) - end - cache:set("signal", active_signal) - return "" + local active_signal, active_err = get_active_signal(signal_techs) + if active_err ~= "" then + error("Failed to get active signal: " .. tostring(active_err)) + end + cache:set("signal", active_signal) + end) + return st ~= "ok" and err or "" end @@ -244,6 +275,14 @@ local function get_identity(address, cache) qmi_port = qmi_ports[1] end + local mbim_ports = cache:get("mbim_ports") + local mbim_port + if mbim_ports and type(mbim_ports) == "table" then + mbim_port = mbim_ports[1] + end + + local mode_port = mbim_port or qmi_port -- Prefer mbim port if available, otherwise use qmi port + local at_ports = cache:get("at_ports") local at_port if at_ports and type(at_ports) == "table" then @@ -259,7 +298,7 @@ local function get_identity(address, cache) local id, id_err = modem_types.new.ModemIdentity( imei, address, - qmi_port, + mode_port, at_port, net_port, device @@ -306,88 +345,73 @@ end ---- Public backend interface ---- +--- See ModemBackend class definition in services.hal.types.modem ----@class ModemBackend ----@field identity ModemIdentity ----@field cache Cache ----@field inhibit_cmd Command|nil ----@field imei fun(self: ModemBackend, timeout: number?): string, string ----@field device fun(self: ModemBackend, timeout: number?): string, string ----@field primary_port fun(self: ModemBackend, timeout: number?): string, string ----@field at_ports fun(self: ModemBackend, timeout: number?): table, string ----@field qmi_ports fun(self: ModemBackend, timeout: number?): table, string ----@field gps_ports fun(self: ModemBackend, timeout: number?): table, string ----@field net_ports fun(self: ModemBackend, timeout: number?): table, string ----@field ignored_ports fun(self: ModemBackend, timeout: number?): table, string ----@field access_techs fun(self: ModemBackend, timeout: number?): table, string ----@field sim fun(self: ModemBackend, timeout: number?): string, string ----@field drivers fun(self: ModemBackend, timeout: number?): table, string ----@field plugin fun(self: ModemBackend, timeout: number?): string, string ----@field model fun(self: ModemBackend, timeout: number?): string, string ----@field revision fun(self: ModemBackend, timeout: number?): string, string ----@field enable fun(self: ModemBackend): boolean, string ----@field disable fun(self: ModemBackend): boolean, string ----@field reset fun(self: ModemBackend): boolean, string ----@field connect fun(self: ModemBackend, conn_string: string): boolean, string ----@field disconnect fun(self: ModemBackend): boolean, string ----@field inhibit fun(self: ModemBackend): boolean, string ----@field uninhibit fun(self: ModemBackend): boolean, string ----@field monitor_state_op fun(self: ModemBackend): Op local ModemBackend = {} ModemBackend.__index = ModemBackend -- Add all getter methods to ModemBackend -getters.add_getters(ModemBackend, fetch_modem_info, fetch_signal_info) +getters.add_getters(ModemBackend, + fetch_modem_info, + fetch_signal_info, + read_net_stat +) --- Enable the modem ---@return boolean ok ---@return string error function ModemBackend:enable() - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "-e", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" then - return false, "mmcli command failed to execute: " .. tostring(err) - end - return true, "" + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-e", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Disable the modem ---@return boolean ok ---@return string error function ModemBackend:disable() - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "-d", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" then - return false, "mmcli command failed to execute: " .. tostring(err) - end - return true, "" + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "-d", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Reset the modem ---@return boolean ok ---@return string error function ModemBackend:reset() - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "--reset", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" then - return false, "mmcli command failed to execute: " .. tostring(err) - end - return true, "" + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--reset", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Connect the modem @@ -396,34 +420,38 @@ end ---@return string error function ModemBackend:connect(conn_string) local full_conn_string = "--simple-connect=" .. conn_string - local cmd = exec.command { - "mmcli", "-m", self.identity.address, full_conn_string, - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" then - return false, "mmcli command failed to execute: " .. tostring(err) - end - return true, "" + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, full_conn_string, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Disconnect the modem ---@return boolean ok ---@return string error function ModemBackend:disconnect() - local cmd = exec.command { - "mmcli", "-m", self.identity.address, "--simple-disconnect", - stdin = "null", - stdout = "pipe", - stderr = "stdout" - } - local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) - if status ~= "exited" then - return false, "mmcli command failed to execute: " .. tostring(err) - end - return true, "" + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--simple-disconnect", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Inhibit the modem @@ -466,9 +494,13 @@ function ModemBackend:uninhibit() return true, "Modem uninhibited" end ---- Listen for modem state changes ----@return Op -function ModemBackend:monitor_state_op() +--- Start monitoring modem state changes +---@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", @@ -477,9 +509,41 @@ function ModemBackend:monitor_state_op() } local stream, err = cmd:stdout_stream() if not stream then - error("Failed to start monitor state command: " .. tostring(err)) + return false, "Failed to start monitor state command: " .. tostring(err) end - return stream:read_line_op():wrap(parse_modem_state_line) + self.state_monitor = { cmd = cmd, stream = stream } + return true, "" +end + +--- Listen for modem state changes +---@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(parse_modem_state_line) + end) +end + +--- Set the modem signal update interval +---@param period number +---@return boolean ok +---@return string error +function ModemBackend:set_signal_update_interval(period) + local st, _, err = fibers.run_scope(function() + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--signal-setup=" .. tostring(period), + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" then + error("mmcli command failed to execute: " .. tostring(err)) + end + end) + return st == "ok", err or "" end --- Builds the backend instance @@ -489,6 +553,7 @@ local function new(address) local self = { cache = cache, identity = get_identity(address, cache), + base = "linux_mm" } return setmetatable(self, ModemBackend) end 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 9a40487b..3af9cc5f 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/init.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -1,16 +1,19 @@ local file = require "fibers.io.file" local exec = require "fibers.io.exec" local op = require "fibers.op" +local fibers = require "fibers" + +local backend = require "services.hal.backends.modem.providers.linux_mm.impl" local function is_linux() - local fh = file.open("/proc/version", "r") - if not fh then + local fh, open_err = file.open("/proc/version", "r") + if not fh or open_err then return false end - local content = fh:read("*a") + local content, read_err = fh:read_all() fh:close() - if not content then + if not content or read_err then return false end @@ -26,7 +29,7 @@ local function has_mmcli() stdout = "pipe", stderr = "stdout" } - local _, status, code, _, err = op.perform(cmd:combined_output_op()) + local _, status, code, _, err = fibers.perform(cmd:combined_output_op()) if status == "exited" and code == 0 then return true end @@ -36,11 +39,10 @@ end --- Returns if linux with modem manager is supported ---@return boolean local function is_supported() - return is_linux() and has_mmcli() + local res = is_linux() and has_mmcli() + return res end -local backend = require "services.hal.backends.modem.providers.linux_mm.impl" - return { is_supported = is_supported, backend = backend From 7b11e14441eaf1924337188f80a814701e381c69 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 17 Feb 2026 14:23:06 +0000 Subject: [PATCH 13/33] update ModemcardManager to improve scope handling and error logging --- src/services/hal/managers/modemcard.lua | 127 +++++++++++++++--------- 1 file changed, 78 insertions(+), 49 deletions(-) diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua index 7d2bf54d..83647eda 100644 --- a/src/services/hal/managers/modemcard.lua +++ b/src/services/hal/managers/modemcard.lua @@ -1,12 +1,11 @@ -- HAL modules local mmcli = require "services.hal.backends.mmcli" local hal_types = require "services.hal.types.core" -local modem_types = require "services.hal.types.modem" +local external_types = require "services.hal.types.external" local modem_driver = require "services.hal.drivers.modem" -- Fiber modules local fibers = require "fibers" -local scope_mod = require "fibers.scope" local op = require "fibers.op" local channel = require "fibers.channel" local sleep = require "fibers.sleep" @@ -22,13 +21,14 @@ local STOP_TIMEOUT = 5.0 -- seconds ---@class ModemDriver ---@class ModemcardManager ----@field spawned boolean +---@field scope Scope +---@field started boolean ---@field modem_remove_ch Channel ---@field modem_detect_ch Channel ---@field driver_ch Channel ---@field modems table local ModemcardManager = { - spawned = false, + started = false, modem_remove_ch = channel.new(), modem_detect_ch = channel.new(), driver_ch = channel.new(), @@ -51,15 +51,18 @@ end ---Continuously monitors modem add/remove events and publishes them onto ---`ModemcardManager.modem_detect_ch` and `ModemcardManager.modem_remove_ch`. -local function detector() +---@param scope Scope +local function detector(scope) log.trace("Modem Detector: started") + scope:finally(function () + log.trace("Modem Detector: closed") + end) + local monitor_cmd = mmcli.monitor_modems() local stdout, err = monitor_cmd:stdout_stream() if not stdout then - log.error("Modem Detector: failed to open stdout stream:", err) - log.trace("Modem Detector: closed") - return + error("Modem Detector: failed to get stdout stream: " .. err) end while true do @@ -84,8 +87,6 @@ local function detector() ModemcardManager.modem_remove_ch:put(address) end end - - log.trace("Modem Detector: closed") end ---Handle modem removal. @@ -105,14 +106,11 @@ local function on_remove(dev_ev_ch, address) return end - local identity, id_err = driver:get_identity() - if not identity then - log.error("Modemcard Manager: failed to get identity for removal at", address, id_err) - return - end - local device = identity.device + -- 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 device = driver:get(external_types.new.ModemGetOpts("device", math.huge)) - fibers.spawn(function() + fibers.current_scope():spawn(function() local ok, stop_err = driver:stop(STOP_TIMEOUT) if not ok then log.error("Modemcard Manager: failed to stop driver:", stop_err) @@ -145,18 +143,16 @@ local function on_detection(address) log.info("Modemcard Manager: creating modem at", address) - -- Create a child scope for the driver - local child_scope = fibers.current_scope():child() - local driver, drv_err = modem_driver.new(child_scope, address) + local driver, drv_err = modem_driver.new(address) if not driver then log.error("Modemcard Manager: failed to create modem driver:", drv_err) return end - fibers.spawn(function() + fibers.current_scope():spawn(function() local init_err = driver:init() if init_err ~= "" then - log.error("Modemcard Manager: failed to init modem driver:", init_err) + log.error(("Modemcard Manager: failed to init modem driver %s: %s"):format(address, init_err)) return end ModemcardManager.driver_ch:put(driver) @@ -170,18 +166,11 @@ end ---@param driver Modem ---@return nil local function on_driver(dev_ev_ch, cap_emit_ch, driver) - if getmetatable(driver) ~= modem_driver.ModemDriver then - log.error("Modemcard Manager: invalid driver received") - return - end - - local identity, id_err = driver:get_identity() - if not identity then - log.error("Modemcard Manager: failed to get driver identity:", id_err) - return - end + local address = driver.address + -- Get device, no need to have a fresh value so set cache lifetime to infinity + local device = driver:get(external_types.new.ModemGetOpts("device", math.huge)) - ModemcardManager.modems[identity.address] = driver + ModemcardManager.modems[driver.address] = driver -- Build capabilities local capabilities, cap_err = driver:capabilities(cap_emit_ch) @@ -200,10 +189,10 @@ local function on_driver(dev_ev_ch, cap_emit_ch, driver) local device_event, ev_err = hal_types.new.DeviceEvent( "added", "modemcard", - identity.device, + device, { - address = identity.address, - port = identity.device -- the device field holds the usb or pcie port info + address = address, + port = device -- the device field holds the usb or pcie port info }, capabilities ) @@ -217,16 +206,33 @@ local function on_driver(dev_ev_ch, cap_emit_ch, driver) end ---Modemcard Manager notifies HAL of modem additions/removals. +---@param scope Scope ---@param dev_ev_ch Channel Device event channel (DeviceEvent messages) ---@param cap_emit_ch Channel Capability emit channel (Emit messages) ---@return nil -local function manager(dev_ev_ch, cap_emit_ch) +local function manager(scope, dev_ev_ch, cap_emit_ch) log.trace("Modemcard Manager: started") + + scope:finally(function () + log.trace("Modemcard Manager: closed") + end) + while true do - local source, msg, err = fibers.perform(op.named_choice { + 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 @@ -240,29 +246,46 @@ local function manager(dev_ev_ch, cap_emit_ch) 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("Modemcard Manager: driver fault detected for modem at", msg) + on_remove(dev_ev_ch, msg) + else + log.error("Modemcard Manager: unknown operation source:", source) end end - - log.trace("Modemcard Manager: closed") end ---Starts the Modemcard Manager's detector and manager fibers. ----@param scope Scope ---@param dev_ev_ch Channel ---@param cap_emit_ch Channel -function ModemcardManager.start(scope, dev_ev_ch, cap_emit_ch) - if ModemcardManager.spawned then - log.warn("Modemcard Manager: already spawned") - return +---@return string error +function ModemcardManager.start(dev_ev_ch, cap_emit_ch) + 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 - scope:spawn(detector) - scope:spawn(manager, dev_ev_ch, cap_emit_ch) + -- 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(("Modem Manager: error - %s"):format(tostring(primary))) + log.trace("Modem Manager: scope exiting with status", st) + end + log.trace("Modem Manager: stopped") + end) + + ModemcardManager.scope:spawn(detector) + ModemcardManager.scope:spawn(manager, dev_ev_ch, cap_emit_ch) - ModemcardManager.spawned = true - log.trace("Modemcard Manager: spawned") + ModemcardManager.started = true + log.trace("Modemcard Manager: started") + return "" end ---Stops the Modemcard Manager. @@ -270,6 +293,9 @@ 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 ModemcardManager.scope:cancel() @@ -281,5 +307,8 @@ function ModemcardManager.stop(timeout) if source == "timeout" then return false, "modemcard manager stop timeout" end + ModemcardManager.started = false return true, "" end + +return ModemcardManager From 2cc8204f3bd1f2fee60aa81b0a67c535298bdf5d Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 17 Feb 2026 14:23:28 +0000 Subject: [PATCH 14/33] at lib for modem backend --- src/services/hal/backends/modem/at.lua | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/services/hal/backends/modem/at.lua diff --git a/src/services/hal/backends/modem/at.lua b/src/services/hal/backends/modem/at.lua new file mode 100644 index 00000000..d4d5e740 --- /dev/null +++ b/src/services/hal/backends/modem/at.lua @@ -0,0 +1,86 @@ +package.path = '/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua;' .. package.path + +local file = require 'fibers.io.file' +local fibers = require 'fibers' + +---@alias ATCommand string +---@alias SerialPortPath string +---@alias ATResponseLine string + +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]+$", "")) +end + +---@class AT +local at = {} + +---Send an AT command to a serial port and collect response lines until OK/ERROR. +--- +---Notes: +--- - Returns a list of non-empty response lines. +--- - On success, error is nil. +--- - On error, error may be a generic string ('error', 'unknown error') or a +--- numeric string parsed from +CME/+CMS. +---@param port SerialPortPath +---@param command ATCommand +---@return ATResponseLine[]? lines +---@return string error +function at.send(port, command) + local st, _, err, result = fibers.run_scope(function() + local reader, err = file.open(port, "r") + if not reader then return "error opening AT read port: " .. err, nil end + + local writer = assert(file.open(port, "w")) + if not writer then return "error opening AT write port: " .. err, nil end + + -- file write + writer:write_chars(command .. '\r') + + writer:close() + + local res = {} + + while true do + local line = reader:read_line() + + if not line then return 'unknown error', nil end + + line = trim(line) + + -- check for non-descriptive success/fail + if line:find("^OK$") then + reader:close() + return res, nil + elseif line:find("^ERROR$") then + reader:close() + return res, 'error' + else + -- check for descriptive fail + local error_code + error_code = line:match("^%+CME ERROR: (%d+)$") + if error_code then + reader:close() + return error_code, res + end + error_code = line:match("^%+CMS ERROR: (%d+)$") + if error_code then + reader:close() + return error_code, res + end + end + + if #line > 0 then table.insert(res, line) end + end + end) + + if st == 'ok' then + return result, "" + else + return nil, err or "AT command failed" + end +end + +return at From fcf7ba26666a4e716d9d604486b44bfa11a7b9e6 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Tue, 17 Feb 2026 14:24:40 +0000 Subject: [PATCH 15/33] Modem backend definition --- src/services/hal/types/modem.lua | 63 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/services/hal/types/modem.lua b/src/services/hal/types/modem.lua index 8f2c6c3f..4e982c7b 100644 --- a/src/services/hal/types/modem.lua +++ b/src/services/hal/types/modem.lua @@ -32,8 +32,9 @@ end ---@class ModemIdentity ---@field imei string ---@field address ModemAddress ----@field primary_port string +---@field mode_port string ---@field at_port string +---@field net_port string ---@field device string local ModemIdentity = {} ModemIdentity.__index = ModemIdentity @@ -41,12 +42,13 @@ ModemIdentity.__index = ModemIdentity ---Create a new ModemIdentity. ---@param imei string ---@param address ModemAddress ----@param primary_port string +---@param mode_port string ---@param at_port string +---@param net_port string ---@param device string ---@return ModemIdentity? ---@return string error -function new.ModemIdentity(imei, address, primary_port, at_port, device) +function new.ModemIdentity(imei, address, mode_port, at_port, net_port, device) if type(imei) ~= 'string' or imei == '' then return nil, "invalid imei" end @@ -55,14 +57,18 @@ function new.ModemIdentity(imei, address, primary_port, at_port, device) return nil, "invalid address" end - if type(primary_port) ~= 'string' or primary_port == '' then - return nil, "invalid primary_port" + if type(mode_port) ~= 'string' or mode_port == '' then + return nil, "invalid mode_port" end if type(at_port) ~= 'string' or at_port == '' then return nil, "invalid at_port" end + if type(net_port) ~= 'string' or net_port == '' then + return nil, "invalid net_port" + end + if type(device) ~= 'string' or device == '' then return nil, "invalid device" end @@ -70,8 +76,9 @@ function new.ModemIdentity(imei, address, primary_port, at_port, device) local identity = setmetatable({ imei = imei, address = address, - primary_port = primary_port, + mode_port = mode_port, at_port = at_port, + net_port = net_port, device = device, }, ModemIdentity) return identity, "" @@ -134,6 +141,50 @@ function new.ModemStateRemovedEvent(reason) return new.ModemStateEvent("removed", "removed", "removed", reason) end +---@class ModemBackend +---@field identity ModemIdentity +---@field cache Cache +---@field base string +---@field inhibit_cmd Command? +---@field state_monitor table? +---@field sim_present table? +---@field imei fun(self: ModemBackend, timeout: number?): string, string +---@field device fun(self: ModemBackend, timeout: number?): string, string +---@field primary_port fun(self: ModemBackend, timeout: number?): string, string +---@field at_ports fun(self: ModemBackend, timeout: number?): table, string +---@field qmi_ports fun(self: ModemBackend, timeout: number?): table, string +---@field gps_ports fun(self: ModemBackend, timeout: number?): table, string +---@field net_ports fun(self: ModemBackend, timeout: number?): table, string +---@field access_techs fun(self: ModemBackend, timeout: number?): table, string +---@field sim fun(self: ModemBackend, timeout: number?): string, string +---@field drivers fun(self: ModemBackend, timeout: number?): table, string +---@field plugin fun(self: ModemBackend, timeout: number?): string, string +---@field model fun(self: ModemBackend, timeout: number?): string, string +---@field revision fun(self: ModemBackend, timeout: number?): string, string +---@field operator fun(self: ModemBackend, timeout: number?): string, string +---@field rx_bytes fun(self: ModemBackend): integer, string +---@field tx_bytes fun(self: ModemBackend): integer, string +---@field signal fun(self: ModemBackend, timeout: number?): table, string +---@field mcc fun(self: ModemBackend, timeout: number?): string, string +---@field mnc fun(self: ModemBackend, timeout: number?): string, string +---@field gid1 fun(self: ModemBackend, timeout: number?): string, string +---@field active_band_class fun(self: ModemBackend, timeout: number?): string, string +---@field start_state_monitor fun(self: ModemBackend): boolean, string +---@field monitor_state_op fun(self: ModemBackend): Op +---@field start_sim_presence_monitor fun(self: ModemBackend): boolean, string +---@field wait_for_sim_present_op fun(self: ModemBackend): Op +---@field wait_for_sim_present fun(self: ModemBackend): boolean, string +---@field is_sim_present fun(self: ModemBackend): boolean, string +---@field trigger_sim_presence_check fun(self: ModemBackend, cooldown: number?): boolean, string +---@field enable fun(self: ModemBackend): boolean, string +---@field disable fun(self: ModemBackend): boolean, string +---@field reset fun(self: ModemBackend): boolean, string +---@field connect fun(self: ModemBackend, conn_string: string): boolean, string +---@field disconnect fun(self: ModemBackend): boolean, string +---@field inhibit fun(self: ModemBackend): boolean, string +---@field uninhibit fun(self: ModemBackend): boolean, string +---@field set_signal_update_interval fun(self: ModemBackend, interval: number): boolean, string + return { ModemDevice = ModemDevice, ModemIdentity = ModemIdentity, From b1146114e680ff074da228acc04731b3cab3bcbd Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:03:13 +0000 Subject: [PATCH 16/33] Initial GSM with fibers-next --- src/services/gsm.lua | 850 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 src/services/gsm.lua diff --git a/src/services/gsm.lua b/src/services/gsm.lua new file mode 100644 index 00000000..bc15b8ad --- /dev/null +++ b/src/services/gsm.lua @@ -0,0 +1,850 @@ +-- services/gsm.lua +-- +-- GSM service (new fibers): +-- - consumes HAL modem capabilities +-- - runs per-modem child scopes for autoconnect + metrics +-- - publishes derived observability metrics only + +local fibers = require "fibers" +local op = require "fibers.op" +local sleep = require "fibers.sleep" +local pulse = require "fibers.pulse" + +local perform = fibers.perform + +local log = require "services.log" +local external_types = require "services.hal.types.external" + +local REQUEST_TIMEOUT = 10 +local DEFAULT_RETRY_TIMEOUT = 5 +local DEFAULT_METRICS_INTERVAL = 10 +local DEFAULT_SIGNAL_FREQ = 5 + +local SCOREMAP = { + cdma1x = { rssi = { -110, -100, -86, -70, 1000000 } }, + evdo = { rssi = { -110, -100, -86, -70, 1000000 } }, + gsm = { rssi = { -110, -100, -86, -70, 1000000 } }, + umts = { rscp = { -124, -95, -85, -75, 1000000 } }, + lte = { rsrp = { -115, -105, -95, -85, 1000000 } }, + ["5g"] = { rsrp = { -115, -105, -95, -85, 1000000 } }, +} + +local ACCESS_TECH_MAP = { + { tokens = { 'lte', '5gnr' }, tech = '5g' }, + { tokens = { '5gnr' }, tech = '5g' }, + { tokens = { '5g' }, tech = '5g' }, + { tokens = { 'lte' }, tech = 'lte' }, + { tokens = { 'umts' }, tech = 'umts' }, + { tokens = { 'gsm' }, tech = 'gsm' }, + { tokens = { 'evdo' }, tech = 'evdo' }, + { tokens = { 'cdma1x' }, tech = 'cdma1x' }, +} + +---@return table +local function t(...) + return { ... } +end + +-- Topic helpers (centralized so we can remap if needed) +---@param name string +---@return table +local function t_cfg(name) + return { 'cfg', name } +end + +---@param id string|number +---@return table +local function t_cap_state(id) + return { 'cap', 'modem', id, 'state' } +end + +---@param id string|number +---@param method string +---@return table +local function t_cap_rpc(id, method) + return { 'cap', 'modem', id, 'rpc', method } +end + +---@param id string|number +---@param key string +---@return table +local function t_obs_metric(id, key) + return { 'obs', 'v1', 'gsm', 'metric', id, key } +end + +---@param id string|number +---@param key string +---@return table +local function t_obs_event(id, key) + return { 'obs', 'v1', 'gsm', 'event', id, key } +end + +---@param conn Connection +---@param name string +---@param state string +---@param extra table? +local function publish_status(conn, name, state, extra) + local payload = { state = state, ts = fibers.now() } + if type(extra) == 'table' then + for k, v in pairs(extra) do payload[k] = v end + end + conn:retain(t('svc', name, 'status'), payload) +end + +---@param conn Connection +---@param id string|number +---@param method string +---@param payload table? +---@param timeout number? +---@return any +---@return string +local function call_modem_rpc(conn, id, method, payload, timeout) + local reply, err = conn:call(t_cap_rpc(id, method), payload or {}, { + timeout = timeout or REQUEST_TIMEOUT, + }) + if not reply then + return false, err or "rpc failed" + end + if reply.ok ~= true then + return false, reply.reason or 'rpc failed' + end + if reply.reason == nil then + return false, "empty reply" + end + return reply.reason, "" +end + +---@param conn Connection +---@param id string|number +---@param field string +---@param timeout number? +---@return any +---@return string +local function modem_get_field(conn, id, field, timeout) + local opts, opts_err = external_types.new.ModemGetOpts(field) + if not opts then + return false, opts_err or "invalid modem get opts" + end + return call_modem_rpc(conn, id, 'get', opts, timeout) +end + +---@param conn Connection +---@param id string|number +---@param freq number +---@return any +---@return string +local function modem_set_signal_freq(conn, id, freq) + local opts, opts_err = external_types.new.ModemSignalUpdateOpts(freq) + if not opts then + return false, opts_err or "invalid signal update opts" + end + return call_modem_rpc(conn, id, 'set_signal_update_freq', opts, REQUEST_TIMEOUT) +end + +---@param tbl table? +---@return table +local function shallow_copy(tbl) + local out = {} + if tbl then + for k, v in pairs(tbl) do out[k] = v end + end + return out +end + +---@param cfg table +---@param defaults table +local function apply_defaults(cfg, defaults) + for key, value in pairs(defaults) do + if cfg[key] == nil then + cfg[key] = value + elseif type(value) == 'table' and type(cfg[key]) == 'table' then + apply_defaults(cfg[key], value) + end + end +end + +---@param value any +---@return boolean +local function is_plain_table(value) + return type(value) == 'table' and getmetatable(value) == nil +end + +---@param access_techs any +---@return string +local function derive_access_tech(access_techs) + local techs = {} + if type(access_techs) == 'string' then + for token in string.gmatch(access_techs, "([^,]+)") do + techs[token] = true + end + elseif is_plain_table(access_techs) then + for _, token in ipairs(access_techs) do + techs[token] = true + end + end + + for _, rule in ipairs(ACCESS_TECH_MAP) do + local all_match = true + for _, required_token in ipairs(rule.tokens) do + if not techs[required_token] then + all_match = false + break + end + end + if all_match then + return rule.tech + end + end + + return "" +end + +---@param access_tech string +---@return string +local function get_access_family(access_tech) + local map = { + cdma1x = '3G', + evdo = '3G', + gsm = '2G', + umts = '3G', + lte = '4G', + ['5g'] = '5G', + } + return map[access_tech] or "" +end + +---@param access_tech string +---@param signal_type string +---@param signal number +---@return number +---@return string +local function get_signal_bars(access_tech, signal_type, signal) + local tech_map = SCOREMAP[access_tech] + if not tech_map then + return 0, "invalid access tech" + end + + local thresholds = tech_map[signal_type] + if not thresholds then + return 0, "invalid signal type" + end + + for index, threshold in ipairs(thresholds) do + if signal < threshold then + return index, "" + end + end + + return 0, "" +end + +---@param sim_value any +---@return string +local function normalize_sim_presence(sim_value) + if sim_value == nil or sim_value == '--' then + return "--" + end + if type(sim_value) == 'table' then + return "present" + end + if tostring(sim_value) ~= '' then + return "present" + end + return "--" +end + +---@param access_techs any +---@param access_err string +---@param rssi any +---@param rssi_err string +---@param rsrp any +---@param rsrp_err string +---@param rscp any +---@param rscp_err string +---@return string +---@return number +---@return string +local function select_signal_for_bars(access_techs, access_err, rssi, rssi_err, rsrp, rsrp_err, rscp, rscp_err) + if access_err ~= "" then + return "", 0, "access tech unavailable" + end + + local access_tech = derive_access_tech(access_techs) + if access_tech == "" then + return "", 0, "access tech unknown" + end + + if access_tech == 'umts' and rscp_err == "" then + local rscp_value = tonumber(rscp) + if rscp_value then + return access_tech, rscp_value, "rscp" + end + end + + if (access_tech == 'lte' or access_tech == '5g') and rsrp_err == "" then + local rsrp_value = tonumber(rsrp) + if rsrp_value then + return access_tech, rsrp_value, "rsrp" + end + end + + if rssi_err == "" then + local rssi_value = tonumber(rssi) + if rssi_value then + return access_tech, rssi_value, "rssi" + end + end + + return access_tech, 0, "" +end + +---@param cfg table? +---@return table +---@return string +local function normalize_config(cfg) + if not is_plain_table(cfg) then + return {}, "config must be a table" + end + ---@cast cfg table + local modems_cfg = rawget(cfg, 'modems') + if not is_plain_table(modems_cfg) then + return {}, "config.modems must be a table" + end + local modems_default = rawget(modems_cfg, 'default') + if not is_plain_table(modems_default) then + return {}, "config.modems.default must be a table" + end + local modems_known = rawget(modems_cfg, 'known') + if modems_known ~= nil and type(modems_known) ~= 'table' then + return {}, "config.modems.known must be a list" + end + return shallow_copy(cfg), "" +end + +---@param cfg table +---@param imei string|number +---@param device string +---@return table +---@return string +---@return string +local function get_modem_config(cfg, imei, device) + local base = shallow_copy(cfg.modems and cfg.modems.default or {}) + local known = cfg.modems and cfg.modems.known + if type(known) == 'table' then + for _, entry in ipairs(known) do + if is_plain_table(entry) then + local id_field = entry.id_field or 'imei' + if (id_field == 'device' and device ~= "" and entry.device == device) + or (id_field ~= 'device' and ((entry.imei == imei) or (entry.id == imei))) + then + local merged = shallow_copy(entry) + apply_defaults(merged, base) + return merged, (merged.name or ""), "" + end + end + end + end + + return base, "", "" +end + +---@class GsmModem +---@field conn Connection +---@field id string|number +---@field name string +---@field cfg table +---@field device string +---@field scope Scope? +---@field config_pulse Pulse +local GsmModem = {} +GsmModem.__index = GsmModem + +---@param conn Connection +---@param id string|number +---@return GsmModem +function GsmModem.new(conn, id) + local self = setmetatable({}, GsmModem) + self.conn = conn + self.id = id + self.name = tostring(id) + self.cfg = {} + self.device = "" + self.scope = nil + self.config_pulse = pulse.new() + return self +end + +---@return nil +function GsmModem:_signal_config_change() + self.config_pulse:signal() +end + +---@param cfg table +---@param name string? +---@return nil +function GsmModem:apply_config(cfg, name) + self.cfg = cfg or {} + if name and name ~= "" then + self.name = name + end + self:_signal_config_change() +end + +---@param key string +---@param value any +---@return nil +function GsmModem:_emit_metric(key, value) + if value == nil then + return + end + self.conn:publish(t_obs_metric(self.id, key), value) +end + +---@param key string +---@param value any +---@return nil +function GsmModem:_emit_event(key, value) + if value == nil then + return + end + self.conn:publish(t_obs_event(self.id, key), value) +end + +---@return nil +function GsmModem:_emit_metrics_once() + -- Derived metrics only; HAL remains the source of truth. + local access_techs, access_err = modem_get_field(self.conn, self.id, 'access_techs', REQUEST_TIMEOUT) + if access_err ~= "" then + log.debug("GSM", self.id, "- access_techs:", access_err) + else + self:_emit_metric('access_techs', access_techs) + local access_tech = derive_access_tech(access_techs) + if access_tech ~= "" then + self:_emit_metric('access_tech', access_tech) + local access_family = get_access_family(access_tech) + if access_family ~= "" then + self:_emit_metric('access_family', access_family) + end + end + end + + local band, band_err = modem_get_field(self.conn, self.id, 'band', REQUEST_TIMEOUT) + if band_err ~= "" then + log.debug("GSM", self.id, "- band:", band_err) + else + self:_emit_metric('band', band) + end + + local imei, imei_err = modem_get_field(self.conn, self.id, 'imei', REQUEST_TIMEOUT) + if imei_err ~= "" then + log.debug("GSM", self.id, "- imei:", imei_err) + else + self:_emit_metric('imei', imei) + end + + local operator, operator_err = modem_get_field(self.conn, self.id, 'operator', REQUEST_TIMEOUT) + if operator_err ~= "" then + log.debug("GSM", self.id, "- operator:", operator_err) + else + self:_emit_metric('operator', operator) + end + + local sim, sim_err = modem_get_field(self.conn, self.id, 'sim', REQUEST_TIMEOUT) + if sim_err ~= "" then + log.debug("GSM", self.id, "- sim:", sim_err) + else + self:_emit_metric('sim', normalize_sim_presence(sim)) + end + + local iccid, iccid_err = modem_get_field(self.conn, self.id, 'iccid', REQUEST_TIMEOUT) + if iccid_err ~= "" then + log.debug("GSM", self.id, "- iccid:", iccid_err) + else + self:_emit_metric('iccid', iccid) + end + + local firmware, firmware_err = modem_get_field(self.conn, self.id, 'firmware', REQUEST_TIMEOUT) + if firmware_err ~= "" then + log.debug("GSM", self.id, "- firmware:", firmware_err) + else + self:_emit_metric('firmware', firmware) + end + + local state, state_err = modem_get_field(self.conn, self.id, 'state', REQUEST_TIMEOUT) + if state_err ~= "" then + log.debug("GSM", self.id, "- state:", state_err) + else + self:_emit_metric('state', state) + end + + local net_ports, net_ports_err = modem_get_field(self.conn, self.id, 'net_ports', REQUEST_TIMEOUT) + if net_ports_err ~= "" then + log.debug("GSM", self.id, "- net_ports:", net_ports_err) + else + local interface = net_ports and net_ports[1] + if interface then + self:_emit_metric('interface', interface) + else + log.debug("GSM", self.id, "- no net_ports available") + end + end + + local rx_bytes, rx_err = modem_get_field(self.conn, self.id, 'rx_bytes', REQUEST_TIMEOUT) + if rx_err ~= "" then + log.debug("GSM", self.id, "- rx_bytes:", rx_err) + else + self:_emit_metric('rx_bytes', rx_bytes) + end + + local tx_bytes, tx_err = modem_get_field(self.conn, self.id, 'tx_bytes', REQUEST_TIMEOUT) + if tx_err ~= "" then + log.debug("GSM", self.id, "- tx_bytes:", tx_err) + else + self:_emit_metric('tx_bytes', tx_bytes) + end + + local rssi, rssi_err = modem_get_field(self.conn, self.id, 'rssi', REQUEST_TIMEOUT) + if rssi_err ~= "" then + log.debug("GSM", self.id, "- rssi:", rssi_err) + else + self:_emit_metric('signal_rssi', rssi) + end + + local rsrp, rsrp_err = modem_get_field(self.conn, self.id, 'rsrp', REQUEST_TIMEOUT) + if rsrp_err ~= "" then + log.debug("GSM", self.id, "- rsrp:", rsrp_err) + else + self:_emit_metric('signal_rsrp', rsrp) + end + + local rsrq, rsrq_err = modem_get_field(self.conn, self.id, 'rsrq', REQUEST_TIMEOUT) + if rsrq_err ~= "" then + log.debug("GSM", self.id, "- rsrq:", rsrq_err) + else + self:_emit_metric('signal_rsrq', rsrq) + end + + local rscp, rscp_err = modem_get_field(self.conn, self.id, 'rscp', REQUEST_TIMEOUT) + if rscp_err ~= "" then + log.debug("GSM", self.id, "- rscp:", rscp_err) + else + self:_emit_metric('signal_rscp', rscp) + end + + local bars_access_tech, signal_value, signal_type = select_signal_for_bars( + access_techs, + access_err, + rssi, + rssi_err, + rsrp, + rsrp_err, + rscp, + rscp_err + ) + + if bars_access_tech ~= "" and signal_type ~= "" then + local bars, bars_err = get_signal_bars(bars_access_tech, signal_type, signal_value) + if bars_err == "" then + self:_emit_metric('bars', bars) + end + end +end + +---@return nil +function GsmModem:_metrics_loop() + local seen = self.config_pulse:version() + local interval = tonumber(self.cfg.metrics_interval) or DEFAULT_METRICS_INTERVAL + + while true do + local which, ver = perform(op.named_choice({ + tick = sleep.sleep_op(interval), + config = self.config_pulse:changed_op(seen), + })) + + if which == 'config' then + if not ver then + return + end + seen = ver + interval = tonumber(self.cfg.metrics_interval) or DEFAULT_METRICS_INTERVAL + else + self:_emit_metrics_once() + end + end +end + +---@return boolean +---@return string +function GsmModem:_connect_once() + -- TODO: Add APN selection logic; currently uses a precomputed connection string. + if not self.cfg.connection_string then + return false, "missing connection_string" + end + + local opts, opts_err = external_types.new.ModemConnectOpts(self.cfg.connection_string) + if not opts then + return false, opts_err or "invalid connection string" + end + + local _, err = call_modem_rpc(self.conn, self.id, 'connect', opts, REQUEST_TIMEOUT) + if err ~= "" then + return false, err + end + + return true, "" +end + +-- Autoconnect loop: reconnects on a simple backoff and reacts to config changes. +---@return nil +function GsmModem:_autoconnect_loop() + local seen = self.config_pulse:version() + + while true do + if not self.cfg.autoconnect then + local which, ver = perform(op.named_choice({ + idle = sleep.sleep_op(DEFAULT_RETRY_TIMEOUT), + config = self.config_pulse:changed_op(seen), + })) + + if which == 'config' then + if not ver then + return + end + seen = ver + end + else + local ok, err = self:_connect_once() + if ok then + self:_emit_event('autoconnect', 'connected') + else + self:_emit_event('autoconnect', 'failed') + log.debug("GSM", self.id, "- autoconnect failed:", err) + end + + local retry = tonumber(self.cfg.retry_interval) or DEFAULT_RETRY_TIMEOUT + local which, ver = perform(op.named_choice({ + backoff = sleep.sleep_op(retry), + config = self.config_pulse:changed_op(seen), + })) + + if which == 'config' then + if not ver then + return + end + seen = ver + end + end + end +end + +---@param parent_scope Scope +---@return boolean +---@return string +function GsmModem:start(parent_scope) + if self.scope then + return true, "" + end + + if self.config_pulse:is_closed() then + self.config_pulse = pulse.new() + end + + local child, err = parent_scope:child() + if not child then + return false, err or "failed to create modem scope" + end + + self.scope = child + + child:finally(function () + log.trace("GSM", self.id, "- modem scope closed") + end) + + local ok, spawn_err = child:spawn(function () + local signal_freq = tonumber(self.cfg.signal_freq) or DEFAULT_SIGNAL_FREQ + local _, sig_err = modem_set_signal_freq(self.conn, self.id, signal_freq) + if sig_err ~= "" then + log.debug("GSM", self.id, "- set_signal_update_freq:", sig_err) + end + end) + if not ok then + return false, spawn_err or "failed to spawn signal update" + end + + ok, spawn_err = child:spawn(function () + self:_metrics_loop() + end) + if not ok then + return false, spawn_err or "failed to spawn metrics loop" + end + + ok, spawn_err = child:spawn(function () + self:_autoconnect_loop() + end) + if not ok then + return false, spawn_err or "failed to spawn autoconnect loop" + end + + return true, "" +end + +---@param reason string? +---@param close_pulse boolean? +---@return nil +function GsmModem:stop(reason, close_pulse) + if not self.scope then + return + end + + self.scope:cancel(reason or 'modem stopped') + perform(self.scope:join_op()) + self.scope = nil + + if close_pulse then + self.config_pulse:close(reason or 'modem stopped') + end +end + +---@class GsmService +---@field name string +local GsmService = {} + +---@param conn Connection +---@param opts table? +---@return nil +function GsmService.start(conn, opts) + opts = opts or {} + local name = opts.name or 'gsm' + + publish_status(conn, name, 'starting') + + local current_cfg = {} + local config_ready = false + local modems = {} + + local parent_scope = fibers.current_scope() + + parent_scope:finally(function (_, st, primary) + for _, modem in pairs(modems) do + modem:stop(primary or st, true) + end + publish_status(conn, name, 'stopped', { reason = primary or st }) + end) + + local function ensure_modem(id) + local modem = modems[id] + if modem then + return modem + end + + modem = GsmModem.new(conn, id) + modems[id] = modem + + local device, device_err = modem_get_field(conn, id, 'device', REQUEST_TIMEOUT) + if device_err ~= "" then + log.debug("GSM", id, "- device lookup:", device_err) + else + modem.device = tostring(device or "") + end + + local cfg, modem_name, _ = get_modem_config(current_cfg, id, modem.device) + modem:apply_config(cfg, modem_name) + + local ok, err = modem:start(parent_scope) + if not ok then + log.debug("GSM", id, "- failed to start modem scope:", err) + end + + return modem + end + + local function remove_modem(id) + local modem = modems[id] + if not modem then + return + end + modem:stop('modem removed', true) + modems[id] = nil + end + + local cfg_sub = conn:subscribe(t_cfg(name), { + queue_len = 1, + full = 'drop_oldest', + }) + + while not config_ready do + local which, msg, err = perform(op.named_choice({ + cfg = cfg_sub:recv_op(), + timeout = sleep.sleep_op(REQUEST_TIMEOUT), + })) + + if which == 'timeout' then + log.warn("GSM", "- waiting for initial config") + else + if not msg then + log.warn("GSM", "- config subscription closed:", err) + return + end + if not is_plain_table(msg.payload) then + log.warn("GSM", "- invalid config payload") + else + local cfg, cfg_err = normalize_config(msg.payload) + if cfg_err ~= "" then + log.warn("GSM", "- invalid config:", cfg_err) + else + current_cfg = cfg + config_ready = true + end + end + end + end + + local cap_sub = conn:subscribe(t_cap_state('+'), { + queue_len = 1, + full = 'drop_oldest', + }) + + publish_status(conn, name, 'running') + + while true do + local which, msg, err = perform(op.named_choice({ + cap = cap_sub:recv_op(), + cfg = cfg_sub:recv_op(), + })) + + if not msg then + log.debug("GSM", "- subscription closed:", err) + return + end + + if which == 'cap' then + local id = msg.topic and msg.topic[3] + if msg.payload == 'added' then + ensure_modem(id) + elseif msg.payload == 'removed' then + remove_modem(id) + else + log.debug("GSM", id, "- unknown modem state:", msg.payload) + end + else + if not is_plain_table(msg.payload) then + log.debug("GSM", "- invalid config payload") + else + local updated_cfg, cfg_err = normalize_config(msg.payload) + if cfg_err ~= "" then + log.debug("GSM", "- invalid config:", cfg_err) + else + current_cfg = updated_cfg + for id, modem in pairs(modems) do + local modem_cfg, modem_name, _ = get_modem_config(current_cfg, id, modem.device) + modem:apply_config(modem_cfg, modem_name) + modem:stop('config change') + modem:start(parent_scope) + end + end + end + end + end +end + +return GsmService From 106affe6993a4bc84768d9128ba2fa7bb77bacb3 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:04:15 +0000 Subject: [PATCH 17/33] General error message --- src/services/hal/backends/fetch.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/hal/backends/fetch.lua b/src/services/hal/backends/fetch.lua index 8633904a..4cd9b0d3 100644 --- a/src/services/hal/backends/fetch.lua +++ b/src/services/hal/backends/fetch.lua @@ -14,7 +14,7 @@ local function get_cached_value(identity, key, cache, ret_type, timeout, fetch_f local err = fetch_fn(identity, cache) if err ~= "" then - return nil, "Failed to fetch modem info: " .. tostring(err) + return nil, "Failed to fetch info: " .. tostring(err) end local value = cache:get(key, timeout) From ec96b00d5432d0975d236ca45fa611a9254a43a0 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:04:42 +0000 Subject: [PATCH 18/33] Fixed return values, reading and writing method --- src/services/hal/backends/modem/at.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/services/hal/backends/modem/at.lua b/src/services/hal/backends/modem/at.lua index d4d5e740..df7ff60f 100644 --- a/src/services/hal/backends/modem/at.lua +++ b/src/services/hal/backends/modem/at.lua @@ -30,14 +30,14 @@ local at = {} ---@return string error function at.send(port, command) local st, _, err, result = fibers.run_scope(function() - local reader, err = file.open(port, "r") - if not reader then return "error opening AT read port: " .. err, nil end + local reader, rd_err = file.open(port, "r") + if not reader then return "error opening AT read port: " .. rd_err, nil end - local writer = assert(file.open(port, "w")) - if not writer then return "error opening AT write port: " .. err, nil end + local writer, wr_err = file.open(port, "w") + if not writer then return "error opening AT write port: " .. wr_err, nil end -- file write - writer:write_chars(command .. '\r') + writer:write(command .. '\r') writer:close() @@ -46,17 +46,17 @@ function at.send(port, command) while true do local line = reader:read_line() - if not line then return 'unknown error', nil end + if not line then return "unknown error", nil end line = trim(line) -- check for non-descriptive success/fail if line:find("^OK$") then reader:close() - return res, nil + return "", res elseif line:find("^ERROR$") then reader:close() - return res, 'error' + return 'error', res else -- check for descriptive fail local error_code From b2e737b7f4fe73b8539be0d70c3ccbc9e58dba07 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:06:06 +0000 Subject: [PATCH 19/33] added iccid to modem backend --- src/services/hal/backends/modem/contract.lua | 1 + src/services/hal/backends/modem/provider.lua | 6 -- .../modem/providers/linux_mm/getters.lua | 11 ++- .../modem/providers/linux_mm/impl.lua | 80 ++++++++++++++++++- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/services/hal/backends/modem/contract.lua b/src/services/hal/backends/modem/contract.lua index c1efbbab..202afc80 100644 --- a/src/services/hal/backends/modem/contract.lua +++ b/src/services/hal/backends/modem/contract.lua @@ -30,6 +30,7 @@ local BACKEND_FUNCTIONS = list_to_map { "gid1", "active_band_class", "firmware", + "iccid", -- State monitoring "start_state_monitor", diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua index 9cd19020..6f4893ce 100644 --- a/src/services/hal/backends/modem/provider.lua +++ b/src/services/hal/backends/modem/provider.lua @@ -36,11 +36,9 @@ end local function get_backend_impl() local backend_impl = nil for _, backend_name in ipairs(BACKENDS) do - print("Checking backend: services.hal.backends.modem.providers." .. backend_name .. ".init") local ok, backend_mod = pcall(require, "services.hal.backends.modem.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 - print("Selected backend:", backend_name) break end end @@ -62,14 +60,12 @@ local function new(address) end local drivers_str = table.concat(drivers, ",") - print(drivers_str) local mode if drivers_str:match("qmi_wwan") then mode = "qmi" elseif drivers_str:match("cdc_mbim") then mode = "mbim" end - print(mode) if mode then local ok, driver_mod = pcall(require, "services.hal.backends.modem.modes." .. mode) @@ -93,8 +89,6 @@ local function new(address) error("Failed to get modem revision: " .. tostring(rev_err)) end - print("Plugin:", plugin, "Model:", model, "Revision:", revision) - local model_funcs_loaded = false for manufacturer, models in pairs(MODEL_INFO) do if string.match(plugin:lower(), manufacturer) then diff --git a/src/services/hal/backends/modem/providers/linux_mm/getters.lua b/src/services/hal/backends/modem/providers/linux_mm/getters.lua index 5c0d17fd..11f6374d 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/getters.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -6,9 +6,10 @@ local fetch = require "services.hal.backends.fetch" --- Adds all getter methods to the ModemBackend class ---@param ModemBackend table The ModemBackend class table ---@param fetch_modem_info function The fetch function for modem info +---@param fetch_sim_info function The fetch function for SIM info ---@param fetch_signal_info function The fetch function for signal info ---@param read_net_stat function The function to read network statistics -local function add_getters(ModemBackend, fetch_modem_info, fetch_signal_info, read_net_stat) +local function add_getters(ModemBackend, fetch_modem_info, fetch_sim_info, fetch_signal_info, read_net_stat) --- Gets the modem's IMEI number ---@param timeout number? Cache timeout in seconds (optional) ---@return string imei @@ -143,6 +144,14 @@ local function add_getters(ModemBackend, fetch_modem_info, fetch_signal_info, re return fetch.get_cached_value(self.identity, "signal", self.cache, "table", timeout, fetch_signal_info) end + --- Gets the modem's ICCID + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string iccid + ---@return string error + function ModemBackend:iccid(timeout) + return fetch.get_cached_value(self.identity, "iccid", self.cache, "string", timeout, fetch_sim_info) + end + end return { 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 fc6f6128..48180d43 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/impl.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -38,6 +38,10 @@ local MODEM_INFO_PATHS = { operator = { "3gpp", "operator-name" }, } +local SIM_INFO_PATHS = { + iccid = { "properties", "iccid" } +} + local SIGNAL_TECHNOLOGIES = list_to_map { "5g", "cdma1x", @@ -82,6 +86,17 @@ local function nested_to_flat(nested, key_paths) return flat, errors end +--- Shallow copy of a table +---@param tbl table +---@return table copy +local function shallow_copy(tbl) + local copy = {} + for k, v in pairs(tbl) do + copy[k] = v + end + return copy +end + --- Formats each port from a string list of "name (type)" to a map of type_ports:name[] --- Returns a table of cache keys to be stored separately ---@param ports string[] @@ -110,6 +125,23 @@ local FIELD_POST_PROCESSORS = { ports = format_ports, -- Expands to at_ports, qmi_ports, etc. } +--- Dumps a nested table in a well-formatted form +---@param tbl table +---@param indent number +local function dump(tbl, indent) + indent = indent or 0 + local prefix = string.rep(" ", indent) + for k, v in pairs(tbl) do + if type(v) == "table" then + print(prefix .. tostring(k) .. " = {") + dump(v, indent + 1) + print(prefix .. "}") + else + print(prefix .. tostring(k) .. " = " .. tostring(v)) + end + end +end + --- Fetches modem info using mmcli and caches it ---@param identity ModemIdentity @@ -197,7 +229,7 @@ local function get_active_signal(signal_techs) local active_signal = false local filtered_fields = {} for signal_name, signal_value in pairs(signals) do - if not SIGNAL_IGNORE_FIELDS[signal_name] then + if not SIGNAL_IGNORE_FIELDS[signal_name] and signal_value ~= "--" then filtered_fields[signal_name] = signal_value active_signal = true end @@ -241,7 +273,46 @@ local function fetch_signal_info(identity, cache) if active_err ~= "" then error("Failed to get active signal: " .. tostring(active_err)) end - cache:set("signal", active_signal) + cache:set("signal", shallow_copy(active_signal)) + end) + return st ~= "ok" and err or "" +end + +--- Fetches SIM info using mmcli and caches it +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_sim_info(identity, cache) + local st, _, err = fibers.run_scope(function() + local sim = cache:get("sim") + if not sim then + fetch_modem_info(identity, cache) -- SIM info is needed to fetch SIM details, so fetch modem info if SIM path is not cached + sim = cache:get("sim") + if not sim then + error("Failed to get SIM path for fetching SIM info") + end + end + local cmd = exec.command { + "mmcli", "-J", "-i", sim, + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) + if status ~= "exited" or code ~= 0 then + error("mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output)) + end + local data, json_err = json.decode(output) + if not data then + error("Failed to decode mmcli output as JSON: " .. tostring(json_err) .. " , output: " .. tostring(output)) + end + local flat, errors = nested_to_flat(data.sim, SIM_INFO_PATHS) + if #errors > 0 then + log.warn("Errors formatting SIM info: " .. table.concat(errors, ";\n\t")) + end + for k, v in pairs(flat) do + cache:set(k, v) + end end) return st ~= "ok" and err or "" end @@ -281,12 +352,12 @@ local function get_identity(address, cache) mbim_port = mbim_ports[1] end - local mode_port = mbim_port or qmi_port -- Prefer mbim port if available, otherwise use qmi port + local mode_port = "/dev/" .. (mbim_port or qmi_port) -- Prefer mbim port if available, otherwise use qmi port local at_ports = cache:get("at_ports") local at_port if at_ports and type(at_ports) == "table" then - at_port = at_ports[1] + at_port = "/dev/" .. at_ports[1] end local net_ports = cache:get("net_ports") @@ -353,6 +424,7 @@ ModemBackend.__index = ModemBackend -- Add all getter methods to ModemBackend getters.add_getters(ModemBackend, fetch_modem_info, + fetch_sim_info, fetch_signal_info, read_net_stat ) From d8f1e1b22fca5b7a8932b5879f05342789907c0c Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:06:22 +0000 Subject: [PATCH 20/33] Fixed firmware parsing --- src/services/hal/backends/modem/models/quectel.lua | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/services/hal/backends/modem/models/quectel.lua b/src/services/hal/backends/modem/models/quectel.lua index 28e6764e..85cbba92 100644 --- a/src/services/hal/backends/modem/models/quectel.lua +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -59,13 +59,11 @@ local funcs = { if err ~= "" then return nil, "Failed to get firmware version: " .. err end - if lines then - for _, line in ipairs(lines) do - local fw = line:match("Revision:%s*(%S+)") - if fw then - backend.cache:set("firmware", fw) - return fw, "" - end + for _, line in ipairs(lines or {}) do + local firmware_version = string.match(line, "([%w]+_[%w]+%.[%w]+%.[%w]+%.[%w]+)") + if firmware_version then + backend.cache:set("firmware", firmware_version) + return firmware_version, "" end end return nil, "Firmware version not found in AT response" From 9d1494a4fc5e4872b02d989c6012bc1cb6098171 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:06:35 +0000 Subject: [PATCH 21/33] Fixed GID parsing --- src/services/hal/backends/modem/modes/qmi.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua index c9f9b20a..382c2778 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -70,7 +70,7 @@ local function fetch_gid1(identity, cache) end -- Parse the hex string after "Read result:" - local gid1 = out:match("Read result:%s*([%x:]+)$") + local gid1 = out:match("%s+(%S+)%s*$"):gsub(":", "") if not gid1 then error("Failed to parse qmicli output: " .. tostring(out)) end From 6400da0475566a2b6c13cf17d5d7461628a036c3 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:07:37 +0000 Subject: [PATCH 22/33] Modem manager now gets device value via get method --- src/services/hal/managers/modemcard.lua | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua index 83647eda..cec0012e 100644 --- a/src/services/hal/managers/modemcard.lua +++ b/src/services/hal/managers/modemcard.lua @@ -108,7 +108,12 @@ local function on_remove(dev_ev_ch, address) -- 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 device = driver:get(external_types.new.ModemGetOpts("device", math.huge)) + local get_ok, primary = driver:get(external_types.new.ModemGetOpts("device", math.huge)) + if not get_ok then + log.error(("Modemcard Manager: failed to get device: %s"):format(primary)) + return + end + local device = primary fibers.current_scope():spawn(function() local ok, stop_err = driver:stop(STOP_TIMEOUT) @@ -168,7 +173,12 @@ end 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 device = driver:get(external_types.new.ModemGetOpts("device", math.huge)) + local get_ok, primary = driver:get(external_types.new.ModemGetOpts("device", math.huge)) + if not get_ok then + log.error(("Modemcard Manager: failed to get device: %s"):format(primary)) + return + end + local device = primary ModemcardManager.modems[driver.address] = driver @@ -311,4 +321,13 @@ function ModemcardManager.stop(timeout) return true, "" end +---Apply configuration for modemcard manager (no-op, kept for interface consistency). +---@param namespaces table +---@return boolean ok +---@return string error +function ModemcardManager.apply_config(namespaces) -- luacheck: ignore + -- No-op: modemcard manager does not support dynamic configuration + return true, "" +end + return ModemcardManager From 4dc5b52df6cdedc7adca15b1ea1d43364cc741fd Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Feb 2026 17:09:36 +0000 Subject: [PATCH 23/33] Added more get methods, changed capability return format, fixed emitter --- src/services/hal/drivers/modem.lua | 200 ++++++++++++++++------------- 1 file changed, 112 insertions(+), 88 deletions(-) diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index 9b95a632..51a8df42 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -52,10 +52,9 @@ local GET_METHODS = list_to_table { "imei", "device", "primary_port", - "ports", "at_ports", "qmi_ports", - "gps_ports", + -- "gps_ports", -- maybe needed for the future "net_ports", "access_techs", "sim", @@ -63,7 +62,16 @@ local GET_METHODS = list_to_table { "plugin", "model", "revision", - "operator" + "operator", + "rx_bytes", + "tx_bytes", + "signal", + "mcc", + "mnc", + "gid1", + "active_band_class", + "firmware", + "iccid" } @@ -113,14 +121,17 @@ local function emit_kv(emit_ch, imei, kv_data) return all_ok, first_err end ---- Utility function to throw a ControlError +--- Utility function to return a ControlError ---@param err string? ---@param code integer? -local function throw_error(err, code) +---@return boolean ok +---@return string reason +---@return integer? code +local function return_error(err, code) if err == nil then err = "unknown error" end - error(cap_types.new.ControlError(err, code), 2) + return false, err, code end --- Emit an event. @@ -150,30 +161,6 @@ function Modem:_emit_meta(key, data) return emit(self.cap_emit_ch, self.imei, 'meta', key, data) end ---- Emit a set of key-value events. ----@param kv_data table ----@return boolean ok ----@return string? error -function Modem:_emit_kv(kv_data) - return emit_kv(self.cap_emit_ch, self.imei, kv_data) -end - ---- Emit a set of key-value states. ----@param kv_data table ----@return boolean ok ----@return string? error -function Modem:_emit_kv_state(kv_data) - return emit_kv(self.cap_emit_ch, self.imei, kv_data) -end - ---- Emit a set of key-value meta information. ----@param kv_data table ----@return boolean ok ----@return string? error -function Modem:_emit_kv_meta(kv_data) - return emit_kv(self.cap_emit_ch, self.imei, kv_data) -end - --- Validate that a function is implemented ---@param fn any ---@return boolean is_valid @@ -188,82 +175,116 @@ local function validate_fn(fn) 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 +end + ---- Modem Capabilities ---- --- Get a modem attribute. ---@param opts ModemGetOpts? ----@return any value +---@return boolean ok +---@return any reason_or_value +---@return integer? code function Modem:get(opts) if opts == nil or getmetatable(opts) ~= external_types.ModemGetOpts then - throw_error("invalid options") - return + return return_error("invalid options", 1) end local field = opts.field local timescale = opts.timescale or math.huge -- Check that the field is supported if not GET_METHODS[field] then - throw_error("unsupported field: " .. tostring(field)) + return return_error("unsupported field: " .. tostring(field), 1) end -- Call the corresponding backend function to get the value local get_fn = self.backend[field] if not get_fn then - throw_error("field " .. tostring(field) .. " is not implemented by backend") + return return_error("field " .. tostring(field) .. " is not implemented by backend", 1) end local value, err = get_fn(self.backend, timescale) if err ~= "" then - throw_error("error getting field " .. tostring(field) .. ": " .. tostring(err)) + return return_error("error getting field " .. tostring(field) .. ": " .. tostring(err), 1) end - return value + return true, value end --- Enable the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:enable() local ok, err = self.backend:enable() if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Disable the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:disable() local ok, err = self.backend:disable() if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Reset the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:reset() local ok, err = self.backend:reset() if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Connect the modem ---@param opts ModemConnectOpts? +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:connect(opts) if opts == nil or getmetatable(opts) ~= external_types.ModemConnectOpts then - throw_error("invalid options") - return + return return_error("invalid options", 1) end local ok, err = self.backend:connect(opts.connection_string) if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Disconnect the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:disconnect() local ok, err = self.backend:disconnect() if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Inhibit the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:inhibit() local done_ch = channel.new() @@ -273,7 +294,7 @@ function Modem:inhibit() end) if not ok then - throw_error("failed to spawn inhibit fiber: " .. tostring(err)) + return return_error("failed to spawn inhibit fiber: " .. tostring(err), 1) end local source, msg, primary = fibers.perform(op.named_choice { @@ -283,27 +304,34 @@ function Modem:inhibit() if source == "done" then if not msg.ok then - throw_error(msg.err) + return return_error(msg.err, 1) end - return + return true elseif source == "failed" then - throw_error("modem inhibit failed: " .. tostring(primary)) + return return_error("modem inhibit failed: " .. tostring(primary), 1) end - throw_error("unexpected error during modem inhibit") + return return_error("unexpected error during modem inhibit", 1) end --- Uninhibit the modem +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:uninhibit() local ok, err = self.backend:uninhibit() if not ok then - throw_error(err) + return return_error(err, 1) end + return true end --- Start listening for a sim insertion +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:listen_for_sim() if self.listening_for_sim then - throw_error("already listening for SIM") + return return_error("already listening for SIM", 1) end self.listening_for_sim = true local ok, err = fibers.current_scope():spawn(function() @@ -342,39 +370,30 @@ function Modem:listen_for_sim() end) end) if not ok then - throw_error("listen_for_sim spawn failed: " .. tostring(err)) + return return_error("listen_for_sim spawn failed: " .. tostring(err), 1) end + return true end --- Set the signal update period ---@param opts ModemSignalUpdateOpts +---@return boolean ok +---@return string? reason +---@return integer? code function Modem:set_signal_update_freq(opts) if opts == nil or getmetatable(opts) ~= external_types.ModemSignalUpdateOpts then - throw_error("invalid options") - return + return return_error("invalid options", 1) end local ok, err = self.backend:set_signal_update_interval(opts.frequency) if not ok then - throw_error(err) + return return_error(err, 1) end + return true end ----- Long Running Fibers ---- - ---- Get reason and code from a ControlError ----@param control_err any ----@param verb string -local function get_reason_and_code(control_err, verb) - if getmetatable(control_err) == cap_types.ControlError then - ---@cast control_err ControlError - return control_err.reason, control_err.code - end - - local reason = "function " .. tostring(verb) .. " failed with error: " .. tostring(control_err) - return reason, 1 -end function Modem:emitter() + local timeout_buffer = 0.1 log.trace("Modem Driver", self.imei, "emitter: started") fibers.current_scope():finally(function() @@ -383,23 +402,28 @@ function Modem:emitter() while true do self.state_pulse:next() -- wait for a pulse indicating state change + sleep.sleep(timeout_buffer) -- we want to put some buffer time in to invalidate any cache for method, _ in pairs(GET_METHODS) do - local st, _, primary = fibers.run_scope(function() self:get(external_types.new.ModemGetOpts(method, 0)) end) - - if st ~= 'ok' then - local err_msg = primary - if type(err_msg) == "table" and err_msg.reason then - err_msg = err_msg.reason - end + local opts, opts_err = external_types.new.ModemGetOpts(method, timeout_buffer) + if not opts then log.warn("Modem Driver", self.imei, - "emitter: error getting field " .. tostring(method) .. ": " .. tostring(err_msg)) + "emitter: failed to build get opts for field " .. tostring(method) .. ": " + .. tostring(opts_err)) else - local ok, emit_err = self:_emit_meta(method, primary) + local ok, value_or_err = self:get(opts) if not ok then + local trimmed_err = trim_error(value_or_err) log.warn("Modem Driver", self.imei, - "emitter: failed to emit meta for field " .. tostring(method) .. ": " - .. tostring(emit_err)) + "emitter: error getting field " .. tostring(method) .. ": " + .. tostring(trimmed_err)) + else + local emit_ok, emit_err = self:_emit_meta(method, value_or_err) + if not emit_ok then + log.warn("Modem Driver", self.imei, + "emitter: failed to emit meta for field " .. tostring(method) .. ": " + .. tostring(emit_err)) + end end end end @@ -464,14 +488,15 @@ function Modem:control_manager() ok = false reason = "no function exists for verb: " .. tostring(validation_err) else - local status, _, primary_or_val = fibers.run_scope(fn, self, request.opts) - -- reason field holds the error in case of failure, or the return value in case of success - if status == 'ok' then - ok = true - reason = primary_or_val - else - reason, code = get_reason_and_code(primary_or_val, request.verb) + 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 @@ -539,7 +564,6 @@ function Modem:capabilities(emit_ch) self.cap_emit_ch = emit_ch local modem_cap, mod_cap_err = cap_types.new.ModemCapability( - 'modem', self.imei, self.control_ch ) From 52f1520fc949ca063be7dbcba031b7919ce7a4ef Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:20:34 +0000 Subject: [PATCH 24/33] GSM state machine --- src/services/gsm.lua | 406 ++++++++++++++++++++++++++----------------- 1 file changed, 251 insertions(+), 155 deletions(-) diff --git a/src/services/gsm.lua b/src/services/gsm.lua index bc15b8ad..de05ce8e 100644 --- a/src/services/gsm.lua +++ b/src/services/gsm.lua @@ -14,9 +14,10 @@ local perform = fibers.perform local log = require "services.log" local external_types = require "services.hal.types.external" +local apns = require "services.gsm.apn" local REQUEST_TIMEOUT = 10 -local DEFAULT_RETRY_TIMEOUT = 5 +local DEFAULT_RETRY_TIMEOUT = 20 local DEFAULT_METRICS_INTERVAL = 10 local DEFAULT_SIGNAL_FREQ = 5 @@ -31,13 +32,13 @@ local SCOREMAP = { local ACCESS_TECH_MAP = { { tokens = { 'lte', '5gnr' }, tech = '5g' }, - { tokens = { '5gnr' }, tech = '5g' }, - { tokens = { '5g' }, tech = '5g' }, - { tokens = { 'lte' }, tech = 'lte' }, - { tokens = { 'umts' }, tech = 'umts' }, - { tokens = { 'gsm' }, tech = 'gsm' }, - { tokens = { 'evdo' }, tech = 'evdo' }, - { tokens = { 'cdma1x' }, tech = 'cdma1x' }, + { tokens = { '5gnr' }, tech = '5g' }, + { tokens = { '5g' }, tech = '5g' }, + { tokens = { 'lte' }, tech = 'lte' }, + { tokens = { 'umts' }, tech = 'umts' }, + { tokens = { 'gsm' }, tech = 'gsm' }, + { tokens = { 'evdo' }, tech = 'evdo' }, + { tokens = { 'cdma1x' }, tech = 'cdma1x' }, } ---@return table @@ -58,6 +59,12 @@ local function t_cap_state(id) return { 'cap', 'modem', id, 'state' } end +---@param id string|number +---@return table +local function t_cap_card_state(id) + return { 'cap', 'modem', id, 'state', 'card' } +end + ---@param id string|number ---@param method string ---@return table @@ -103,13 +110,10 @@ local function call_modem_rpc(conn, id, method, payload, timeout) timeout = timeout or REQUEST_TIMEOUT, }) if not reply then - return false, err or "rpc failed" + return nil, err or "rpc failed" end if reply.ok ~= true then - return false, reply.reason or 'rpc failed' - end - if reply.reason == nil then - return false, "empty reply" + return nil, reply.reason or 'rpc failed' end return reply.reason, "" end @@ -118,12 +122,13 @@ end ---@param id string|number ---@param field string ---@param timeout number? +---@param timescale number? ---@return any ---@return string -local function modem_get_field(conn, id, field, timeout) - local opts, opts_err = external_types.new.ModemGetOpts(field) +local function modem_get_field(conn, id, field, timeout, timescale) + local opts, opts_err = external_types.new.ModemGetOpts(field, timescale) if not opts then - return false, opts_err or "invalid modem get opts" + return nil, opts_err or "invalid modem get opts" end return call_modem_rpc(conn, id, 'get', opts, timeout) end @@ -254,18 +259,14 @@ local function normalize_sim_presence(sim_value) end ---@param access_techs any ----@param access_err string ---@param rssi any ----@param rssi_err string ---@param rsrp any ----@param rsrp_err string ---@param rscp any ----@param rscp_err string ---@return string ---@return number ---@return string -local function select_signal_for_bars(access_techs, access_err, rssi, rssi_err, rsrp, rsrp_err, rscp, rscp_err) - if access_err ~= "" then +local function select_signal_for_bars(access_techs, rssi, rsrp, rscp) + if not access_techs then return "", 0, "access tech unavailable" end @@ -274,21 +275,21 @@ local function select_signal_for_bars(access_techs, access_err, rssi, rssi_err, return "", 0, "access tech unknown" end - if access_tech == 'umts' and rscp_err == "" then + if access_tech == 'umts' and rscp ~= nil then local rscp_value = tonumber(rscp) if rscp_value then return access_tech, rscp_value, "rscp" end end - if (access_tech == 'lte' or access_tech == '5g') and rsrp_err == "" then + if (access_tech == 'lte' or access_tech == '5g') and rsrp ~= nil then local rsrp_value = tonumber(rsrp) if rsrp_value then return access_tech, rsrp_value, "rsrp" end end - if rssi_err == "" then + if rssi ~= nil then local rssi_value = tonumber(rssi) if rssi_value then return access_tech, rssi_value, "rssi" @@ -335,7 +336,7 @@ local function get_modem_config(cfg, imei, device) if is_plain_table(entry) then local id_field = entry.id_field or 'imei' if (id_field == 'device' and device ~= "" and entry.device == device) - or (id_field ~= 'device' and ((entry.imei == imei) or (entry.id == imei))) + or (id_field ~= 'device' and (entry.imei == imei)) then local merged = shallow_copy(entry) apply_defaults(merged, base) @@ -348,6 +349,25 @@ local function get_modem_config(cfg, imei, device) return base, "", "" end +--- Waits for a modem state to move out of connecting +---@param name string +---@param state_sub Subscription +---@return boolean ok +---@return string error +local function wait_for_connection(name, state_sub) + while true do + local msg, err = state_sub:recv() + if err then + return false, "state subscription interrupted" + end + local state = msg.payload and msg.payload.to + log.trace(("GSM %s connection progress - modem state change: %s"):format(tostring(name), tostring(state))) + if state and state ~= 'connecting' then + return true, "" + end + end +end + ---@class GsmModem ---@field conn Connection ---@field id string|number @@ -394,6 +414,7 @@ end ---@param value any ---@return nil function GsmModem:_emit_metric(key, value) + log.info("GSM", self.name, "- emitting metric", key, "=", tostring(value)) if value == nil then return end @@ -414,10 +435,7 @@ end function GsmModem:_emit_metrics_once() -- Derived metrics only; HAL remains the source of truth. local access_techs, access_err = modem_get_field(self.conn, self.id, 'access_techs', REQUEST_TIMEOUT) - if access_err ~= "" then - log.debug("GSM", self.id, "- access_techs:", access_err) - else - self:_emit_metric('access_techs', access_techs) + if access_err == "" then local access_tech = derive_access_tech(access_techs) if access_tech ~= "" then self:_emit_metric('access_tech', access_tech) @@ -428,118 +446,88 @@ function GsmModem:_emit_metrics_once() end end - local band, band_err = modem_get_field(self.conn, self.id, 'band', REQUEST_TIMEOUT) - if band_err ~= "" then - log.debug("GSM", self.id, "- band:", band_err) - else + local band, band_err = modem_get_field(self.conn, self.id, 'active_band_class', REQUEST_TIMEOUT) + if band_err == "" then self:_emit_metric('band', band) end local imei, imei_err = modem_get_field(self.conn, self.id, 'imei', REQUEST_TIMEOUT) - if imei_err ~= "" then - log.debug("GSM", self.id, "- imei:", imei_err) - else + if imei_err == "" then self:_emit_metric('imei', imei) end local operator, operator_err = modem_get_field(self.conn, self.id, 'operator', REQUEST_TIMEOUT) - if operator_err ~= "" then - log.debug("GSM", self.id, "- operator:", operator_err) - else + if operator_err == "" then self:_emit_metric('operator', operator) end local sim, sim_err = modem_get_field(self.conn, self.id, 'sim', REQUEST_TIMEOUT) - if sim_err ~= "" then - log.debug("GSM", self.id, "- sim:", sim_err) - else + if sim_err == "" then self:_emit_metric('sim', normalize_sim_presence(sim)) end local iccid, iccid_err = modem_get_field(self.conn, self.id, 'iccid', REQUEST_TIMEOUT) - if iccid_err ~= "" then - log.debug("GSM", self.id, "- iccid:", iccid_err) - else + if iccid_err == "" then self:_emit_metric('iccid', iccid) end local firmware, firmware_err = modem_get_field(self.conn, self.id, 'firmware', REQUEST_TIMEOUT) - if firmware_err ~= "" then - log.debug("GSM", self.id, "- firmware:", firmware_err) - else + if firmware_err == "" then self:_emit_metric('firmware', firmware) end - local state, state_err = modem_get_field(self.conn, self.id, 'state', REQUEST_TIMEOUT) - if state_err ~= "" then - log.debug("GSM", self.id, "- state:", state_err) - else + local state_sub = self.conn:subscribe(t_cap_card_state(self.id)) + local state_msg, msg_err = state_sub:recv() + if msg_err then + log.debug("GSM", self.name, "- state: ", msg_err) + end + local state = state_msg.payload and state_msg.payload.to + if state then self:_emit_metric('state', state) end local net_ports, net_ports_err = modem_get_field(self.conn, self.id, 'net_ports', REQUEST_TIMEOUT) - if net_ports_err ~= "" then - log.debug("GSM", self.id, "- net_ports:", net_ports_err) - else + if net_ports_err == "" then local interface = net_ports and net_ports[1] if interface then self:_emit_metric('interface', interface) else - log.debug("GSM", self.id, "- no net_ports available") + log.debug("GSM", self.name, "- no net_ports available") end end local rx_bytes, rx_err = modem_get_field(self.conn, self.id, 'rx_bytes', REQUEST_TIMEOUT) - if rx_err ~= "" then - log.debug("GSM", self.id, "- rx_bytes:", rx_err) - else + if rx_err == "" then self:_emit_metric('rx_bytes', rx_bytes) end local tx_bytes, tx_err = modem_get_field(self.conn, self.id, 'tx_bytes', REQUEST_TIMEOUT) - if tx_err ~= "" then - log.debug("GSM", self.id, "- tx_bytes:", tx_err) - else + if tx_err == "" then self:_emit_metric('tx_bytes', tx_bytes) end - local rssi, rssi_err = modem_get_field(self.conn, self.id, 'rssi', REQUEST_TIMEOUT) - if rssi_err ~= "" then - log.debug("GSM", self.id, "- rssi:", rssi_err) - else - self:_emit_metric('signal_rssi', rssi) - end - - local rsrp, rsrp_err = modem_get_field(self.conn, self.id, 'rsrp', REQUEST_TIMEOUT) - if rsrp_err ~= "" then - log.debug("GSM", self.id, "- rsrp:", rsrp_err) - else - self:_emit_metric('signal_rsrp', rsrp) + local signal, signal_err = modem_get_field(self.conn, self.id, 'signal', REQUEST_TIMEOUT) + local rssi, rsrp, rsrq, rscp = nil, nil, nil, nil + if signal_err == "" then + rssi = signal.rssi + rsrp = signal.rsrp + rsrq = signal.rsrq + rscp = signal.rscp end - local rsrq, rsrq_err = modem_get_field(self.conn, self.id, 'rsrq', REQUEST_TIMEOUT) - if rsrq_err ~= "" then - log.debug("GSM", self.id, "- rsrq:", rsrq_err) - else - self:_emit_metric('signal_rsrq', rsrq) + if rsrp then + self:_emit_metric('rsrp', rsrp) end - local rscp, rscp_err = modem_get_field(self.conn, self.id, 'rscp', REQUEST_TIMEOUT) - if rscp_err ~= "" then - log.debug("GSM", self.id, "- rscp:", rscp_err) - else - self:_emit_metric('signal_rscp', rscp) + if rsrq then + self:_emit_metric('rsrq', rsrq) end local bars_access_tech, signal_value, signal_type = select_signal_for_bars( access_techs, - access_err, rssi, - rssi_err, rsrp, - rsrp_err, - rscp, - rscp_err + rscp ) if bars_access_tech ~= "" and signal_type ~= "" then @@ -573,68 +561,161 @@ function GsmModem:_metrics_loop() end end ----@return boolean ----@return string -function GsmModem:_connect_once() - -- TODO: Add APN selection logic; currently uses a precomputed connection string. - if not self.cfg.connection_string then - return false, "missing connection_string" +---@return table|nil apn +---@return string error +---@return number? retry_timeout +function GsmModem:_apn_connect() + -- Fetch network/SIM identifiers from HAL + local mcc, mcc_err = modem_get_field(self.conn, self.id, 'mcc', REQUEST_TIMEOUT) + if mcc_err ~= "" then + return nil, "mcc: " .. mcc_err, DEFAULT_RETRY_TIMEOUT end - local opts, opts_err = external_types.new.ModemConnectOpts(self.cfg.connection_string) - if not opts then - return false, opts_err or "invalid connection string" + local mnc, mnc_err = modem_get_field(self.conn, self.id, 'mnc', REQUEST_TIMEOUT) + if mnc_err ~= "" then + return nil, "mnc: " .. mnc_err, DEFAULT_RETRY_TIMEOUT end - local _, err = call_modem_rpc(self.conn, self.id, 'connect', opts, REQUEST_TIMEOUT) - if err ~= "" then - return false, err + local imsi, imsi_err = modem_get_field(self.conn, self.id, 'imsi', REQUEST_TIMEOUT) + if imsi_err ~= "" then + return nil, "imsi: " .. imsi_err, DEFAULT_RETRY_TIMEOUT end - return true, "" + local gid1, gid1_err = modem_get_field(self.conn, self.id, 'gid1', REQUEST_TIMEOUT) + if gid1_err ~= "" then + return nil, "gid1: " .. gid1_err, DEFAULT_RETRY_TIMEOUT + end + + -- Get ranked APNs + local rank_cutoff = tonumber(self.cfg.apn_rank_cutoff) or 4 + local ranked_apns, rankings = apns.get_ranked_apns(mcc, mnc, imsi, nil, gid1) + + -- Iterate through ranked APNs + for _, ranking in ipairs(rankings) do + if ranking.rank > rank_cutoff then break end + + local apn_table = ranked_apns[ranking.name] + local conn_str, build_err = apns.build_connection_string(apn_table, self.cfg.roaming_allow) + + if not build_err and conn_str then + -- Build and send connect RPC + local opts, opts_err = external_types.new.ModemConnectOpts(conn_str) + if opts then + log.trace("GSM", self.name, "- attempting to connect APN", ranking.name, "with connection string:", + conn_str) + + local _, conn_err = call_modem_rpc(self.conn, self.id, 'connect', opts, REQUEST_TIMEOUT) + + log.debug("GSM", self.name, "- connect RPC for APN", ranking.name, "returned:", conn_err) + if conn_err == "" then + -- Connect succeeded + log.trace("GSM", self.name, "- APN", ranking.name, "connected successfully") + return apn_table, "", nil + end + + -- Check for throttled error + if string.find(conn_err, "pdn-ipv4-call-throttled") then + log.debug("GSM", self.name, "- APN connection throttled") + return nil, conn_err, 360 -- 6-minute backoff + end + + -- Connection attempt failed, wait for modem state to stabilize before trying next APN + log.debug("GSM", self.name, "- APN", ranking.name, "connect failed:", conn_err, + "waiting for state change") + + -- Subscribe to state changes to monitor connection progress + local state_sub = self.conn:subscribe(t_cap_card_state(self.id), { + queue_len = 1, + full = 'drop_oldest', + }) + local ok, wait_err = wait_for_connection(self.name, state_sub) + state_sub:unsubscribe() + if not ok then + log.debug("GSM", self.name, "- error while waiting for state change, failure:", wait_err) + return nil, wait_err, DEFAULT_RETRY_TIMEOUT + end + + log.trace("GSM", self.name, "- APN", ranking.name, "connection attempt failed") + else + log.debug("GSM", self.name, "- invalid connect opts for APN", ranking.name, ":", opts_err) + end + else + log.debug("GSM", self.name, "- failed to build connection string for APN", ranking.name, ":", + build_err or "nil") + end + end + + state_sub:unsubscribe() + return nil, "no apn connected", DEFAULT_RETRY_TIMEOUT end --- Autoconnect loop: reconnects on a simple backoff and reacts to config changes. +-- Autoconnect loop: listens to modem state changes and reacts with enable/fix/connect. +-- Retry logic with exponential backoff and state change preemption. ---@return nil function GsmModem:_autoconnect_loop() local seen = self.config_pulse:version() + local state_sub = self.conn:subscribe(t_cap_card_state(self.id), { + queue_len = 1, + full = 'drop_oldest', + }) + + local current_state = nil + local backoff = math.huge while true do - if not self.cfg.autoconnect then - local which, ver = perform(op.named_choice({ - idle = sleep.sleep_op(DEFAULT_RETRY_TIMEOUT), - config = self.config_pulse:changed_op(seen), - })) - - if which == 'config' then - if not ver then - return - end - seen = ver + local which, msg_or_ver = perform(op.named_choice({ + state = state_sub:recv_op(), + backoff = sleep.sleep_op(backoff), + config = self.config_pulse:changed_op(seen), + })) + + if which == 'config' then + if not msg_or_ver then + -- pulse closed, exit + break end - else - local ok, err = self:_connect_once() - if ok then - self:_emit_event('autoconnect', 'connected') - else - self:_emit_event('autoconnect', 'failed') - log.debug("GSM", self.id, "- autoconnect failed:", err) + seen = msg_or_ver + backoff = math.huge + elseif which == 'state' then + local msg = msg_or_ver + if msg then + current_state = msg.payload.to + log.trace("GSM", self.name, "- modem state changed:", current_state) end + elseif which == 'backoff' then + log.trace("GSM", self.name, "- retrying state:", current_state) + end - local retry = tonumber(self.cfg.retry_interval) or DEFAULT_RETRY_TIMEOUT - local which, ver = perform(op.named_choice({ - backoff = sleep.sleep_op(retry), - config = self.config_pulse:changed_op(seen), - })) - - if which == 'config' then - if not ver then - return + -- Act on current_state + if current_state and self.cfg.autoconnect then + local err, retry_timeout + + if current_state == 'disabled' then + local _, err_inner = call_modem_rpc(self.conn, self.id, 'enable', {}, REQUEST_TIMEOUT) + err = err_inner + elseif current_state == 'failed' then + local _, err_inner = call_modem_rpc(self.conn, self.id, 'listen_for_sim', {}, REQUEST_TIMEOUT) + err = err_inner + elseif current_state == 'registered' then + local _, err_inner, retry_inner = self:_apn_connect() + err = err_inner + retry_timeout = retry_inner + if err == "" then + self:_emit_event('autoconnect', 'connected') end - seen = ver + end + + if err and err ~= "" then + backoff = retry_timeout or DEFAULT_RETRY_TIMEOUT + log.error("GSM", self.name, "- state", current_state, "failed:", err, + "retrying after", backoff, "seconds") + else + backoff = math.huge end end end + + state_sub:unsubscribe() end ---@param parent_scope Scope @@ -656,29 +737,29 @@ function GsmModem:start(parent_scope) self.scope = child - child:finally(function () - log.trace("GSM", self.id, "- modem scope closed") + child:finally(function() + log.trace("GSM", self.name, "- modem scope closed") end) - local ok, spawn_err = child:spawn(function () + local ok, spawn_err = child:spawn(function() local signal_freq = tonumber(self.cfg.signal_freq) or DEFAULT_SIGNAL_FREQ local _, sig_err = modem_set_signal_freq(self.conn, self.id, signal_freq) if sig_err ~= "" then - log.debug("GSM", self.id, "- set_signal_update_freq:", sig_err) + log.debug("GSM", self.name, "- set_signal_update_freq:", sig_err) end end) if not ok then return false, spawn_err or "failed to spawn signal update" end - ok, spawn_err = child:spawn(function () + ok, spawn_err = child:spawn(function() self:_metrics_loop() end) if not ok then return false, spawn_err or "failed to spawn metrics loop" end - ok, spawn_err = child:spawn(function () + ok, spawn_err = child:spawn(function() self:_autoconnect_loop() end) if not ok then @@ -696,13 +777,13 @@ function GsmModem:stop(reason, close_pulse) return end - self.scope:cancel(reason or 'modem stopped') - perform(self.scope:join_op()) - self.scope = nil - if close_pulse then self.config_pulse:close(reason or 'modem stopped') end + + self.scope:cancel(reason or 'modem stopped') + perform(self.scope:join_op()) + self.scope = nil end ---@class GsmService @@ -720,11 +801,13 @@ function GsmService.start(conn, opts) local current_cfg = {} local config_ready = false + + ---@type table local modems = {} local parent_scope = fibers.current_scope() - parent_scope:finally(function (_, st, primary) + parent_scope:finally(function(_, st, primary) for _, modem in pairs(modems) do modem:stop(primary or st, true) end @@ -752,7 +835,7 @@ function GsmService.start(conn, opts) local ok, err = modem:start(parent_scope) if not ok then - log.debug("GSM", id, "- failed to start modem scope:", err) + log.error("GSM", id, "- failed to start modem scope:", err) end return modem @@ -767,10 +850,7 @@ function GsmService.start(conn, opts) modems[id] = nil end - local cfg_sub = conn:subscribe(t_cfg(name), { - queue_len = 1, - full = 'drop_oldest', - }) + local cfg_sub = conn:subscribe(t_cfg(name)) while not config_ready do local which, msg, err = perform(op.named_choice({ @@ -799,18 +879,28 @@ function GsmService.start(conn, opts) end end - local cap_sub = conn:subscribe(t_cap_state('+'), { - queue_len = 1, - full = 'drop_oldest', - }) + local cap_sub = conn:subscribe(t_cap_state('+')) publish_status(conn, name, 'running') while true do - local which, msg, err = perform(op.named_choice({ + local choices = { cap = cap_sub:recv_op(), cfg = cfg_sub:recv_op(), - })) + } + + local modem_fault_ops = {} + for id, modem in pairs(modems) do + table.insert(modem_fault_ops, modem.scope:fault_op():wrap(function(_, pr) + return { id = id, primary = pr } + end)) + end + + if #modem_fault_ops > 0 then + choices.modem_fault = op.choice(unpack(modem_fault_ops)) + end + + local which, msg, err = perform(op.named_choice(choices)) if not msg then log.debug("GSM", "- subscription closed:", err) @@ -826,7 +916,13 @@ function GsmService.start(conn, opts) else log.debug("GSM", id, "- unknown modem state:", msg.payload) end - else + elseif which == 'modem_fault' then + local modem = modems[msg.id] + if modem then + log.debug("GSM", msg.id, "- modem scope faulted: " .. tostring(msg.primary)) + modem:stop() + end + elseif which == 'cfg' then if not is_plain_table(msg.payload) then log.debug("GSM", "- invalid config payload") else From 083604cc68c50a07f1574bb50bbf761cfaf19416 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:21:08 +0000 Subject: [PATCH 25/33] apn db loading and conection string builder --- src/services/gsm/apn.lua | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/services/gsm/apn.lua diff --git a/src/services/gsm/apn.lua b/src/services/gsm/apn.lua new file mode 100644 index 00000000..d2036f52 --- /dev/null +++ b/src/services/gsm/apn.lua @@ -0,0 +1,73 @@ +local binser = require "shared.binser" + +-- ask rich what these fellas do +local g_authtypes = {} +g_authtypes["0"] = "none" +g_authtypes["1"] = "pap" +g_authtypes["2"] = "chap" +g_authtypes["3"] = "pap|chap" + +-- deserialise the apn database and return the apn for the sim +local function get_apns(mcc, mnc) + local apndb = binser.r("etc/apns")[1] + local apns = apndb[mcc][mnc] + return 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" then table.insert(a, "allowed-auth="..g_authtypes[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 + +-- the connect function takes a list of apns and applies +local function rank(apns, imsi, spn, gid1) + -- first comes MVNO matches, next general MNO APNs, then generic "apn=internet", finally non-match MVNO + local rankings = {} + for k, v in pairs(apns) do + -- print("k is: ", k) + if v.mvno_type then + -- print("v is: ", v.mvno_type) + if v.mvno_type == "spn" and spn and string.find(spn, v.mvno_match_data) then + table.insert(rankings, {name=k, rank=1}) + elseif v.mvno_type == "gid" and gid1 and string.find(gid1, v.mvno_match_data) then + table.insert(rankings, {name=k, rank=1}) + elseif v.mvno_type == "imsi" and string.find(imsi, v.mvno_match_data) then + table.insert(rankings, {name=k, rank=1}) + else + table.insert(rankings, {name=k, rank=4}) + end + else + table.insert(rankings, {name=k, rank=2}) + end + end + table.insert(apns, {default={apn='internet'}}) + table.insert(rankings, {name='default', rank=3}) + table.sort(rankings, function (k1, k2) return k1.rank < k2.rank end ) + return apns, rankings +end + +local function get_ranked_apns(mcc, mnc, imsi, spn, gid1) + if mnc == nil then return {}, {} end + if #mnc == 1 then mnc = "0"..mnc end + -- get apns from the network service + local apns = get_apns(mcc, mnc) + -- or read directly + -- local apns = binser.r("etc/apns")[1] + local rankapns, rankings = rank(apns, imsi, spn, gid1) + return rankapns, rankings +end + +return { + get_ranked_apns = get_ranked_apns, + build_connection_string = build_connection_string +} From 52b134c876bb7f5efb2169d4ed925dfa84783ad3 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:21:23 +0000 Subject: [PATCH 26/33] at commands as ops --- src/services/hal/backends/modem/at.lua | 118 +++++++++++++++++-------- 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/src/services/hal/backends/modem/at.lua b/src/services/hal/backends/modem/at.lua index df7ff60f..c640d588 100644 --- a/src/services/hal/backends/modem/at.lua +++ b/src/services/hal/backends/modem/at.lua @@ -1,12 +1,37 @@ package.path = '/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua;' .. package.path -local file = require 'fibers.io.file' -local fibers = require 'fibers' +local file = require 'fibers.io.file' +local scope = require 'fibers.scope' ---@alias ATCommand string ---@alias SerialPortPath string ---@alias ATResponseLine string +---@class ATTerminalPattern +---@field pattern string Lua pattern matched against trimmed lines +---@field is_error boolean When true the matched line is returned as an error string + +---@class ATSendOpts +---@field terminal_patterns ATTerminalPattern[]? + +-- Usage example: +-- +-- local op = require 'fibers.op' +-- local sleep = require 'fibers.sleep' +-- local fibers = require 'fibers' +-- +-- while true do +-- local src, lines, err = fibers.perform(op.named_choice({ +-- response = at.send_op(port, "AT+QGMR"), +-- timeout = sleep.sleep_op(5), +-- })) +-- if src == "response" then +-- -- lines: ATResponseLine[]?, err: string? +-- break +-- end +-- -- timeout arm: loop retries with a fresh op; port re-opened cleanly +-- 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 @@ -17,70 +42,85 @@ end ---@class AT local at = {} ----Send an AT command to a serial port and collect response lines until OK/ERROR. +---Return an Op that sends an AT command and collects response lines until a +---terminal line is hit. +--- +---The Op yields `(lines, err)`: +--- - On success: `lines` is a list of non-empty response lines, `err` is nil. +--- - On AT error: `lines` is the accumulated lines so far, `err` is a string +--- (`'error'`, `'unknown error'`, or a numeric code string from +CME/+CMS). +--- - On cancellation (e.g. the op loses a race against a timeout): `lines` is +--- nil, `err` is `'cancelled'`. The port is closed as part of losing the race. +--- +---`opts.terminal_patterns` is an optional list of additional terminal patterns. +---Each entry is `{ pattern = string, is_error = bool }`. When a trimmed line +---matches, that line is appended to `lines` and the op completes. If `is_error` +---is true, the matched line is also returned as `err`. --- ----Notes: ---- - Returns a list of non-empty response lines. ---- - On success, error is nil. ---- - On error, error may be a generic string ('error', 'unknown error') or a ---- numeric string parsed from +CME/+CMS. ---@param port SerialPortPath ---@param command ATCommand ----@return ATResponseLine[]? lines ----@return string error -function at.send(port, command) - local st, _, err, result = fibers.run_scope(function() +---@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 "error opening AT read port: " .. rd_err, nil end + 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 "error opening AT write port: " .. wr_err, nil end + if not writer then + return nil, "error opening AT write port: " .. wr_err + end - -- file write writer:write(command .. '\r') - writer:close() local res = {} - while true do - local line = reader:read_line() + local line, read_err = s:perform(reader:read_line_op()) - if not line then return "unknown error", nil end + if not line then + return nil, read_err or "unknown error" + end line = trim(line) - -- check for non-descriptive success/fail + -- Built-in terminals if line:find("^OK$") then - reader:close() - return "", res + return res, nil elseif line:find("^ERROR$") then - reader:close() - return 'error', res + return res, "error" else - -- check for descriptive fail - local error_code - error_code = line:match("^%+CME ERROR: (%d+)$") + local error_code = line:match("^%+CME ERROR: (%d+)$") + or line:match("^%+CMS ERROR: (%d+)$") if error_code then - reader:close() - return error_code, res + return res, error_code end - error_code = line:match("^%+CMS ERROR: (%d+)$") - if error_code then - reader:close() - return error_code, res + 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) - - if st == 'ok' then - return result, "" - else - return nil, err or "AT command failed" - end end return at From 70f9c0811649e70989dbfc97ab29259386d011f5 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:22:24 +0000 Subject: [PATCH 27/33] updated modem backend for complete field coverage and persistant cache --- src/services/hal/backends/modem/contract.lua | 1 + .../hal/backends/modem/models/quectel.lua | 27 +++++++++--- src/services/hal/backends/modem/modes/qmi.lua | 6 +-- .../modem/providers/linux_mm/getters.lua | 8 ++++ .../modem/providers/linux_mm/impl.lua | 44 +++++++------------ 5 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/services/hal/backends/modem/contract.lua b/src/services/hal/backends/modem/contract.lua index 202afc80..ef7aaa95 100644 --- a/src/services/hal/backends/modem/contract.lua +++ b/src/services/hal/backends/modem/contract.lua @@ -31,6 +31,7 @@ local BACKEND_FUNCTIONS = list_to_map { "active_band_class", "firmware", "iccid", + "imsi", -- State monitoring "start_state_monitor", diff --git a/src/services/hal/backends/modem/models/quectel.lua b/src/services/hal/backends/modem/models/quectel.lua index 85cbba92..7e675e5c 100644 --- a/src/services/hal/backends/modem/models/quectel.lua +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -1,8 +1,12 @@ local exec = require "fibers.io.exec" local fibers = require "fibers" +local sleep = require "fibers.sleep" +local op = require "fibers.op" local at = require "services.hal.backends.modem.at" +local DEFAULT_TIMEOUT = 5 + local funcs = { -- This is a special case for the RM520N, it has a initial bearer which will always cause a -- multiple PDN failure unless set to the APN we want to use OR set to empty. @@ -55,18 +59,31 @@ local funcs = { ---@cast backend ModemBackend local firmware = backend.cache:get("firmware") if firmware then return firmware, "" end - local lines, err = at.send(backend.identity.at_port, "AT+QGMR") - if err ~= "" then + local at_send_op = at.send_op(backend.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(DEFAULT_TIMEOUT) + })) + + if err then return nil, "Failed to get firmware version: " .. err end - for _, line in ipairs(lines or {}) do - local firmware_version = string.match(line, "([%w]+_[%w]+%.[%w]+%.[%w]+%.[%w]+)") + + if source == "timeout" then + return nil, "Timed out while getting firmware version" + elseif source == "at" then + local firmware_version = string.match(resp[#resp], "([%w]+_[%w]+%.[%w]+%.[%w]+%.[%w]+)") if firmware_version then backend.cache:set("firmware", firmware_version) return firmware_version, "" + else + return nil, "Firmware version not found in AT response" end end - return nil, "Firmware version not found in AT response" end } -- Other functions diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua index 382c2778..b0fed019 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -37,7 +37,7 @@ local function fetch_home_network_info(identity, cache) } 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)) + error("Failed to execute qmicli command: --nas-get-home-network, reason: " .. tostring(err)) end local mcc = out:match("MCC:%s+'(%d+)'") @@ -66,7 +66,7 @@ local function fetch_gid1(identity, cache) } 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)) + error("Failed to execute qmicli command: --uim-read-transparent, reason: " .. tostring(err)) end -- Parse the hex string after "Read result:" @@ -94,7 +94,7 @@ local function fetch_rf_band_info(identity, cache) } 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)) + 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*'([^']+)'") diff --git a/src/services/hal/backends/modem/providers/linux_mm/getters.lua b/src/services/hal/backends/modem/providers/linux_mm/getters.lua index 11f6374d..fe2c6de5 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/getters.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -152,6 +152,14 @@ local function add_getters(ModemBackend, fetch_modem_info, fetch_sim_info, fetch return fetch.get_cached_value(self.identity, "iccid", self.cache, "string", timeout, fetch_sim_info) end + --- Gets the modem's IMSI + ---@param timeout number? Cache timeout in seconds (optional) + ---@return string imsi + ---@return string error + function ModemBackend:imsi(timeout) + return fetch.get_cached_value(self.identity, "imsi", self.cache, "string", timeout, fetch_sim_info) + end + end return { 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 48180d43..a1e6dda1 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/impl.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -22,7 +22,7 @@ local function list_to_map(list) end ---- Constants ---- -local CACHE_TIMEOUT = 10 -- seconds +local CACHE_TIMEOUT = math.huge -- The default for cache is to hold a value indefinitely local MODEM_INFO_PATHS = { imei = { "generic", "equipment-identifier" }, @@ -39,7 +39,8 @@ local MODEM_INFO_PATHS = { } local SIM_INFO_PATHS = { - iccid = { "properties", "iccid" } + iccid = { "properties", "iccid" }, + imsi = { "properties", "imsi" }, } local SIGNAL_TECHNOLOGIES = list_to_map { @@ -125,23 +126,6 @@ local FIELD_POST_PROCESSORS = { ports = format_ports, -- Expands to at_ports, qmi_ports, etc. } ---- Dumps a nested table in a well-formatted form ----@param tbl table ----@param indent number -local function dump(tbl, indent) - indent = indent or 0 - local prefix = string.rep(" ", indent) - for k, v in pairs(tbl) do - if type(v) == "table" then - print(prefix .. tostring(k) .. " = {") - dump(v, indent + 1) - print(prefix .. "}") - else - print(prefix .. tostring(k) .. " = " .. tostring(v)) - end - end -end - --- Fetches modem info using mmcli and caches it ---@param identity ModemIdentity @@ -256,7 +240,7 @@ local function fetch_signal_info(identity, cache) } local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" or code ~= 0 then - error("mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output)) + error("mmcli command failed: --signal-get, reason: " .. tostring(err) .. ", output: " .. tostring(output)) end local data, json_err = json.decode(output) @@ -273,7 +257,7 @@ local function fetch_signal_info(identity, cache) if active_err ~= "" then error("Failed to get active signal: " .. tostring(active_err)) end - cache:set("signal", shallow_copy(active_signal)) + cache:set("signal", shallow_copy(active_signal)) -- Cache a copy of the active signal fields end) return st ~= "ok" and err or "" end @@ -292,6 +276,9 @@ local function fetch_sim_info(identity, cache) error("Failed to get SIM path for fetching SIM info") end end + if sim == "--" then + return "" -- no sim means we cannot get sim info + end local cmd = exec.command { "mmcli", "-J", "-i", sim, stdin = "null", @@ -300,7 +287,8 @@ local function fetch_sim_info(identity, cache) } local output, status, code, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" or code ~= 0 then - error("mmcli command failed: " .. tostring(err) .. ", output: " .. tostring(output)) + error("mmcli command failed: mmcli -J -i " .. + sim .. ", reason:" .. tostring(err) .. ", output: " .. tostring(output)) end local data, json_err = json.decode(output) if not data then @@ -442,7 +430,7 @@ function ModemBackend:enable() } local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - error("mmcli command failed to execute: " .. tostring(err)) + error("mmcli command failed to execute: enable, reason: " .. tostring(err)) end end) return st == "ok", err or "" @@ -461,7 +449,7 @@ function ModemBackend:disable() } local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - error("mmcli command failed to execute: " .. tostring(err)) + error("mmcli command failed to execute: disable, reason: " .. tostring(err)) end end) return st == "ok", err or "" @@ -480,7 +468,7 @@ function ModemBackend:reset() } local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - error("mmcli command failed to execute: " .. tostring(err)) + error("mmcli command failed to execute: --reset, reason: " .. tostring(err)) end end) return st == "ok", err or "" @@ -501,7 +489,7 @@ function ModemBackend:connect(conn_string) } local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - error("mmcli command failed to execute: " .. tostring(err)) + error("mmcli command failed to execute: --simple-connect, reason: " .. tostring(err)) end end) return st == "ok", err or "" @@ -520,7 +508,7 @@ function ModemBackend:disconnect() } local _, status, _, _, err = fibers.perform(cmd:combined_output_op()) if status ~= "exited" then - error("mmcli command failed to execute: " .. tostring(err)) + error("mmcli command failed to execute: --simple-disconnect, reason: " .. tostring(err)) end end) return st == "ok", err or "" @@ -545,7 +533,7 @@ function ModemBackend:inhibit() -- The command will run in the background, managed by the scope local stream, err = cmd:stdout_stream() if not stream then - return false, "Failed to start inhibit command: " .. tostring(err) + return false, "Failed to start inhibit command: --inhibit, reason: " .. tostring(err) end self.inhibit_cmd = cmd From a8378cddf649d32e11762f22cb7cbc323e475e5a Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:23:07 +0000 Subject: [PATCH 28/33] added more modem caps and start of uart --- src/services/hal/types/capabilities.lua | 14 ++++++-- src/services/hal/types/external.lua | 46 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/services/hal/types/capabilities.lua b/src/services/hal/types/capabilities.lua index 12c51c50..afac1629 100644 --- a/src/services/hal/types/capabilities.lua +++ b/src/services/hal/types/capabilities.lua @@ -95,8 +95,7 @@ function new.ModemCapability(id, control_ch) 'restart', 'connect', 'disconnect', - 'sim_detect', - 'fix_failure', + 'listen_for_sim', 'set_signal_update_freq', } return new.Capability('modem', id, control_ch, offerings) @@ -201,6 +200,17 @@ function new.FilesystemCapability(id, control_ch) return new.Capability('fs', id, control_ch, offerings) end +---@param id CapabilityId +---@param control_ch Channel +---@return Capability? +---@return string error +function new.UARTCapability(id, control_ch) + local offerings = { + 'open', 'close', 'write' + } + return new.Capability('uart', id, control_ch, offerings) +end + ---@class ControlError ---@field reason string ---@field code integer diff --git a/src/services/hal/types/external.lua b/src/services/hal/types/external.lua index 494104be..69ca8b3e 100644 --- a/src/services/hal/types/external.lua +++ b/src/services/hal/types/external.lua @@ -126,10 +126,56 @@ function new.FilesystemWriteOpts(filename, data) }, FilesystemWriteOpts), "" end +---@class UARTOpenOpts +---@field read boolean +---@field write boolean +local UARTOpenOpts = {} +UARTOpenOpts.__index = UARTOpenOpts + +---Create a new UARTOpenOpts. +---At least one of read or write must be true. +---@param read boolean +---@param write boolean +---@return UARTOpenOpts? +---@return string error +function new.UARTOpenOpts(read, write) + if type(read) ~= 'boolean' or type(write) ~= 'boolean' then + return nil, "read and write must be booleans" + end + if not read and not write then + return nil, "at least one of read or write must be true" + end + return setmetatable({ + read = read, + write = write, + }, UARTOpenOpts), "" +end + +---@class UARTWriteOpts +---@field data string +local UARTWriteOpts = {} +UARTWriteOpts.__index = UARTWriteOpts + +---Create a new UARTWriteOpts. +---@param data string +---@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), "" +end + return { ModemGetOpts = ModemGetOpts, ModemConnectOpts = ModemConnectOpts, + ModemSignalUpdateOpts = ModemSignalUpdateOpts, FilesystemReadOpts = FilesystemReadOpts, FilesystemWriteOpts = FilesystemWriteOpts, + UARTOpenOpts = UARTOpenOpts, + UARTWriteOpts = UARTWriteOpts, new = new, } From e31385dabe309bfd609f7674e9a6d7306aba3484 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:23:41 +0000 Subject: [PATCH 29/33] better error reporting and more rebust emitter fiber --- src/services/hal/drivers/modem.lua | 46 +++++++++++++++++++----------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index 51a8df42..cab02ae1 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -29,7 +29,6 @@ local pulse = require "fibers.pulse" ---@field initialised boolean ---@field caps_applied boolean ---@field state_pulse Pulse ----@field cache Cache local Modem = {} Modem.__index = Modem @@ -42,6 +41,7 @@ local function list_to_table(list) end ---- Constant Definitions ---- +local D_LOG_EMITTER = false local DEFAULT_STOP_TIMEOUT = 5 local DEFAULT_CACHE_TIMEOUT = 10 @@ -71,7 +71,8 @@ local GET_METHODS = list_to_table { "gid1", "active_band_class", "firmware", - "iccid" + "iccid", + "imsi" } @@ -163,14 +164,15 @@ end --- Validate that a function is implemented ---@param fn any +---@param verb string ---@return boolean is_valid ---@return string? error -local function validate_fn(fn) +local function validate_fn(fn, verb) if fn == nil then - return false, tostring(fn) .. " is unimplemented" + return false, tostring(verb) .. " is unimplemented" end if type(fn) ~= "function" then - return false, tostring(fn) .. " is not a function" + return false, tostring(verb) .. " is not a function" end return true end @@ -198,7 +200,7 @@ function Modem:get(opts) return return_error("invalid options", 1) end local field = opts.field - local timescale = opts.timescale or math.huge + local timescale = opts.timescale -- Check that the field is supported if not GET_METHODS[field] then @@ -331,7 +333,7 @@ end ---@return integer? code function Modem:listen_for_sim() if self.listening_for_sim then - return return_error("already listening for SIM", 1) + return true end self.listening_for_sim = true local ok, err = fibers.current_scope():spawn(function() @@ -391,7 +393,6 @@ function Modem:set_signal_update_freq(opts) return true end - function Modem:emitter() local timeout_buffer = 0.1 log.trace("Modem Driver", self.imei, "emitter: started") @@ -400,9 +401,17 @@ function Modem:emitter() log.trace("Modem Driver", self.imei, "emitter: exiting") end) + local seen_version = 0 while true do - self.state_pulse:next() -- wait for a pulse indicating state change + log.trace("Modem Driver", self.imei, "emitter: waiting for state change (last 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 + log.trace("Modem Driver", self.imei, "emitter: change detected emitting updates") for method, _ in pairs(GET_METHODS) do local opts, opts_err = external_types.new.ModemGetOpts(method, timeout_buffer) @@ -412,14 +421,14 @@ function Modem:emitter() .. tostring(opts_err)) else local ok, value_or_err = self:get(opts) - if not ok then + if not ok and D_LOG_EMITTER then local trimmed_err = trim_error(value_or_err) log.warn("Modem Driver", self.imei, "emitter: error getting field " .. tostring(method) .. ": " .. tostring(trimmed_err)) else local emit_ok, emit_err = self:_emit_meta(method, value_or_err) - if not emit_ok then + if not emit_ok and D_LOG_EMITTER then log.warn("Modem Driver", self.imei, "emitter: failed to emit meta for field " .. tostring(method) .. ": " .. tostring(emit_err)) @@ -446,6 +455,8 @@ function Modem:state_monitor() elseif err ~= "" then log.error("Modem Driver", self.imei, "state_monitor: error monitoring state:", err) elseif state_update then + log.trace("Modem Driver", self.imei, "state_monitor: detected state change from", + state_update.from, "to", state_update.to, "- signaling pulse") self.state_pulse:signal() -- signal that modem state has changed local ok, emit_err = self:_emit_state('card', state_update) if not ok then @@ -483,10 +494,10 @@ function Modem:control_manager() local ok, reason, code local fn = self[request.verb] - local valid, validation_err = validate_fn(fn) + local valid, validation_err = validate_fn(fn, request.verb) if not valid then ok = false - reason = "no function exists for verb: " .. tostring(validation_err) + reason = validation_err else local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, request.opts) if not call_ok then @@ -526,6 +537,9 @@ function Modem:start() 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() + return true, "" end @@ -638,13 +652,13 @@ local function new(address) end -- Print out driver stack trace if scope closes on a failure - scope:finally(function () + scope:finally(function() local st, primary = scope:status() if st == 'failed' then log.error(("Modem Driver %s: error - %s"):format(tostring(address), tostring(primary))) - log.trace("Modem Driver %s: scope exiting with status %s", tostring(address), st) + log.trace(("Modem Driver %s: scope exiting with status %s"):format(tostring(address), st)) end - log.trace("Modem Driver %s: stopped", tostring(address)) + log.trace(("Modem Driver %s: stopped"):format(tostring(address))) end) return setmetatable({ From 534d3abf1489326fbd76cd46e1c2b363e87f5d2f Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 4 Mar 2026 17:23:56 +0000 Subject: [PATCH 30/33] binary loading for apndb --- src/shared/binser.lua | 752 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 752 insertions(+) create mode 100644 src/shared/binser.lua diff --git a/src/shared/binser.lua b/src/shared/binser.lua new file mode 100644 index 00000000..18a63dae --- /dev/null +++ b/src/shared/binser.lua @@ -0,0 +1,752 @@ +-- binser.lua + +--[[ +Copyright (c) 2016-2019 Calvin Rose + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +]] + +local fiber_file = require "fibers.io.file" + +local assert = assert +local error = error +local select = select +local pairs = pairs +local getmetatable = getmetatable +local setmetatable = setmetatable +local type = type +local loadstring = loadstring or load +local concat = table.concat +local char = string.char +local byte = string.byte +local format = string.format +local sub = string.sub +local dump = string.dump +local floor = math.floor +local frexp = math.frexp +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, floor = math.log, math.abs, math.floor + local log2 = log(2) + frexp = function(x) + if x == 0 then return 0, 0 end + local e = floor(log(abs(x)) / log2 + 1) + return x / 2 ^ e, e + end +end + +local function pack(...) + return {...}, select("#", ...) +end + +local function not_array_index(x, len) + 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)) +end + +local bigIntSupport = false +local isInteger +if math.type then -- Detect Lua 5.3 + local mtype = math.type + bigIntSupport = loadstring[[ + local char = string.char + return function(n) + local nn = n < 0 and -(n + 1) or n + local b1 = nn // 0x100000000000000 + local b2 = nn // 0x1000000000000 % 0x100 + local b3 = nn // 0x10000000000 % 0x100 + local b4 = nn // 0x100000000 % 0x100 + local b5 = nn // 0x1000000 % 0x100 + local b6 = nn // 0x10000 % 0x100 + local b7 = nn // 0x100 % 0x100 + local b8 = nn % 0x100 + if n < 0 then + 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 + return char(212, b1, b2, b3, b4, b5, b6, b7, b8) + end]]() + isInteger = function(x) + return mtype(x) == 'integer' + end +else + 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) +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 +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(x, visited, accum) + accum[#accum + 1] = "\202" + end + + function types.number(x, visited, accum) + accum[#accum + 1] = number_to_str(x) + end + + function types.boolean(x, visited, 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 not (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 i = 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 not (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() From f8215bef60ee8ee047f8c920243317f6f450bf07 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Thu, 12 Mar 2026 16:01:44 +0000 Subject: [PATCH 31/33] correct metrics reporting --- src/services/gsm.lua | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/services/gsm.lua b/src/services/gsm.lua index de05ce8e..2ab2c0eb 100644 --- a/src/services/gsm.lua +++ b/src/services/gsm.lua @@ -72,11 +72,10 @@ local function t_cap_rpc(id, method) return { 'cap', 'modem', id, 'rpc', method } end ----@param id string|number ---@param key string ---@return table -local function t_obs_metric(id, key) - return { 'obs', 'v1', 'gsm', 'metric', id, key } +local function t_obs_metric(key) + return { 'obs', 'v1', 'gsm', 'metric', key } end ---@param id string|number @@ -418,7 +417,19 @@ function GsmModem:_emit_metric(key, value) if value == nil then return end - self.conn:publish(t_obs_metric(self.id, key), value) + local ns_name = nil + if self.name == "primary" then + ns_name = "1" + elseif self.name == "secondary" then + ns_name = "2" + else + return + end + local metric = { + value = value, + namespace = {'modem', ns_name, key} + } + self.conn:publish(t_obs_metric(key), metric) end ---@param key string @@ -482,8 +493,9 @@ function GsmModem:_emit_metrics_once() log.debug("GSM", self.name, "- state: ", msg_err) end local state = state_msg.payload and state_msg.payload.to + ---@cast state ModemStateEvent if state then - self:_emit_metric('state', state) + self:_emit_metric('state', state.to) end local net_ports, net_ports_err = modem_get_field(self.conn, self.id, 'net_ports', REQUEST_TIMEOUT) From 669965c3d0ec0c0cddf7677839324543288d9382 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 18 Mar 2026 11:53:55 +0000 Subject: [PATCH 32/33] implement modem monitoring functionality and validation --- src/services/hal/backends/modem/contract.lua | 24 ++++++- src/services/hal/backends/modem/provider.lua | 47 +++++++++----- .../modem/providers/linux_mm/init.lua | 10 ++- .../modem/providers/linux_mm/monitor.lua | 65 +++++++++++++++++++ src/services/hal/managers/modemcard.lua | 54 +++++---------- src/services/hal/types/modem.lua | 28 ++++++++ 6 files changed, 171 insertions(+), 57 deletions(-) create mode 100644 src/services/hal/backends/modem/providers/linux_mm/monitor.lua diff --git a/src/services/hal/backends/modem/contract.lua b/src/services/hal/backends/modem/contract.lua index ef7aaa95..b3193487 100644 --- a/src/services/hal/backends/modem/contract.lua +++ b/src/services/hal/backends/modem/contract.lua @@ -55,6 +55,27 @@ local BACKEND_FUNCTIONS = list_to_map { "set_signal_update_interval" } +local MONITOR_FUNCTIONS = list_to_map { + "next_event_op", +} + +--- 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 "" +end + --- Check that a modem backend provides all required functions and no more ---@param backend ModemBackend ---@return string error @@ -75,5 +96,6 @@ local function validate(backend) end return { - validate = validate + validate = validate, + validate_monitor = validate_monitor, } diff --git a/src/services/hal/backends/modem/provider.lua b/src/services/hal/backends/modem/provider.lua index 6f4893ce..a7a50f61 100644 --- a/src/services/hal/backends/modem/provider.lua +++ b/src/services/hal/backends/modem/provider.lua @@ -29,29 +29,21 @@ local function starts_with(str, start) return string.sub(str, 1, string.len(start)) == start end --- local backend_impl = nil - ---- select and initialize the backend implementation ----@return table backend_impl -local function get_backend_impl() - local backend_impl = nil +--- 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, backend_mod = pcall(require, "services.hal.backends.modem.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 + 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 - - if backend_impl == nil then - error("No supported modem backend found") - end - - return backend_impl + error("No supported modem provider found") end local function new(address) - local impl = get_backend_impl() + local provider = get_provider() + local impl = provider.backend local backend = impl.new(address) ---@cast backend ModemBackend local drivers, dr_err = backend:drivers() @@ -117,6 +109,25 @@ local function new(address) 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 +end + return { - new = new + new = new, + new_monitor = new_monitor, } 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 3af9cc5f..98969508 100644 --- a/src/services/hal/backends/modem/providers/linux_mm/init.lua +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -4,6 +4,7 @@ local op = require "fibers.op" local fibers = require "fibers" 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") @@ -43,8 +44,15 @@ local function is_supported() return res end +---@return ModemMonitor? monitor +---@return string error +local function new_monitor() + return monitor.new() +end + return { is_supported = is_supported, - backend = backend + 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 new file mode 100644 index 00000000..b02d6880 --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/monitor.lua @@ -0,0 +1,65 @@ +local exec = require "fibers.io.exec" +local op = require "fibers.op" +local modem_types = require "services.hal.types.modem" + +---@class ModemMonitor +---@field cmd Command +---@field stream any +local ModemMonitor = {} +ModemMonitor.__index = ModemMonitor + +--- Parse one line from `mmcli -M` output into a ModemMonitorEvent. +--- Returns (nil, "Command closed") when the stream yields nil (end of stream). +--- Returns (nil, error) for lines that cannot be parsed. +---@param line string? +---@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, "" +end + +--- Returns an Op that when performed yields the next ModemMonitorEvent. +--- (nil, "Command closed") signals end of stream. +--- (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) +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" + } + 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, +} diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua index cec0012e..ef8f7464 100644 --- a/src/services/hal/managers/modemcard.lua +++ b/src/services/hal/managers/modemcard.lua @@ -1,5 +1,5 @@ -- HAL modules -local mmcli = require "services.hal.backends.mmcli" +local modem_provider = require "services.hal.backends.modem.provider" local hal_types = require "services.hal.types.core" local external_types = require "services.hal.types.external" local modem_driver = require "services.hal.drivers.modem" @@ -35,20 +35,6 @@ local ModemcardManager = { modems = {}, } ----Get a modem status and address from a monitor line ----@param line string ----@return boolean? is_added ----@return string? address ----@return string? err -local function parse_monitor(line) - local status, address = line:match("^(.-)(/org%S+)") - if address then - return not status:match("-"), address, nil - else - return nil, nil, 'line could not be parsed' - end -end - ---Continuously monitors modem add/remove events and publishes them onto ---`ModemcardManager.modem_detect_ch` and `ModemcardManager.modem_remove_ch`. ---@param scope Scope @@ -59,32 +45,26 @@ local function detector(scope) log.trace("Modem Detector: closed") end) - local monitor_cmd = mmcli.monitor_modems() - local stdout, err = monitor_cmd:stdout_stream() - if not stdout then - error("Modem Detector: failed to get stdout stream: " .. err) + local monitor, err = modem_provider.new_monitor() + if not monitor then + error("Modem Detector: failed to create monitor: " .. tostring(err)) end while true do - local line, rerr = stdout:read_line() - if rerr then - log.error("Modem Detector: stdout read error:", rerr) - break - end - if line == nil then + local event, mon_err = fibers.perform(monitor:next_event_op()) + if mon_err == "Command closed" then break - end - - local is_added, address, parse_err = parse_monitor(line) - - if is_added == nil or address == nil then - log.error("Modem Detector: failed to parse line:", parse_err) - elseif is_added == true then - log.info("Modem Detector: detected at", address) - ModemcardManager.modem_detect_ch:put(address) - elseif is_added == false then - log.info("Modem Detector: removed at", address) - ModemcardManager.modem_remove_ch:put(address) + elseif mon_err and mon_err ~= "" then + log.warn("Modem Detector: skipping unparseable line:", mon_err) + elseif event then + ---@cast event ModemMonitorEvent + if event.is_added then + log.info("Modem Detector: detected at", event.address) + ModemcardManager.modem_detect_ch:put(event.address) + else + log.info("Modem Detector: removed at", event.address) + ModemcardManager.modem_remove_ch:put(event.address) + end end end end diff --git a/src/services/hal/types/modem.lua b/src/services/hal/types/modem.lua index 4e982c7b..30038d35 100644 --- a/src/services/hal/types/modem.lua +++ b/src/services/hal/types/modem.lua @@ -141,6 +141,33 @@ function new.ModemStateRemovedEvent(reason) return new.ModemStateEvent("removed", "removed", "removed", reason) end +---@class ModemMonitorEvent +---@field is_added boolean +---@field address ModemAddress +local ModemMonitorEvent = {} +ModemMonitorEvent.__index = ModemMonitorEvent + +---Create a new ModemMonitorEvent. +---@param is_added boolean +---@param address ModemAddress +---@return ModemMonitorEvent? +---@return string error +function new.ModemMonitorEvent(is_added, address) + if type(is_added) ~= 'boolean' then + return nil, "invalid is_added: expected boolean" + end + if type(address) ~= 'string' or address == '' then + return nil, "invalid address" + end + return setmetatable({ + is_added = is_added, + address = address, + }, ModemMonitorEvent), "" +end + +---@class ModemMonitor +---@field next_event_op fun(self: ModemMonitor): Op + ---@class ModemBackend ---@field identity ModemIdentity ---@field cache Cache @@ -189,5 +216,6 @@ return { ModemDevice = ModemDevice, ModemIdentity = ModemIdentity, ModemStateEvent = ModemStateEvent, + ModemMonitorEvent = ModemMonitorEvent, new = new, } From 7ed23dab2f42a6048bd514fc1226f6485a68a989 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Fri, 20 Mar 2026 13:07:09 +0000 Subject: [PATCH 33/33] enhance modem handling: add SIM presence polling and lifecycle monitoring --- .../hal/backends/modem/models/quectel.lua | 67 ++++++++++++ src/services/hal/backends/modem/modes/qmi.lua | 4 +- src/services/hal/drivers/modem.lua | 103 ++++++++++++------ 3 files changed, 142 insertions(+), 32 deletions(-) diff --git a/src/services/hal/backends/modem/models/quectel.lua b/src/services/hal/backends/modem/models/quectel.lua index 7e675e5c..7ca8e647 100644 --- a/src/services/hal/backends/modem/models/quectel.lua +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -2,10 +2,29 @@ local exec = require "fibers.io.exec" local fibers = require "fibers" local sleep = require "fibers.sleep" local op = require "fibers.op" +local scope = require "fibers.scope" local at = require "services.hal.backends.modem.at" local DEFAULT_TIMEOUT = 5 +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 +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 + else + return nil, "Invalid firmware version format" + end +end local funcs = { -- This is a special case for the RM520N, it has a initial bearer which will always cause a @@ -85,6 +104,54 @@ local funcs = { end end end + }, + { + -- On em06 (all firmware) and eg25-g (≤ 01.002) the UIM slot monitor cannot be relied upon + -- to fire on SIM removal or insertion. Override with a polling implementation using + -- is_sim_present(). listen_for_sim() drives trigger_sim_presence_check() for insertion. + name = 'wait_for_sim_present_op', + conditionals = { + function(backend, model, _) + if model == 'em06' then return true end + if model ~= 'eg25' then return false end + if type(backend.firmware) ~= "function" then return false end + for _ = 0, 2 do + local firmware_version, err = backend:firmware() + if err == "" and firmware_version then + local version_code = firmware_version_code(firmware_version_string(firmware_version)) + return version_code ~= nil and version_code <= firmware_version_code("01.002") + end + sleep.sleep(1) + end + return false + end + }, + func = function(backend) + return op.guard(function() + return scope.run_op(function(s) + while true do + local present, err = backend:is_sim_present() + if err ~= "" then + error("Failed to poll SIM presence: " .. err) + end + if present ~= backend.last_sim_state then + backend.last_sim_state = present + return present, "" + end + s:perform(sleep.sleep_op(SIM_POLL_INTERVAL)) + end + 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 } -- Other functions } diff --git a/src/services/hal/backends/modem/modes/qmi.lua b/src/services/hal/backends/modem/modes/qmi.lua index b0fed019..e2253637 100644 --- a/src/services/hal/backends/modem/modes/qmi.lua +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -135,7 +135,9 @@ local function add_mode_funcs(ModemBackend) return true, "" end - --- Make an op to listen for sim presence + --- Make an op that completes when the UIM slot status changes to reflect a new SIM state. + --- Behaviour varies by modem and firmware — on some models the slot monitor cannot be relied + --- upon to fire on removal or insertion. See models/quectel.lua for the polling override. ---@return Op function ModemBackend:wait_for_sim_present_op() return op.guard(function() diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index cab02ae1..01f491ed 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -29,6 +29,8 @@ local pulse = require "fibers.pulse" ---@field initialised boolean ---@field caps_applied boolean ---@field state_pulse Pulse +---@field sim_inserted_pulse Pulse +---@field sim_state_ch Channel local Modem = {} Modem.__index = Modem @@ -45,6 +47,7 @@ local D_LOG_EMITTER = false local DEFAULT_STOP_TIMEOUT = 5 local DEFAULT_CACHE_TIMEOUT = 10 +local LISTEN_TRIGGER_INTERVAL = 1 local CONTROL_Q_LEN = 8 @@ -337,39 +340,37 @@ function Modem:listen_for_sim() end self.listening_for_sim = true local ok, err = fibers.current_scope():spawn(function() - fibers.run_scope(function() - self:_emit_state("sim_listener", "open") - - fibers.current_scope():finally(function() - self.listening_for_sim = false - self:_emit_state("sim_listener", "closed") - end) - - while true do - --- out returns true if SIM is present, false if not - local source, out, err = fibers.perform(op.named_choice { - sim_present = self.backend:wait_for_sim_present_op(), - timeout = sleep.sleep_op(DEFAULT_CACHE_TIMEOUT) - }) - if source == "sim_present" then - if err ~= "" then - log.error("Modem Driver", self.imei, - "listen_for_sim: error waiting for SIM presence:", err) - self:_emit_event("sim_listen_error", err) - end - if out then - self.state_pulse:signal() - break - end - elseif source == "timeout" then - local ok, check_err = self.backend:trigger_sim_presence_check() - if not ok then - log.error("Modem Driver", self.imei, - "listen_for_sim: failed to trigger SIM presence check:", check_err) - end + 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 + log.error("Modem Driver", self.imei, + "listen_for_sim: failed to trigger SIM presence check:", check_err) end + elseif source == "failed" then + log.error("Modem Driver", self.imei, + "listen_for_sim: modem scope faulted:", tostring(primary)) + break end - end) + end end) if not ok then return return_error("listen_for_sim spawn failed: " .. tostring(err), 1) @@ -520,6 +521,43 @@ function Modem:control_manager() end end +function Modem:sim_lifecycle_monitor() + log.trace("Modem Driver", self.imei, "sim_lifecycle_monitor: started") + + fibers.current_scope():finally(function() + log.trace("Modem Driver", self.imei, "sim_lifecycle_monitor: exiting") + end) + + local sim_present = nil + while true do + local source, v1, v2 = fibers.perform(op.named_choice { + sim_change = self.backend:wait_for_sim_present_op(), + send = self.sim_state_ch:put_op(sim_present), + }) + if source == "sim_change" then + local present, err = v1, v2 + if err == "cancelled" or err == "Stream closed" or err == "Command closed" then + log.trace("Modem Driver", self.imei, "sim_lifecycle_monitor:", err) + break + elseif err ~= "" then + log.error("Modem Driver", self.imei, "sim_lifecycle_monitor: error polling SIM presence:", err) + sleep.sleep(DEFAULT_CACHE_TIMEOUT) + elseif present then + log.trace("Modem Driver", self.imei, "sim_lifecycle_monitor: SIM inserted") + sim_present = true + self.sim_inserted_pulse:signal() + self.state_pulse:signal() + self:_emit_state("sim_status", "present") + else + log.trace("Modem Driver", self.imei, "sim_lifecycle_monitor: SIM removed") + sim_present = false + self:_emit_state("sim_status", "absent") + end + end + -- source == "send": listener consumed the state, loop to offer it again + end +end + ---- Driver Functions ---- --- Spawn driver services @@ -536,6 +574,7 @@ function Modem:start() self.scope:spawn(function() self:state_monitor() end) self.scope:spawn(function() self:control_manager() end) self.scope:spawn(function() self:emitter() end) + self.scope:spawn(function() self:sim_lifecycle_monitor() end) -- Signal initial pulse so emitter emits the initial state self.state_pulse:signal() @@ -668,6 +707,8 @@ local function new(address) 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