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 + + + diff --git a/src/services/gsm.lua b/src/services/gsm.lua new file mode 100644 index 00000000..2ab2c0eb --- /dev/null +++ b/src/services/gsm.lua @@ -0,0 +1,958 @@ +-- 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 apns = require "services.gsm.apn" + +local REQUEST_TIMEOUT = 10 +local DEFAULT_RETRY_TIMEOUT = 20 +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 +---@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 +local function t_cap_rpc(id, method) + return { 'cap', 'modem', id, 'rpc', method } +end + +---@param key string +---@return table +local function t_obs_metric(key) + return { 'obs', 'v1', 'gsm', 'metric', 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 nil, err or "rpc failed" + end + if reply.ok ~= true then + return nil, reply.reason or 'rpc failed' + end + return reply.reason, "" +end + +---@param conn Connection +---@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, timescale) + local opts, opts_err = external_types.new.ModemGetOpts(field, timescale) + if not opts then + return nil, 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 rssi any +---@param rsrp any +---@param rscp any +---@return string +---@return number +---@return string +local function select_signal_for_bars(access_techs, rssi, rsrp, rscp) + if not access_techs 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 ~= 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 ~= nil then + local rsrp_value = tonumber(rsrp) + if rsrp_value then + return access_tech, rsrp_value, "rsrp" + end + end + + if rssi ~= nil 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)) + then + local merged = shallow_copy(entry) + apply_defaults(merged, base) + return merged, (merged.name or ""), "" + end + end + end + end + + 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 +---@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) + log.info("GSM", self.name, "- emitting metric", key, "=", tostring(value)) + if value == nil then + return + end + 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 +---@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 + 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, '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 + self:_emit_metric('imei', imei) + end + + local operator, operator_err = modem_get_field(self.conn, self.id, 'operator', REQUEST_TIMEOUT) + 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 + 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 + self:_emit_metric('iccid', iccid) + end + + local firmware, firmware_err = modem_get_field(self.conn, self.id, 'firmware', REQUEST_TIMEOUT) + if firmware_err == "" then + self:_emit_metric('firmware', firmware) + end + + 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 + ---@cast state ModemStateEvent + if state then + self:_emit_metric('state', state.to) + end + + local net_ports, net_ports_err = modem_get_field(self.conn, self.id, 'net_ports', REQUEST_TIMEOUT) + 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.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 + 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 + self:_emit_metric('tx_bytes', tx_bytes) + end + + 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 + + if rsrp then + self:_emit_metric('rsrp', rsrp) + end + + if rsrq then + self:_emit_metric('rsrq', rsrq) + end + + local bars_access_tech, signal_value, signal_type = select_signal_for_bars( + access_techs, + rssi, + rsrp, + rscp + ) + + 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 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 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 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 + + 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: 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 + 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 + 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 + + -- 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 + 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 +---@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.name, "- 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.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() + 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 + + 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 +---@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 + + ---@type table + 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.error("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)) + + 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('+')) + + publish_status(conn, name, 'running') + + while true do + 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) + 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 + 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 + 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 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 +} diff --git a/src/services/hal/backends/fetch.lua b/src/services/hal/backends/fetch.lua new file mode 100644 index 00000000..4cd9b0d3 --- /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 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/at.lua b/src/services/hal/backends/modem/at.lua new file mode 100644 index 00000000..c640d588 --- /dev/null +++ b/src/services/hal/backends/modem/at.lua @@ -0,0 +1,126 @@ +package.path = '/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua;' .. package.path + +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 + -- %z matches the character with representation 0x00 (NUL byte) + return (input:gsub("^[%c%s%z]+", ""):gsub("[%c%s%z]+$", "")) +end + +---@class AT +local at = {} + +---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`. +--- +---@param port SerialPortPath +---@param command ATCommand +---@param opts ATSendOpts? +---@return Op -- yields (ATResponseLine[]?, string?) +function at.send_op(port, command, opts) + local terminal_patterns = (opts and opts.terminal_patterns) or {} + + return scope.run_op(function(s) + local reader, rd_err = file.open(port, "r") + if not reader then + return nil, "error opening AT read port: " .. rd_err + end + + -- Centralised cleanup: reader is closed however this scope exits + -- (success, AT error, unhandled error, or cancelled by losing a race). + s:finally(function() reader:close() end) + + local writer, wr_err = file.open(port, "w") + if not writer then + return nil, "error opening AT write port: " .. wr_err + end + + writer:write(command .. '\r') + writer:close() + + local res = {} + while true do + local line, read_err = s:perform(reader:read_line_op()) + + if not line then + return nil, read_err or "unknown error" + end + + line = trim(line) + + -- Built-in terminals + if line:find("^OK$") then + return res, nil + elseif line:find("^ERROR$") then + return res, "error" + else + local error_code = line:match("^%+CME ERROR: (%d+)$") + or line:match("^%+CMS ERROR: (%d+)$") + if error_code then + return res, error_code + end + end + + -- User-supplied terminal patterns + for _, tp in ipairs(terminal_patterns) do + if line:find(tp.pattern) then + table.insert(res, line) + return res, tp.is_error and line or nil + end + end + + if #line > 0 then table.insert(res, line) end + end + end):wrap(function(st, _report, a, b) + if st == 'ok' then return a, b end + if st == 'cancelled' then return nil, 'cancelled' end + -- 'failed': a is the primary error string + return nil, a or "AT command failed" + end) +end + +return at diff --git a/src/services/hal/backends/modem/contract.lua b/src/services/hal/backends/modem/contract.lua new file mode 100644 index 00000000..b3193487 --- /dev/null +++ b/src/services/hal/backends/modem/contract.lua @@ -0,0 +1,101 @@ +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", + "firmware", + "iccid", + "imsi", + + -- 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", + "connect", + "disconnect", + "inhibit", + "uninhibit", + "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 +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, + validate_monitor = validate_monitor, +} 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..978660da --- /dev/null +++ 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 new file mode 100644 index 00000000..7ca8e647 --- /dev/null +++ b/src/services/hal/backends/modem/models/quectel.lua @@ -0,0 +1,176 @@ +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 + -- 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 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 + + 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 + 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 +} + +--- 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 new file mode 100644 index 00000000..b783107f --- /dev/null +++ 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 new file mode 100644 index 00000000..e2253637 --- /dev/null +++ b/src/services/hal/backends/modem/modes/qmi.lua @@ -0,0 +1,298 @@ +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" + +local fetch = require "services.hal.backends.fetch" + +--- 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 + +--- 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 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: --nas-get-home-network, reason: " .. tostring(err)) + end + + local mcc = out:match("MCC:%s+'(%d+)'") + local mnc = out:match("MNC:%s+'(%d+)'") + if not mcc or not mnc then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + 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 +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_gid1(identity, cache) + 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: --uim-read-transparent, reason: " .. tostring(err)) + end + + -- Parse the hex string after "Read result:" + local gid1 = out:match("%s+(%S+)%s*$"):gsub(":", "") + if not gid1 then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + 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 +---@param identity ModemIdentity +---@param cache Cache +---@return string error +local function fetch_rf_band_info(identity, cache) + 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: --nas-get-rf-band-info, reason: " .. tostring(err)) + end + + local active_band_class = out:match("Active Band Class:%s*'([^']+)'") + if not active_band_class then + error("Failed to parse qmicli output: " .. tostring(out)) + end + + 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 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() + return scope.run_op(function(s) + if not self.sim_present then + return false, "Sim presence monitor not started" + end + -- 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 + 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 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) + + if st == "ok" then + return present_or_err, "" + else + return false, present_or_err or "Unknown error" + end + 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) + 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 st == "ok", err or "" + 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 { + 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..a7a50f61 --- /dev/null +++ b/src/services/hal/backends/modem/provider.lua @@ -0,0 +1,133 @@ +local contract = require "services.hal.backends.modem.contract" + +local MODEL_INFO = { + quectel = { + -- these are ordered, as eg25gl should match before eg25g + { mod_string = "UNKNOWN", rev_string = "eg25gl", model = "eg25", model_variant = "gl" }, + { mod_string = "UNKNOWN", rev_string = "eg25g", model = "eg25", model_variant = "g" }, + { mod_string = "UNKNOWN", rev_string = "ec25e", model = "ec25", model_variant = "e" }, + { mod_string = "em06-e", rev_string = "em06e", model = "em06", model_variant = "e" }, + { mod_string = "rm520n-gl", rev_string = "rm520ngl", model = "rm520n", model_variant = "gl" }, + { mod_string = "em12-g", rev_string = "em12g", model = "em12", model_variant = "g" }, + -- more quectel models here + }, + fibocom = {} +} + +local BACKENDS = { + "linux_mm" +} + +--- 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 + +--- Select the first supported provider for the current environment. +---@return table provider +local function get_provider() + for _, backend_name in ipairs(BACKENDS) do + local ok, mod = pcall(require, "services.hal.backends.modem.providers." .. backend_name .. ".init") + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + return mod + end + end + error("No supported modem provider found") +end + +local function new(address) + local provider = get_provider() + local impl = provider.backend + local 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 + + 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, driver_mod = pcall(require, "services.hal.backends.modem.modes." .. mode) + if ok and type(driver_mod) == "table" and driver_mod.add_mode_funcs then + driver_mod.add_mode_funcs(backend) + end + end + + local plugin, 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 + + 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, 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 + end + end + end + if model_funcs_loaded then break end + end + + local iface_err = contract.validate(backend) + if iface_err ~= "" then + error("Modem backend does not implement required interface: " .. tostring(iface_err)) + end + + 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_monitor = new_monitor, +} 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..fe2c6de5 --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/getters.lua @@ -0,0 +1,167 @@ +--- Modem backend getter methods +--- This module defines all the attribute accessor methods for ModemBackend + +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_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 + ---@return string error + function ModemBackend:imei(timeout) + return fetch.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 fetch.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 fetch.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 fetch.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 fetch.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 fetch.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 fetch.get_cached_value(self.identity, "net_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 fetch.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 fetch.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 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 string plugin + ---@return string error + function ModemBackend:plugin(timeout) + return fetch.get_cached_value(self.identity, "plugin", self.cache, "string", 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 fetch.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 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 + + --- 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 + + --- 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 { + 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..a1e6dda1 --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/impl.lua @@ -0,0 +1,623 @@ +-- 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" + +-- Other modules +local cache_mod = require "shared.cache" +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 = math.huge -- The default for cache is to hold a value indefinitely + +local MODEM_INFO_PATHS = { + imei = { "generic", "equipment-identifier" }, + device = { "generic", "device" }, + primary_port = { "generic", "primary-port" }, + ports = { "generic", "ports" }, + access_techs = { "generic", "access-technologies" }, + sim = { "generic", "sim" }, + drivers = { "generic", "drivers" }, + plugin = { "generic", "plugin" }, + model = { "generic", "model" }, + revision = { "generic", "revision" }, + operator = { "3gpp", "operator-name" }, +} + +local SIM_INFO_PATHS = { + iccid = { "properties", "iccid" }, + imsi = { "properties", "imsi" }, +} + +local SIGNAL_TECHNOLOGIES = list_to_map { + "5g", + "cdma1x", + "evdo", + "gsm", + "lte", + "umts" +} + +local SIGNAL_IGNORE_FIELDS = list_to_map { + "error-rate" +} + + +---- 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 + +--- 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[] +---@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 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 + error("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 + 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 -1, rx_bytes_or_err or "Unknown error" +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] and signal_value ~= "--" 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 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: --signal-get, reason: " .. 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 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 + error("Failed to get active signal: " .. tostring(active_err)) + end + cache:set("signal", shallow_copy(active_signal)) -- Cache a copy of the active signal fields + 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 + if sim == "--" then + return "" -- no sim means we cannot get sim info + 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: mmcli -J -i " .. + sim .. ", reason:" .. 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 + + +--- 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", + "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 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 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 = "/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 = "/dev/" .. 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, + mode_port, + at_port, + net_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 ---- +--- See ModemBackend class definition in services.hal.types.modem + +local ModemBackend = {} +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 +) + +--- Enable the modem +---@return boolean ok +---@return string error +function ModemBackend:enable() + 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: enable, reason: " .. tostring(err)) + end + end) + return st == "ok", err or "" +end + +--- Disable the modem +---@return boolean ok +---@return string error +function ModemBackend:disable() + 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: disable, reason: " .. tostring(err)) + end + end) + return st == "ok", err or "" +end + +--- Reset the modem +---@return boolean ok +---@return string error +function ModemBackend:reset() + 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: --reset, reason: " .. tostring(err)) + end + end) + return st == "ok", err or "" +end + +--- Connect the modem +---@param conn_string string +---@return boolean ok +---@return string error +function ModemBackend:connect(conn_string) + local full_conn_string = "--simple-connect=" .. conn_string + 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: --simple-connect, reason: " .. tostring(err)) + end + end) + return st == "ok", err or "" +end + +--- Disconnect the modem +---@return boolean ok +---@return string error +function ModemBackend:disconnect() + 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: --simple-disconnect, reason: " .. tostring(err)) + end + end) + return st == "ok", err or "" +end + +--- Inhibit the modem +---@return boolean ok +---@return string error +function ModemBackend:inhibit() + if self.inhibit_cmd then + return false, "Modem is already inhibited" + end + + local cmd = exec.command { + "mmcli", "-m", self.identity.address, "--inhibit", + stdin = "null", + stdout = "pipe", + stderr = "stdout" + } + + -- 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 false, "Failed to start inhibit command: --inhibit, reason: " .. tostring(err) + end + + self.inhibit_cmd = cmd + return true, "Modem inhibit started" +end + +--- Uninhibit the modem +---@return boolean ok +---@return string error +function ModemBackend:uninhibit() + if not self.inhibit_cmd then + return false, "Modem is not inhibited" + end + + self.inhibit_cmd:kill() + self.inhibit_cmd = nil + + return true, "Modem uninhibited" +end + +--- 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", + stdout = "pipe", + stderr = "stdout" + } + local stream, err = cmd:stdout_stream() + if not stream then + return false, "Failed to start monitor state command: " .. tostring(err) + end + self.state_monitor = { cmd = cmd, stream = stream } + return true, "" +end + +--- 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 +--- @return ModemBackend +local function new(address) + local cache = cache_mod.new(CACHE_TIMEOUT, nil, '.') + local self = { + cache = cache, + identity = get_identity(address, cache), + base = "linux_mm" + } + 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..98969508 --- /dev/null +++ b/src/services/hal/backends/modem/providers/linux_mm/init.lua @@ -0,0 +1,58 @@ +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 monitor = require "services.hal.backends.modem.providers.linux_mm.monitor" + +local function is_linux() + local fh, open_err = file.open("/proc/version", "r") + if not fh or open_err then + return false + end + + local content, read_err = fh:read_all() + fh:close() + if not content or read_err then + return false + end + + return content:lower():find("linux") ~= nil +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 = fibers.perform(cmd:combined_output_op()) + if status == "exited" and code == 0 then + return true + end + return false +end + +--- Returns if linux with modem manager is supported +---@return boolean +local function is_supported() + local res = is_linux() and has_mmcli() + return res +end + +---@return ModemMonitor? monitor +---@return string error +local function new_monitor() + return monitor.new() +end + +return { + is_supported = is_supported, + backend = backend, + new_monitor = new_monitor, +} + diff --git a/src/services/hal/backends/modem/providers/linux_mm/monitor.lua b/src/services/hal/backends/modem/providers/linux_mm/monitor.lua 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/drivers/modem.lua b/src/services/hal/drivers/modem.lua new file mode 100644 index 00000000..01f491ed --- /dev/null +++ b/src/services/hal/drivers/modem.lua @@ -0,0 +1,718 @@ +-- Modem modules +local modem_backend_provider = require "services.hal.backends.modem.provider" + +-- 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 op = require "fibers.op" +local channel = require "fibers.channel" +local sleep = require "fibers.sleep" +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 imei string +---@field model string +---@field model_variant string +---@field mode string +---@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 + +local function list_to_table(list) + local t = {} + for _, v in ipairs(list) do + t[v] = true + end + return t +end + +---- Constant Definitions ---- +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 + +local GET_METHODS = list_to_table { + "imei", + "device", + "primary_port", + "at_ports", + "qmi_ports", + -- "gps_ports", -- maybe needed for the future + "net_ports", + "access_techs", + "sim", + "drivers", + "plugin", + "model", + "revision", + "operator", + "rx_bytes", + "tx_bytes", + "signal", + "mcc", + "mnc", + "gid1", + "active_band_class", + "firmware", + "iccid", + "imsi" +} + + +---- 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 return a ControlError +---@param err string? +---@param code integer? +---@return boolean ok +---@return string reason +---@return integer? code +local function return_error(err, code) + if err == nil then + err = "unknown error" + end + return false, err, code +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.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.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.imei, 'meta', key, data) +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, verb) + if fn == nil then + return false, tostring(verb) .. " is unimplemented" + end + if type(fn) ~= "function" then + return false, tostring(verb) .. " is not a function" + end + return true +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 boolean ok +---@return any reason_or_value +---@return integer? code +function Modem:get(opts) + if opts == nil or getmetatable(opts) ~= external_types.ModemGetOpts then + return return_error("invalid options", 1) + end + local field = opts.field + local timescale = opts.timescale + + -- Check that the field is supported + if not GET_METHODS[field] then + 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 + return return_error("field " .. tostring(field) .. " is not implemented by backend", 1) + end + local value, err = get_fn(self.backend, timescale) + if err ~= "" then + return return_error("error getting field " .. tostring(field) .. ": " .. tostring(err), 1) + end + 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 + 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 + 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 + 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 + return return_error("invalid options", 1) + end + local ok, err = self.backend:connect(opts.connection_string) + if not ok then + return return_error(err, 1) + end + 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 + 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() + + local ok, err = self.scope:spawn(function() + local result_ok, result_err = self.backend:inhibit() + done_ch:put({ ok = result_ok, err = result_err }) + end) + + if not ok then + return return_error("failed to spawn inhibit fiber: " .. tostring(err), 1) + end + + local source, msg, primary = fibers.perform(op.named_choice { + done = done_ch:get_op(), + failed = self.scope:fault_op(), + }) + + if source == "done" then + if not msg.ok then + return return_error(msg.err, 1) + end + return true + elseif source == "failed" then + return return_error("modem inhibit failed: " .. tostring(primary), 1) + end + return return_error("unexpected error during modem inhibit", 1) +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 + 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 + return true + end + self.listening_for_sim = true + local ok, err = fibers.current_scope():spawn(function() + self:_emit_state("sim_listener", "open") + + fibers.current_scope():finally(function() + self.listening_for_sim = false + self:_emit_state("sim_listener", "closed") + end) + + -- Capture pulse version before reading state to close the insertion race window. + local last_seen = self.sim_inserted_pulse:version() + local sim_present = self.sim_state_ch:get() + + while sim_present ~= true do + local source, _, primary = fibers.perform(op.named_choice { + inserted = self.sim_inserted_pulse:changed_op(last_seen), + trigger = sleep.sleep_op(LISTEN_TRIGGER_INTERVAL), + failed = self.scope:fault_op(), + }) + if source == "inserted" then + break + elseif source == "trigger" then + local trigger_ok, check_err = self.backend:trigger_sim_presence_check() + if not trigger_ok then + 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) + if not ok then + 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 + return return_error("invalid options", 1) + end + local ok, err = self.backend:set_signal_update_interval(opts.frequency) + if not ok then + return return_error(err, 1) + end + return true +end + +function Modem:emitter() + local timeout_buffer = 0.1 + log.trace("Modem Driver", self.imei, "emitter: started") + + fibers.current_scope():finally(function() + log.trace("Modem Driver", self.imei, "emitter: exiting") + end) + + local seen_version = 0 + while true do + 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) + if not opts then + log.warn("Modem Driver", self.imei, + "emitter: failed to build get opts for field " .. tostring(method) .. ": " + .. tostring(opts_err)) + else + local ok, value_or_err = self:get(opts) + 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 and D_LOG_EMITTER then + log.warn("Modem Driver", self.imei, + "emitter: failed to emit meta for field " .. tostring(method) .. ": " + .. tostring(emit_err)) + end + 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 + 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 + 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.imei, "control_manager: cap_emit_ch is nil") + return + end + if self.control_ch == nil then + log.error("Modem Driver", self.imei, "control_manager: control_ch is nil") + return + end + + 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.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, request.verb) + if not valid then + ok = false + reason = validation_err + else + local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, request.opts) + if not call_ok then + ok = false + reason = "internal error: " .. tostring(fn_ok) + code = 1 + else + ok = fn_ok + reason = fn_reason + code = fn_code + end + end + + local reply, reply_err = hal_types.new.Reply(ok, reason, code) + if not reply then + log.error("Modem Driver", self.imei, "control_manager: failed to create reply:", reply_err) + else + request.reply_ch:put(reply) + end + 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 +---@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(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() + + 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( + self.imei, + self.control_ch + ) + if not modem_cap then + return nil, "failed to create modem capability: " .. tostring(mod_cap_err) + end + + self.caps_applied = true + + return { modem_cap } +end + +--- Setup modem overrides and long running fibers +---@return string error +function Modem:init() + if self.initialised then + return "already initialised" + end + + local backend_built_sig = cond.new() + + local ok, err = self.scope:spawn(function() + self.backend = modem_backend_provider.new(self.address) + + self.backend:start_sim_presence_monitor() + self.backend:start_state_monitor() + + -- 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 + + backend_built_sig:signal() + end) + + if not ok then + return "failed to spawn modem backend fiber: " .. tostring(err) + end + + local source, _, primary = fibers.perform(op.named_choice { + backend_ready = backend_built_sig:wait_op(), + failed = self.scope:fault_op() + }) + + if source == "backend_ready" then + self.initialised = true + return "" + elseif source == "failed" then + return "modem init failed: " .. tostring(primary) -- primary is the error from the faulted fiber + else + return "unexpected error during modem init" + end +end + +--- Create a new Modem driver. +---@param address ModemAddress +---@return Modem? modem +---@return string error +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"):format(tostring(address), st)) + end + log.trace(("Modem Driver %s: stopped"):format(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 + listening_for_sim = false, + state_pulse = pulse.new(), + sim_inserted_pulse = pulse.new(), + sim_state_ch = channel.new(), + control_ch = control_ch + }, Modem), "" +end + +return { + new = new +} diff --git a/src/services/hal/managers/modemcard.lua b/src/services/hal/managers/modemcard.lua new file mode 100644 index 00000000..ef8f7464 --- /dev/null +++ b/src/services/hal/managers/modemcard.lua @@ -0,0 +1,313 @@ +-- HAL modules +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" + +-- Fiber modules +local fibers = require "fibers" +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 scope Scope +---@field started boolean +---@field modem_remove_ch Channel +---@field modem_detect_ch Channel +---@field driver_ch Channel +---@field modems table +local ModemcardManager = { + started = false, + modem_remove_ch = channel.new(), + modem_detect_ch = channel.new(), + driver_ch = channel.new(), + modems = {}, +} + +---Continuously monitors modem add/remove events and publishes them onto +---`ModemcardManager.modem_detect_ch` and `ModemcardManager.modem_remove_ch`. +---@param scope Scope +local function detector(scope) + log.trace("Modem Detector: started") + + scope:finally(function () + log.trace("Modem Detector: closed") + end) + + 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 event, mon_err = fibers.perform(monitor:next_event_op()) + if mon_err == "Command closed" then + break + elseif mon_err and mon_err ~= "" then + log.warn("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 + +---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 + + -- Get device, no need to have a fresh value so set cache lifetime to infinity + -- Also asking for a fresh value when the modem may have disconnected could cause errors + local get_ok, primary = driver:get(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) + 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) + + 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.current_scope():spawn(function() + local init_err = driver:init() + if init_err ~= "" then + log.error(("Modemcard Manager: failed to init modem driver %s: %s"):format(address, 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) + local address = driver.address + -- Get device, no need to have a fresh value so set cache lifetime to infinity + local get_ok, primary = driver:get(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 + + -- 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", + device, + { + address = address, + port = 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 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(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 fault_ops = {} + for address, driver in pairs(ModemcardManager.modems) do + table.insert(fault_ops, driver.scope:fault_op():wrap(function () return address end)) + end + + local fault_op = op.never() + if #fault_ops > 0 then + fault_op = op.choice(unpack(fault_ops)) + end + + local source, msg, err = fibers.perform(op.named_choice{ + detect = ModemcardManager.modem_detect_ch:get_op(), + remove = ModemcardManager.modem_remove_ch:get_op(), + driver = ModemcardManager.driver_ch:get_op(), + driver_fault = fault_op, + }) + + if not msg then + log.error("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) + 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 +end + +---Starts the Modemcard Manager's detector and manager fibers. +---@param dev_ev_ch Channel +---@param cap_emit_ch Channel +---@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 + + -- 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.started = true + log.trace("Modemcard Manager: started") + return "" +end + +---Stops the Modemcard Manager. +---@param timeout number? Timeout in seconds +---@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() + + local source = fibers.perform(op.named_choice { + join = ModemcardManager.scope:join_op(), + timeout = sleep.sleep_op(timeout) + }) + + if source == "timeout" then + return false, "modemcard manager stop timeout" + end + ModemcardManager.started = false + return true, "" +end + +---Apply configuration for modemcard manager (no-op, kept for interface consistency). +---@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 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, } diff --git a/src/services/hal/types/modem.lua b/src/services/hal/types/modem.lua index 8f2c6c3f..30038d35 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,9 +141,81 @@ 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 +---@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, ModemStateEvent = ModemStateEvent, + ModemMonitorEvent = ModemMonitorEvent, new = new, } 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()