Browse Source

Merge branch '0wQ:main' into main

机丸 1 year ago
parent
commit
d0cd104e43

+ 4 - 13
README.md

@@ -20,23 +20,14 @@
     - [x] 发短信, 格式: `SMS,10010,余额查询`
 - [x] 定时基站定位
 - [x] 定时查询流量
+- [x] 定时上报存活
 - [x] 开机通知
 - [x] POW 按键长按短按操作
-- [x] 低功耗模式 (使用 IoT Power 测量, 开发板待机 30min 平均电流 2.5mA)
-- [x] 使用消息队列, 经测试同时发送几百条通知, 不会卡死
-- [x] 通知发送失败, 自动重发
+- [x] 使用消息队列, 测试添加几百条通知, 不会卡死
+- [x] 通知发送失败, 自动重发, 断电后再次开机可以恢复重发
 - [x] 支持主从模式,一主对多从,从机通过串口转发消息,主机接受消息后转发到通知服务
 
 ## :hammer: Usage
 
-> 可以参考 [lageev](https://github.com/lageev/air780e-forwarder) 写的教程 https://kdocs.cn/l/coe1ozIlSX70
+https://mizore.notion.site/Air780E-e750efe0d6cc40c3baa276eeb811d534
 
-### 1. 按注释修改 `script/config.lua` 配置文件
-
-### 2. 烧录脚本
-
-> 固件下载 https://gitee.com/openLuat/LuatOS/releases 选择带有 EC618 或 Air780 关键字的固件
->
-> `core` 目录下文件名中带有 `RNDIS` 的, 支持 RNDIS 网卡功能, 如果 SIM 卡流量不多请勿选择
-
-根据 [air780e.cn](http://air780e.cn) 官方指引下载 LuaTools 并写入 `script` 目录下文件

BIN
core/LuatOS-SoC_V0002_EC718PV.soc


BIN
core/LuatOS-SoC_V1002_EC718PV.soc


BIN
core/LuatOS-SoC_V1108_EC618.soc


BIN
core/LuatOS-SoC_V1109_EC618.soc


+ 14 - 5
script/config.lua

@@ -1,6 +1,6 @@
 return {
     -- 通知类型, 支持配置多个
-    -- NOTIFY_TYPE = {"custom_post", "telegram", "pushdeer", "bark", "dingtalk", "feishu", "wecom", "pushover", "inotify", "next-smtp-proxy", "gotify", "serial"},
+    -- NOTIFY_TYPE = { "custom_post", "telegram", "pushdeer", "bark", "dingtalk", "feishu", "wecom", "pushover", "inotify", "next-smtp-proxy", "gotify", "serial" },
     NOTIFY_TYPE = "custom_post",
     --
     -- 角色类型, 用于区分主从机, 仅当使用串口转发 NOTIFY_TYPE = "serial" 时才需要配置
@@ -10,7 +10,7 @@ return {
     -- custom_post 通知配置, 自定义 POST 请求, CUSTOM_POST_BODY_TABLE 中的 {msg} 会被替换为通知内容
     CUSTOM_POST_URL = "https://sctapi.ftqq.com/<SENDKEY>.send",
     CUSTOM_POST_CONTENT_TYPE = "application/json",
-    CUSTOM_POST_BODY_TABLE = {["title"] = "这里是标题", ["desp"] = "这里是内容, 会被替换掉:\n{msg}\n{msg}"},
+    CUSTOM_POST_BODY_TABLE = { ["title"] = "这里是标题", ["desp"] = "这里是内容, 会被替换掉:\n{msg}\n{msg}" },
     --
     -- telegram 通知配置, https://github.com/0wQ/telegram-notify 或者自行反代
     TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage",
@@ -74,6 +74,15 @@ return {
     -- 定时基站定位间隔, 单位毫秒, 设置为 0 关闭 (定位成功后会追加到通知内容后面, 基站定位本身会消耗流量, 通知内容增加也会导致流量消耗增加)
     LOCATION_INTERVAL = 0,
     --
+    -- 定时开关飞行模式间隔, 单位毫秒, 设置为 0 关闭
+    FLYMODE_INTERVAL = 1000 * 60 * 60 * 12,
+    --
+    -- 定时同步时间间隔, 单位毫秒, 设置为 0 关闭
+    SNTP_INTERVAL = 1000 * 60 * 60 * 6,
+    --
+    -- 定时上报间隔, 单位毫秒, 设置为 0 关闭 (定时触发消息上报)
+    REPORT_INTERVAL = 0,
+    --
     -- 开机通知 (会消耗流量)
     BOOT_NOTIFY = true,
     --
@@ -83,9 +92,9 @@ return {
     -- 通知最大重发次数
     NOTIFY_RETRY_MAX = 20,
     --
-    -- 开启低功耗模式, USB 断开连接无法查看日志, RNDIS 网卡会断开
-    LOW_POWER_MODE = false,
-    --
     -- 本机号码, 优先使用 mobile.number() 接口获取, 如果获取不到则使用此号码
     FALLBACK_LOCAL_NUMBER = "+8618888888888",
+    --
+    -- SIM 卡 pin 码
+    PIN_CODE = "",
 }

+ 72 - 44
script/main.lua

@@ -19,38 +19,47 @@ sys.timerLoopStart(wdt.feed, 3000)
 socket.setDNS(nil, 1, "119.29.29.29")
 socket.setDNS(nil, 2, "223.5.5.5")
 
--- 设置 SIM 自动恢复(单位: 毫秒), 搜索小区信息间隔(单位: 毫秒), 最大搜索时间(单位: 秒)
-mobile.setAuto(1000 * 10)
+-- SIM 自动恢复, 周期性获取小区信息, 网络遇到严重故障时尝试自动恢复等功能
+mobile.setAuto(10000, 300000, 8, true, 120000)
 
 -- 开启 IPv6
-mobile.ipv6(true)
+-- mobile.ipv6(true)
+
+-- 初始化 fskv
+log.info("main", "fskv.init", fskv.init())
 
 -- POWERKEY
-local button_last_press_time, button_last_release_time = 0, 0
-gpio.setup(35, function()
-    local current_time = mcu.ticks()
-    -- 按下
-    if gpio.get(35) == 0 then
-        button_last_press_time = current_time -- 记录最后一次按下时间
-        return
-    end
-    -- 释放
-    if button_last_press_time == 0 then -- 开机前已经按下, 开机后释放
-        return
-    end
-    if current_time - button_last_release_time < 250 then -- 防止连按
-        return
-    end
-    local duration = current_time - button_last_press_time -- 按键持续时间
-    button_last_release_time = current_time -- 记录最后一次释放时间
-    if duration > 2000 then
-        log.debug("EVENT.POWERKEY_LONG_PRESS", duration)
-        sys.publish("POWERKEY_LONG_PRESS", duration)
-    elseif duration > 50 then
-        log.debug("EVENT.POWERKEY_SHORT_PRESS", duration)
-        sys.publish("POWERKEY_SHORT_PRESS", duration)
-    end
-end, gpio.PULLUP)
+local rtos_bsp = rtos.bsp()
+local pin_table = { ["EC618"] = 35, ["EC718P"] = 46 }
+local powerkey_pin = pin_table[rtos_bsp]
+
+if powerkey_pin then
+    local button_last_press_time, button_last_release_time = 0, 0
+    gpio.setup(powerkey_pin, function()
+        local current_time = mcu.ticks()
+        -- 按下
+        if gpio.get(powerkey_pin) == 0 then
+            button_last_press_time = current_time -- 记录最后一次按下时间
+            return
+        end
+        -- 释放
+        if button_last_press_time == 0 then -- 开机前已经按下, 开机后释放
+            return
+        end
+        if current_time - button_last_release_time < 250 then -- 防止连按
+            return
+        end
+        local duration = current_time - button_last_press_time -- 按键持续时间
+        button_last_release_time = current_time -- 记录最后一次释放时间
+        if duration > 2000 then
+            log.debug("EVENT.POWERKEY_LONG_PRESS", duration)
+            sys.publish("POWERKEY_LONG_PRESS", duration)
+        elseif duration > 50 then
+            log.debug("EVENT.POWERKEY_SHORT_PRESS", duration)
+            sys.publish("POWERKEY_SHORT_PRESS", duration)
+        end
+    end, gpio.PULLUP)
+end
 
 -- 加载模块
 config = require "config"
@@ -105,44 +114,63 @@ end)
 
 sys.taskInit(function()
     -- 等待网络环境准备就绪
-    sys.waitUntil("IP_READY", 20000)
+    sys.waitUntil("IP_READY", 1000 * 60 * 5)
 
     util_netled.init()
 
     -- 开机通知
-    if config.BOOT_NOTIFY then sys.timerStart(util_notify.add, 1000 * 5, "#BOOT") end
+    if config.BOOT_NOTIFY then
+        sys.timerStart(util_notify.add, 1000 * 5, "#BOOT_" .. pm.lastReson())
+    end
+
+    -- 定时同步时间
+    if os.time() < 1714500000 then
+        socket.sntp()
+    end
+    if type(config.SNTP_INTERVAL) == "number" and config.SNTP_INTERVAL >= 1000 * 60 then
+        sys.timerLoopStart(socket.sntp, config.SNTP_INTERVAL)
+    end
 
     -- 定时查询流量
-    if config.QUERY_TRAFFIC_INTERVAL and config.QUERY_TRAFFIC_INTERVAL >= 1000 * 60 then
+    if type(config.QUERY_TRAFFIC_INTERVAL) == "number" and config.QUERY_TRAFFIC_INTERVAL >= 1000 * 60 then
         sys.timerLoopStart(util_mobile.queryTraffic, config.QUERY_TRAFFIC_INTERVAL)
     end
 
     -- 定时基站定位
-    if config.LOCATION_INTERVAL and config.LOCATION_INTERVAL >= 1000 * 30 then
+    if type(config.LOCATION_INTERVAL) == "number" and config.LOCATION_INTERVAL >= 1000 * 60 then
         util_location.refresh(nil, true)
         sys.timerLoopStart(util_location.refresh, config.LOCATION_INTERVAL)
     end
 
+    -- 定时上报
+    if type(config.REPORT_INTERVAL) == "number" and config.REPORT_INTERVAL >= 1000 * 60 then
+        sys.timerLoopStart(function() util_notify.add("#ALIVE_REPORT") end, config.REPORT_INTERVAL)
+    end
+
     -- 电源键短按发送测试通知
     sys.subscribe("POWERKEY_SHORT_PRESS", function() util_notify.add("#ALIVE") end)
     -- 电源键长按查询流量
     sys.subscribe("POWERKEY_LONG_PRESS", util_mobile.queryTraffic)
+end)
 
-    -- 开启低功耗模式
-    if config.LOW_POWER_MODE then
-        sys.wait(1000 * 15)
-        log.warn("main", "即将关闭 usb 电源, 如需查看日志请在配置中关闭低功耗模式")
-        sys.wait(1000 * 5)
-        gpio.setup(23, nil)
-        gpio.close(33)
-        pm.power(pm.USB, false) -- 关闭 USB
-        pm.power(pm.GPS, false)
-        pm.power(pm.GPS_ANT, false)
-        pm.power(pm.DAC_EN, false)
-        pm.force(pm.LIGHT) -- 进入休眠
+sys.taskInit(function()
+    if type(config.PIN_CODE) ~= "string" or config.PIN_CODE == "" then
+        return
+    end
+    -- 开机等待 5 秒仍未联网, 再进行 pin 验证
+    if not sys.waitUntil("IP_READY", 1000 * 5) then
+        util_mobile.pinVerify(config.PIN_CODE)
     end
 end)
 
+-- 定时开关飞行模式
+if type(config.FLYMODE_INTERVAL) == "number" and config.FLYMODE_INTERVAL >= 1000 * 60 then
+    sys.timerLoopStart(function()
+        mobile.flymode(0, true)
+        mobile.flymode(0, false)
+    end, config.FLYMODE_INTERVAL)
+end
+
 -- 通话相关
 local is_calling = false
 

+ 18 - 5
script/util_http.lua

@@ -5,6 +5,19 @@ local http_count = 0
 -- 记录正在运行的 http 请求数量
 local http_running_count = 0
 
+local luat_http_code_desc = {
+    [0] = "HTTP_OK",
+    [-1] = "HTTP_ERROR_STATE",
+    [-2] = "HTTP_ERROR_HEADER",
+    [-3] = "HTTP_ERROR_BODY",
+    [-4] = "HTTP_ERROR_CONNECT",
+    [-5] = "HTTP_ERROR_CLOSE",
+    [-6] = "HTTP_ERROR_RX",
+    [-7] = "HTTP_ERROR_DOWNLOAD",
+    [-8] = "HTTP_ERROR_TIMEOUT",
+    [-9] = "HTTP_ERROR_FOTA",
+}
+
 --- 对 http.request 的封装
 -- @param timeout 超时时间(单位: 毫秒)
 -- @param method 请求方法
@@ -14,7 +27,7 @@ local http_running_count = 0
 function util_http.fetch(timeout, method, url, headers, body)
     collectgarbage("collect")
 
-    timeout = timeout or 1000 * 25
+    timeout = timeout or 1000 * 20
     local opts = { timeout = timeout }
 
     http_count = http_count + 1
@@ -27,12 +40,12 @@ function util_http.fetch(timeout, method, url, headers, body)
 
     log.debug("util_http.fetch", "开始请求", "id:", id)
     res_code, res_headers, res_body = http.request(method, url, headers, body, opts).wait()
-    log.debug("util_http.fetch", "请求结束", "id:", id, "code:", res_code)
-
-    if res_code == -8 then log.warn("util_http.fetch", "请求超时", "id:", id) end
+    log.debug("util_http.fetch", "请求结束", "id:", id, "code:", res_code, "desc:", luat_http_code_desc[res_code])
 
     http_running_count = http_running_count - 1
-    if http_running_count == 0 then util_netled.blink() end
+    if http_running_count == 0 then
+        util_netled.blink()
+    end
 
     collectgarbage("collect")
 

+ 5 - 3
script/util_location.lua

@@ -28,9 +28,11 @@ end
 
 --- lbsLoc.request 回调
 local function getLocCb(result, lat, lng, addr, time, locType)
-    log.info("util_location.getLocCb", "result,lat,lng,time,locType:", result, lat, lng, time:toHex(), locType)
+    log.info("util_location.getLocCb", "result,lat,lng,time,locType:", result, lat, lng, time and time:toHex(), locType)
     -- 获取经纬度成功, 坐标系WGS84
-    if result == 0 then cache.lbs_data = { lat, lng } end
+    if result == 0 and lat and lng then
+        cache.lbs_data = { lat, lng }
+    end
 end
 
 --- 刷新基站信息
@@ -55,7 +57,7 @@ function util_location.refresh(timeout)
 
     sys.taskInit(function()
         refreshCellInfo()
-        lbsLoc.request(getLocCb, nil, timeout, nil, "bs.air32.cn")
+        lbsLoc.request(getLocCb, nil, timeout)
     end)
 end
 

+ 132 - 0
script/util_mobile.lua

@@ -1,19 +1,43 @@
 local util_mobile = {}
 
+--- 验证 pin 码
+-- @param pin_code string, pin 码
+function util_mobile.pinVerify(pin_code)
+    local sim_id = mobile.simid()
+
+    pin_code = tostring(pin_code or "")
+    if #pin_code < 4 or #pin_code > 8 then
+        log.warn("util_mobile.pinVerify", "pin 码长度不正确")
+        return
+    end
+
+    local cpin_is_ready = mobile.simPin(sim_id)
+    if cpin_is_ready then
+        log.info("util_mobile.pinVerify", "无需验证 pin 码")
+        return
+    end
+
+    cpin_is_ready = mobile.simPin(sim_id, mobile.PIN_VERIFY, pin_code)
+    log.info("util_mobile.pinVerify", "验证 pin 码" .. (cpin_is_ready and "成功" or "失败"))
+end
+
 -- 运营商数据
 local oper_data = {
     -- 中国移动
     ["46000"] = { "CM", "中国移动", { "10086", "CXLL" } },
     ["46002"] = { "CM", "中国移动", { "10086", "CXLL" } },
     ["46007"] = { "CM", "中国移动", { "10086", "CXLL" } },
+    ["46008"] = { "CM", "中国移动", { "10086", "CXLL" } },
     -- 中国联通
     ["46001"] = { "CU", "中国联通", { "10010", "2082" } },
     ["46006"] = { "CU", "中国联通", { "10010", "2082" } },
     ["46009"] = { "CU", "中国联通", { "10010", "2082" } },
+    ["46010"] = { "CU", "中国联通", { "10010", "2082" } },
     -- 中国电信
     ["46003"] = { "CT", "中国电信", { "10001", "108" } },
     ["46005"] = { "CT", "中国电信", { "10001", "108" } },
     ["46011"] = { "CT", "中国电信", { "10001", "108" } },
+    ["46012"] = { "CT", "中国电信", { "10001", "108" } },
     -- 中国广电
     ["46015"] = { "CB", "中国广电" },
 }
@@ -62,4 +86,112 @@ function util_mobile.queryTraffic()
     end
 end
 
+--- 获取网络状态
+-- @return 网络状态
+function util_mobile.status()
+    local codes = {
+        [0] = "网络未注册",
+        [1] = "网络已注册",
+        [2] = "网络搜索中",
+        [3] = "网络注册被拒绝",
+        [4] = "网络状态未知",
+        [5] = "网络已注册,漫游",
+        [6] = "网络已注册,仅SMS",
+        [7] = "网络已注册,漫游,仅SMS",
+        [8] = "网络已注册,紧急服务",
+        [9] = "网络已注册,非主要服务",
+        [10] = "网络已注册,非主要服务,漫游",
+    }
+    local mobile_status = mobile.status()
+    if mobile_status and mobile_status >= 0 and mobile_status <= 10 then
+        return codes[mobile_status] or "未知网络状态"
+    end
+    return "未知网络状态"
+end
+
+--- 追加设备信息
+--- @return string
+function util_mobile.appendDeviceInfo()
+    local msg = "\n"
+
+    -- 本机号码
+    local number = mobile.number(mobile.simid()) or config.FALLBACK_LOCAL_NUMBER
+    if number then
+        msg = msg .. "\n本机号码: " .. number
+    end
+
+    -- 开机时长
+    local ms = mcu.ticks()
+    local seconds = math.floor(ms / 1000)
+    local minutes = math.floor(seconds / 60)
+    local hours = math.floor(minutes / 60)
+    seconds = seconds % 60
+    minutes = minutes % 60
+    local boot_time = string.format("%02d:%02d:%02d", hours, minutes, seconds)
+    if ms >= 0 then
+        msg = msg .. "\n开机时长: " .. boot_time
+    end
+
+    -- 运营商
+    local oper = util_mobile.getOper(true)
+    if oper ~= "" then
+        msg = msg .. "\n运营商: " .. oper
+    end
+
+    -- 信号
+    msg = msg .. "\n信号: " .. mobile.rsrp() .. "dBm"
+
+    -- 频段
+    -- local band = util_mobile.getBand()
+    -- if band >= 0 then
+    --     msg = msg .. "\n频段: B" .. band
+    -- end
+
+    -- 电压, 读取 VBAT 供电电压, 单位为 mV
+    -- adc.open(adc.CH_VBAT)
+    -- local vbat = adc.get(adc.CH_VBAT)
+    -- adc.close(adc.CH_VBAT)
+    -- if vbat >= 0 then
+    --     msg = msg .. "\n电压: " .. string.format("%.1f", vbat / 1000) .. "V"
+    -- end
+
+    -- 温度
+    -- adc.open(adc.CH_CPU)
+    -- local temp = adc.get(adc.CH_CPU)
+    -- adc.close(adc.CH_CPU)
+    -- if temp >= 0 then
+    --     msg = msg .. "\n温度: " .. string.format("%.1f", temp / 1000) .. "°C"
+    -- end
+
+    -- 基站信息
+    -- msg = msg .. "\nECI: " .. mobile.eci()
+    -- msg = msg .. "\nTAC: " .. mobile.tac()
+    -- msg = msg .. "\nENBID: " .. mobile.enbid()
+
+    -- 流量统计
+    -- local uplinkGB, uplinkB, downlinkGB, downlinkB = mobile.dataTraffic()
+    -- uplinkB = uplinkGB * 1024 * 1024 * 1024 + uplinkB
+    -- downlinkB = downlinkGB * 1024 * 1024 * 1024 + downlinkB
+    -- local function formatBytes(bytes)
+    --     if bytes < 1024 then
+    --         return bytes .. "B"
+    --     elseif bytes < 1024 * 1024 then
+    --         return string.format("%.2fKB", bytes / 1024)
+    --     elseif bytes < 1024 * 1024 * 1024 then
+    --         return string.format("%.2fMB", bytes / 1024 / 1024)
+    --     else
+    --         return string.format("%.2fGB", bytes / 1024 / 1024 / 1024)
+    --     end
+    -- end
+    -- msg = msg .. "\n流量: ↑" .. formatBytes(uplinkB) .. " ↓" .. formatBytes(downlinkB)
+
+    -- 位置
+    local _, _, map_link = util_location.get()
+    if map_link ~= "" then
+        msg = msg .. "\n位置: " .. map_link -- 这里使用 U+00a0 防止换行
+    end
+
+    return msg
+end
+
 return util_mobile

+ 120 - 409
script/util_notify.lua

@@ -1,386 +1,39 @@
-local lib_smtp = require "lib_smtp"
+local util_notify_channel = require "util_notify_channel"
 
 local util_notify = {}
 
 -- 消息队列
 local msg_queue = {}
-
-local function urlencodeTab(params)
-    local msg = {}
-    for k, v in pairs(params) do
-        table.insert(msg, string.urlEncode(k) .. "=" .. string.urlEncode(v))
-        table.insert(msg, "&")
-    end
-    table.remove(msg)
-    return table.concat(msg)
-end
-
-local notify = {
-    -- 发送到 custom_post
-    ["custom_post"] = function(msg)
-        if config.CUSTOM_POST_URL == nil or config.CUSTOM_POST_URL == "" then
-            log.error("util_notify", "未配置 `config.CUSTOM_POST_URL`")
-            return
-        end
-        if type(config.CUSTOM_POST_BODY_TABLE) ~= "table" then
-            log.error("util_notify", "未配置 `config.CUSTOM_POST_BODY_TABLE`")
-            return
-        end
-
-        local header = { ["content-type"] = config.CUSTOM_POST_CONTENT_TYPE }
-
-        local body = json.decode(json.encode(config.CUSTOM_POST_BODY_TABLE))
-        -- 遍历并替换其中的变量
-        local function traverse_and_replace(t)
-            for k, v in pairs(t) do
-                if type(v) == "table" then
-                    traverse_and_replace(v)
-                elseif type(v) == "string" then
-                    t[k] = string.gsub(v, "{msg}", msg)
-                end
-            end
-        end
-        traverse_and_replace(body)
-
-        -- 根据 content-type 进行编码, 默认为 application/x-www-form-urlencoded
-        if string.find(config.CUSTOM_POST_CONTENT_TYPE, "json") then
-            body = json.encode(body)
-            -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-            body = string.gsub(body, "\\b", "\\n")
-        else
-            body = urlencodeTab(body)
-        end
-
-        log.info("util_notify", "POST", config.CUSTOM_POST_URL, config.CUSTOM_POST_CONTENT_TYPE, body)
-        return util_http.fetch(nil, "POST", config.CUSTOM_POST_URL, header, body)
-    end,
-    -- 发送到 telegram
-    ["telegram"] = function(msg)
-        if config.TELEGRAM_API == nil or config.TELEGRAM_API == "" then
-            log.error("util_notify", "未配置 `config.TELEGRAM_API`")
-            return
-        end
-        if config.TELEGRAM_CHAT_ID == nil or config.TELEGRAM_CHAT_ID == "" then
-            log.error("util_notify", "未配置 `config.TELEGRAM_CHAT_ID`")
-            return
-        end
-
-        local header = { ["content-type"] = "application/json" }
-        local body = { ["chat_id"] = config.TELEGRAM_CHAT_ID, ["disable_web_page_preview"] = true, ["text"] = msg }
-        local json_data = json.encode(body)
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.TELEGRAM_API)
-        return util_http.fetch(nil, "POST", config.TELEGRAM_API, header, json_data)
-    end,
-    -- 发送到 gotify
-    ["gotify"] = function(msg)
-        if config.GOTIFY_API == nil or config.GOTIFY_API == "" then
-            log.error("util_notify", "未配置 `config.GOTIFY_API`")
-            return
-        end
-        if config.GOTIFY_TOKEN == nil or config.GOTIFY_TOKEN == "" then
-            log.error("util_notify", "未配置 `config.GOTIFY_TOKEN`")
-            return
-        end
-
-        local url = config.GOTIFY_API .. "/message?token=" .. config.GOTIFY_TOKEN
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { title = config.GOTIFY_TITLE, message = msg, priority = config.GOTIFY_PRIORITY }
-        local json_data = json.encode(body)
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.GOTIFY_API)
-        return util_http.fetch(nil, "POST", url, header, json_data)
-    end,
-    -- 发送到 pushdeer
-    ["pushdeer"] = function(msg)
-        if config.PUSHDEER_API == nil or config.PUSHDEER_API == "" then
-            log.error("util_notify", "未配置 `config.PUSHDEER_API`")
-            return
-        end
-        if config.PUSHDEER_KEY == nil or config.PUSHDEER_KEY == "" then
-            log.error("util_notify", "未配置 `config.PUSHDEER_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = { pushkey = config.PUSHDEER_KEY or "", type = "text", text = msg }
-
-        log.info("util_notify", "POST", config.PUSHDEER_API)
-        return util_http.fetch(nil, "POST", config.PUSHDEER_API, header, urlencodeTab(body))
-    end,
-    -- 发送到 bark
-    ["bark"] = function(msg)
-        if config.BARK_API == nil or config.BARK_API == "" then
-            log.error("util_notify", "未配置 `config.BARK_API`")
-            return
-        end
-        if config.BARK_KEY == nil or config.BARK_KEY == "" then
-            log.error("util_notify", "未配置 `config.BARK_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = { body = msg }
-        local url = config.BARK_API .. "/" .. config.BARK_KEY
-
-        log.info("util_notify", "POST", url)
-        return util_http.fetch(nil, "POST", url, header, urlencodeTab(body))
-    end,
-    -- 发送到 dingtalk
-    ["dingtalk"] = function(msg)
-        if config.DINGTALK_WEBHOOK == nil or config.DINGTALK_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.DINGTALK_WEBHOOK`")
-            return
-        end
-
-        local url = config.DINGTALK_WEBHOOK
-        -- 如果配置了 config.DINGTALK_SECRET 则需要签名(加签), 没配置则为自定义关键词
-        if (config.DINGTALK_SECRET ~= nil and config.DINGTALK_SECRET ~= "") then
-            local timestamp = tostring(os.time()) .. "000"
-            local sign = crypto.hmac_sha256(timestamp .. "\n" .. config.DINGTALK_SECRET, config.DINGTALK_SECRET):fromHex():toBase64():urlEncode()
-            url = url .. "&timestamp=" .. timestamp .. "&sign=" .. sign
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msgtype = "text", text = { content = msg } }
-        body = json.encode(body)
-
-        log.info("util_notify", "POST", url)
-        return util_http.fetch(nil, "POST", url, header, body)
-    end,
-    -- 发送到 feishu
-    ["feishu"] = function(msg)
-        if config.FEISHU_WEBHOOK == nil or config.FEISHU_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.FEISHU_WEBHOOK`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msg_type = "text", content = { text = msg } }
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.FEISHU_WEBHOOK)
-        return util_http.fetch(nil, "POST", config.FEISHU_WEBHOOK, header, json_data)
-    end,
-    -- 发送到 wecom
-    ["wecom"] = function(msg)
-        if config.WECOM_WEBHOOK == nil or config.WECOM_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.WECOM_WEBHOOK`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msgtype = "text", text = { content = msg } }
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.WECOM_WEBHOOK)
-        return util_http.fetch(nil, "POST", config.WECOM_WEBHOOK, header, json_data)
-    end,
-    -- 发送到 pushover
-    ["pushover"] = function(msg)
-        if config.PUSHOVER_API_TOKEN == nil or config.PUSHOVER_API_TOKEN == "" then
-            log.error("util_notify", "未配置 `config.PUSHOVER_API_TOKEN`")
-            return
-        end
-        if config.PUSHOVER_USER_KEY == nil or config.PUSHOVER_USER_KEY == "" then
-            log.error("util_notify", "未配置 `config.PUSHOVER_USER_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { token = config.PUSHOVER_API_TOKEN, user = config.PUSHOVER_USER_KEY, message = msg }
-
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        local url = "https://api.pushover.net/1/messages.json"
-
-        log.info("util_notify", "POST", url)
-        return util_http.fetch(nil, "POST", url, header, json_data)
-    end,
-    -- 发送到 inotify
-    ["inotify"] = function(msg)
-        if config.INOTIFY_API == nil or config.INOTIFY_API == "" then
-            log.error("util_notify", "未配置 `config.INOTIFY_API`")
-            return
-        end
-        if not config.INOTIFY_API:endsWith(".send") then
-            log.error("util_notify", "`config.INOTIFY_API` 必须以 `.send` 结尾")
-            return
-        end
-
-        local url = config.INOTIFY_API .. "/" .. string.urlEncode(msg)
-
-        log.info("util_notify", "GET", url)
-        return util_http.fetch(nil, "GET", url)
-    end,
-    -- 发送到 next-smtp-proxy
-    ["next-smtp-proxy"] = function(msg)
-        if config.NEXT_SMTP_PROXY_API == nil or config.NEXT_SMTP_PROXY_API == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_API`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_USER == nil or config.NEXT_SMTP_PROXY_USER == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_USER`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_PASSWORD == nil or config.NEXT_SMTP_PROXY_PASSWORD == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PASSWORD`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_HOST == nil or config.NEXT_SMTP_PROXY_HOST == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_HOST`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_PORT == nil or config.NEXT_SMTP_PROXY_PORT == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PORT`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_TO_EMAIL == nil or config.NEXT_SMTP_PROXY_TO_EMAIL == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_TO_EMAIL`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = {
-            user = config.NEXT_SMTP_PROXY_USER,
-            password = config.NEXT_SMTP_PROXY_PASSWORD,
-            host = config.NEXT_SMTP_PROXY_HOST,
-            port = config.NEXT_SMTP_PROXY_PORT,
-            form_name = config.NEXT_SMTP_PROXY_FORM_NAME,
-            to_email = config.NEXT_SMTP_PROXY_TO_EMAIL,
-            subject = config.NEXT_SMTP_PROXY_SUBJECT,
-            text = msg,
-        }
-
-        log.info("util_notify", "POST", config.NEXT_SMTP_PROXY_API)
-        return util_http.fetch(nil, "POST", config.NEXT_SMTP_PROXY_API, header, urlencodeTab(body))
-    end,
-    ["smtp"] = function(msg)
-        local smtp_config = {
-            host = config.SMTP_HOST,
-            port = config.SMTP_PORT,
-            username = config.SMTP_USERNAME,
-            password = config.SMTP_PASSWORD,
-            mail_from = config.SMTP_MAIL_FROM,
-            mail_to = config.SMTP_MAIL_TO,
-            tls_enable = config.SMTP_TLS_ENABLE,
-        }
-        local result = lib_smtp.send(msg, config.SMTP_MAIL_SUBJECT, smtp_config)
-        log.info("util_notify", "SMTP", result.success, result.message, result.is_retry)
-        if result.success then return 200, nil, result.message end
-        if result.is_retry then return 500, nil, result.message end
-        return 400, nil, result.message
-    end,
-    -- 发送到 serial
-    ["serial"] = function(msg)
-        uart.write(1, msg)
-        log.info("util_notify", "serial", "消息已转发到串口")
-        sys.wait(1000)
-        return 200
-    end,
-}
-
-local function append()
-    local msg = "\n"
-
-    -- 本机号码
-    local number = mobile.number(mobile.simid()) or config.FALLBACK_LOCAL_NUMBER
-    if number then msg = msg .. "\n本机号码: " .. number end
-
-    -- 开机时长
-    local ms = mcu.ticks()
-    local seconds = math.floor(ms / 1000)
-    local minutes = math.floor(seconds / 60)
-    local hours = math.floor(minutes / 60)
-    seconds = seconds % 60
-    minutes = minutes % 60
-    local boot_time = string.format("%02d:%02d:%02d", hours, minutes, seconds)
-    if ms >= 0 then msg = msg .. "\n开机时长: " .. boot_time end
-
-    -- 运营商
-    local oper = util_mobile.getOper(true)
-    if oper ~= "" then msg = msg .. "\n运营商: " .. oper end
-
-    -- 信号
-    local rsrp = mobile.rsrp()
-    if rsrp ~= 0 then msg = msg .. "\n信号: " .. rsrp .. "dBm" end
-
-    -- 频段
-    local band = util_mobile.getBand()
-    if band >= 0 then msg = msg .. "\n频段: B" .. band end
-
-    -- 流量统计
-    -- local uplinkGB, uplinkB, downlinkGB, downlinkB = mobile.dataTraffic()
-    -- uplinkB = uplinkGB * 1024 * 1024 * 1024 + uplinkB
-    -- downlinkB = downlinkGB * 1024 * 1024 * 1024 + downlinkB
-    -- local function formatBytes(bytes)
-    --     if bytes < 1024 then
-    --         return bytes .. "B"
-    --     elseif bytes < 1024 * 1024 then
-    --         return string.format("%.2fKB", bytes / 1024)
-    --     elseif bytes < 1024 * 1024 * 1024 then
-    --         return string.format("%.2fMB", bytes / 1024 / 1024)
-    --     else
-    --         return string.format("%.2fGB", bytes / 1024 / 1024 / 1024)
-    --     end
-    -- end
-    -- msg = msg .. "\n流量: ↑" .. formatBytes(uplinkB) .. " ↓" .. formatBytes(downlinkB)
-
-    -- 位置
-    local _, _, map_link = util_location.get()
-    if map_link ~= "" then
-        msg = msg .. "\n位置: " .. map_link -- 这里使用 U+00a0 防止换行
-    end
-
-    return msg
-end
+-- 发送计数
+local msg_count = 0
+local error_count = 0
 
 --- 发送通知
 -- @param msg 消息内容
 -- @param channel 通知渠道
 -- @return true: 无需重发, false: 需要重发
-function util_notify.send(msg, channel)
+local function send(msg, channel)
     log.info("util_notify.send", "发送通知", channel)
 
     -- 判断消息内容 msg
-    if type(msg) ~= "string" then
-        log.error("util_notify.send", "发送通知失败", "参数类型错误", type(msg))
-        return true
-    end
-    if msg == "" then
-        log.error("util_notify.send", "发送通知失败", "消息为空")
+    if type(msg) ~= "string" or msg == "" then
+        log.error("util_notify.send", "发送通知失败", "msg 参数错误", type(msg))
         return true
     end
 
     -- 判断通知渠道 channel
-    if channel and notify[channel] == nil then
+    if channel and util_notify_channel[channel] == nil then
         log.error("util_notify.send", "发送通知失败", "未知通知渠道", channel)
         return true
     end
 
     -- 发送通知
-    local code, headers, body = notify[channel](msg)
+    local code, headers, body = util_notify_channel[channel](msg)
     if code == nil then
         log.info("util_notify.send", "发送通知失败, 无需重发", "code:", code, "body:", body)
         return true
     end
-    if code == -6 then
-        -- 发生在 url 过长时, 重发也不会成功
-        log.info("util_notify.send", "发送通知失败, 无需重发", "code:", code, "body:", body)
-        return true
-    end
-    if code >= 200 and code < 500 then
-        -- http 2xx 成功
-        -- http 3xx 重定向, 重发也不会成功
-        -- http 4xx 客户端错误, 重发也不会成功
+    if code >= 200 and code < 500 and code ~= 408 and code ~= 409 and code ~= 425 and code ~= 429 then
         log.info("util_notify.send", "发送通知成功", "code:", code, "body:", body)
         return true
     end
@@ -391,73 +44,131 @@ end
 --- 添加到消息队列
 -- @param msg 消息内容
 -- @param channels 通知渠道
-function util_notify.add(msg, channels)
-    if type(msg) == "table" then msg = table.concat(msg, "\n") end
+-- @param id 消息唯一标识
+function util_notify.add(msg, channels, id)
+    msg_count = msg_count + 1
 
-    -- 通知内容追加更多信息, 若已经包含则不再追加
-    local is_append = true
-    if string.find(msg, "本机号码:") and string.find(msg, "开机时长:") then
-        log.info("util_notify.send", "不追加更多信息")
-        is_append = false
+    if id == nil or id == "" then
+        id = "msg-t" .. os.time() .. "c" .. msg_count .. "r" .. math.random(9999)
     end
-    if config.NOTIFY_APPEND_MORE_INFO and is_append then msg = msg .. append() end
 
-    channels = channels or config.NOTIFY_TYPE
+    if type(msg) == "table" then
+        msg = table.concat(msg, "\n")
+    end
 
-    if type(channels) ~= "table" then channels = { channels } end
+    channels = channels or config.NOTIFY_TYPE
+    if type(channels) ~= "table" then
+        channels = { channels }
+    end
 
-    for _, channel in ipairs(channels) do table.insert(msg_queue, { channel = channel, msg = msg, retry = 0 }) end
+    for _, channel in ipairs(channels) do
+        table.insert(msg_queue, { id = id, channel = channel, msg = msg, retry = 0 })
+    end
     sys.publish("NEW_MSG")
-    log.debug("util_notify.add", "添加到消息队列, 当前队列长度:", #msg_queue)
-    log.debug("util_notify.add", "添加到消息队列的内容:", msg:gsub("\r", "\\r"):gsub("\n", "\\n"))
+    log.debug("util_notify.add", "添加到消息队列, 当前队列长度:", #msg_queue, "消息内容:", msg:gsub("\r", "\\r"):gsub("\n", "\\n"))
 end
 
--- 轮询消息队列
--- 发送成功则从消息队列中删除
--- 发送失败则等待下次轮询
+--- 轮询消息队列, 发送成功则从队列中删除, 发送失败则等待下次
 local function poll()
-    local item, result
-    local codes = {
-        [0] = "网络未注册",
-        [1] = "网络已注册",
-        [2] = "网络搜索中",
-        [3] = "网络注册被拒绝",
-        [4] = "网络状态未知",
-        [5] = "网络已注册,漫游",
-        [6] = "网络已注册,仅SMS",
-        [7] = "网络已注册,漫游,仅SMS",
-        [8] = "网络已注册,紧急服务",
-        [9] = "网络已注册,非主要服务",
-        [10] = "网络已注册,非主要服务,漫游",
-    }
-    while true do
-        -- 消息队列非空, 且网络已注册
-        log.debug("mobile.status:", codes[mobile.status() or 0] or "未知网络状态")
-        if next(msg_queue) ~= nil and (mobile.status() == 1 or mobile.status() == 5) then
-            log.debug("util_notify.poll", "轮询消息队列中, 当前队列长度:", #msg_queue)
+    -- 打印网络状态
+    if mobile.status() ~= 1 then
+        log.warn("util_notify.poll", "mobile.status", mobile_status, util_mobile.status())
+    end
 
-            item = msg_queue[1]
-            table.remove(msg_queue, 1)
+    -- 消息队列非空
+    if next(msg_queue) == nil then
+        sys.waitUntil("NEW_MSG", 1000 * 10)
+        return
+    end
+
+    local item = msg_queue[1]
+    table.remove(msg_queue, 1)
+    local msg = item.msg
+    log.info("util_notify.poll", "轮询消息队列中", "总长度: " .. #msg_queue, "当前ID: " .. item.id, "当前重发次数: " .. item.retry, "连续失败次数: " .. error_count)
+
+    -- 通知内容添加设备信息
+    if config.NOTIFY_APPEND_MORE_INFO and not string.find(msg, "开机时长:") then
+        msg = msg .. util_mobile.appendDeviceInfo()
+    end
+    -- 通知内容添加重发次数
+    if error_count > 0 then
+        msg = msg .. "\n重发次数: " .. error_count
+    end
+
+    -- 超过最大重发次数
+    if item.retry > (config.NOTIFY_RETRY_MAX or 20) then
+        log.warn("util_notify.poll", "超过最大重发次数, 放弃重发", item.msg)
+        return
+    end
 
-            if item.retry > (config.NOTIFY_RETRY_MAX or 100) then
-                log.error("util_notify.poll", "超过最大重发次数", "msg:", item.msg)
-            else
-                result = util_notify.send(item.msg, item.channel)
-                item.retry = item.retry + 1
+    -- 开始发送
+    local result = send(msg, item.channel)
 
-                if not result then
-                    -- 发送失败, 移到队尾
-                    table.insert(msg_queue, item)
-                    sys.wait(5000)
-                end
-            end
-            sys.wait(50)
-        else
-            sys.waitUntil("NEW_MSG", 1000 * 10)
+    -- 发送成功
+    if result then
+        error_count = 0
+        -- 检查 fskv 中如果存在则删除
+        if fskv.get(item.id) then
+            fskv.del(item.id)
         end
+        return
+    end
+
+    -- 发送失败
+    error_count = error_count + 1
+    item.retry = item.retry + 1
+    table.insert(msg_queue, item)
+    log.info("util_notify.poll", "等待下次重发", "当前重发次数", item.retry, "连续失败次数", error_count)
+    sys.waitUntil("IP_READY", 1000 * 5)
+
+    -- 每连续失败 2 次, 开关飞行模式
+    if error_count % 2 == 0 then
+        -- 开关飞行模式
+        log.warn("util_notify.poll", "连续失败次数过多, 开关飞行模式")
+        mobile.flymode(0, true)
+        mobile.flymode(0, false)
+        sys.wait(1000)
+    end
+
+    -- 每条消息第 1 次重发失败后, 保存到 fskv, 断电开机可恢复重发
+    if item.retry == 1 then
+        if not (string.find(item.msg, "#SMS") or string.find(item.msg, "#CALL")) then
+            return
+        end
+        log.info("util_notify.poll", "当前第 1 次重发失败, 保存到 fskv", item.id)
+        if fskv.get(item.id) then
+            log.info("util_notify.poll", "fskv 已存在, 跳过写入", item.id)
+            return
+        end
+        local kv_set_result = fskv.set(item.id, item.msg)
+        log.info("util_notify.poll", "fskv.set", kv_set_result, "used,total,count:", fskv.status())
     end
 end
 
-sys.taskInit(poll)
+sys.taskInit(function()
+    while true do
+        poll()
+        sys.wait(100)
+    end
+end)
+
+sys.taskInit(function()
+    sys.waitUntil("IP_READY")
+    sys.wait(10000)
+
+    local iter = fskv.iter()
+    while iter do
+        local k = fskv.next(iter)
+        if not k then
+            break
+        end
+        local v = fskv.get(k)
+        if not (v and v ~= "" and string.find(k, "msg-")) then
+            break
+        end
+        log.info("util_notify", "检查到 fskv 中有历史消息", k, v:gsub("\r", "\\r"):gsub("\n", "\\n"))
+        util_notify.add(v, nil, k)
+    end
+end)
 
 return util_notify

+ 305 - 0
script/util_notify_channel.lua

@@ -0,0 +1,305 @@
+local lib_smtp = require "lib_smtp"
+
+local function urlencodeTab(params)
+    local msg = {}
+    for k, v in pairs(params) do
+        table.insert(msg, string.urlEncode(k) .. "=" .. string.urlEncode(v))
+        table.insert(msg, "&")
+    end
+    table.remove(msg)
+    return table.concat(msg)
+end
+
+return {
+    -- 发送到 custom_post
+    ["custom_post"] = function(msg)
+        if config.CUSTOM_POST_URL == nil or config.CUSTOM_POST_URL == "" then
+            log.error("util_notify", "未配置 `config.CUSTOM_POST_URL`")
+            return
+        end
+        if type(config.CUSTOM_POST_BODY_TABLE) ~= "table" then
+            log.error("util_notify", "未配置 `config.CUSTOM_POST_BODY_TABLE`")
+            return
+        end
+
+        local header = { ["content-type"] = config.CUSTOM_POST_CONTENT_TYPE }
+        local body = json.decode(json.encode(config.CUSTOM_POST_BODY_TABLE))
+        -- 遍历并替换其中的变量
+        local function traverse_and_replace(t)
+            for k, v in pairs(t) do
+                if type(v) == "table" then
+                    traverse_and_replace(v)
+                elseif type(v) == "string" then
+                    t[k] = string.gsub(v, "{msg}", msg)
+                end
+            end
+        end
+        traverse_and_replace(body)
+
+        -- 根据 content-type 进行编码, 默认为 application/x-www-form-urlencoded
+        if string.find(config.CUSTOM_POST_CONTENT_TYPE, "json") then
+            body = json.encode(body)
+        else
+            body = urlencodeTab(body)
+        end
+
+        log.info("util_notify", "POST", config.CUSTOM_POST_URL, config.CUSTOM_POST_CONTENT_TYPE, body)
+        return util_http.fetch(nil, "POST", config.CUSTOM_POST_URL, header, body)
+    end,
+    -- 发送到 telegram
+    ["telegram"] = function(msg)
+        if config.TELEGRAM_API == nil or config.TELEGRAM_API == "" then
+            log.error("util_notify", "未配置 `config.TELEGRAM_API`")
+            return
+        end
+        if config.TELEGRAM_CHAT_ID == nil or config.TELEGRAM_CHAT_ID == "" then
+            log.error("util_notify", "未配置 `config.TELEGRAM_CHAT_ID`")
+            return
+        end
+
+        local header = { ["content-type"] = "application/json" }
+        local body = { ["chat_id"] = config.TELEGRAM_CHAT_ID, ["disable_web_page_preview"] = true, ["text"] = msg }
+
+        log.info("util_notify", "POST", config.TELEGRAM_API)
+        return util_http.fetch(nil, "POST", config.TELEGRAM_API, header, json.encode(body))
+    end,
+    -- 发送到 gotify
+    ["gotify"] = function(msg)
+        if config.GOTIFY_API == nil or config.GOTIFY_API == "" then
+            log.error("util_notify", "未配置 `config.GOTIFY_API`")
+            return
+        end
+        if config.GOTIFY_TOKEN == nil or config.GOTIFY_TOKEN == "" then
+            log.error("util_notify", "未配置 `config.GOTIFY_TOKEN`")
+            return
+        end
+
+        local url = config.GOTIFY_API .. "/message?token=" .. config.GOTIFY_TOKEN
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { title = config.GOTIFY_TITLE, message = msg, priority = config.GOTIFY_PRIORITY }
+
+        log.info("util_notify", "POST", config.GOTIFY_API)
+        return util_http.fetch(nil, "POST", url, header, json.encode(body))
+    end,
+    -- 发送到 pushdeer
+    ["pushdeer"] = function(msg)
+        if config.PUSHDEER_API == nil or config.PUSHDEER_API == "" then
+            log.error("util_notify", "未配置 `config.PUSHDEER_API`")
+            return
+        end
+        if config.PUSHDEER_KEY == nil or config.PUSHDEER_KEY == "" then
+            log.error("util_notify", "未配置 `config.PUSHDEER_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = { pushkey = config.PUSHDEER_KEY or "", type = "text", text = msg }
+
+        log.info("util_notify", "POST", config.PUSHDEER_API)
+        return util_http.fetch(nil, "POST", config.PUSHDEER_API, header, urlencodeTab(body))
+    end,
+    -- 发送到 bark
+    ["bark"] = function(msg)
+        if config.BARK_API == nil or config.BARK_API == "" then
+            log.error("util_notify", "未配置 `config.BARK_API`")
+            return
+        end
+        if config.BARK_KEY == nil or config.BARK_KEY == "" then
+            log.error("util_notify", "未配置 `config.BARK_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = { body = msg }
+        local url = config.BARK_API .. "/" .. config.BARK_KEY
+
+        log.info("util_notify", "POST", url)
+        return util_http.fetch(nil, "POST", url, header, urlencodeTab(body))
+    end,
+    -- 发送到 dingtalk
+    ["dingtalk"] = function(msg)
+        if config.DINGTALK_WEBHOOK == nil or config.DINGTALK_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.DINGTALK_WEBHOOK`")
+            return
+        end
+
+        local url = config.DINGTALK_WEBHOOK
+        -- 如果配置了 config.DINGTALK_SECRET 则需要签名(加签), 没配置则为自定义关键词
+        if (config.DINGTALK_SECRET and config.DINGTALK_SECRET ~= "") then
+            -- 时间异常则等待同步
+            if os.time() < 1714500000 then
+                socket.sntp()
+                sys.waitUntil("NTP_UPDATE", 1000 * 10)
+            end
+            local timestamp = tostring(os.time()) .. "000"
+            local sign = crypto.hmac_sha256(timestamp .. "\n" .. config.DINGTALK_SECRET, config.DINGTALK_SECRET):fromHex():toBase64():urlEncode()
+            url = url .. "&timestamp=" .. timestamp .. "&sign=" .. sign
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msgtype = "text", text = { content = msg } }
+        body = json.encode(body)
+
+        log.info("util_notify", "POST", url)
+        local res_code, res_headers, res_body = util_http.fetch(nil, "POST", url, header, body)
+
+        -- 处理响应
+        -- https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages
+        if res_code == 200 and res_body and res_body ~= "" then
+            local res_data = json.decode(res_body)
+            local res_errcode = res_data.errcode or 0
+            local res_errmsg = res_data.errmsg or ""
+            -- 系统繁忙 / 发送速度太快而限流
+            if res_errcode == -1 or res_errcode == 410100 then
+                return 500, res_headers, res_body
+            end
+            -- timestamp 无效
+            if res_errcode == 310000 and (string.find(res_errmsg, "timestamp") or string.find(res_errmsg, "过期")) then
+                socket.sntp()
+                return 500, res_headers, res_body
+            end
+        end
+        return res_code, res_headers, res_body
+    end,
+    -- 发送到 feishu
+    ["feishu"] = function(msg)
+        if config.FEISHU_WEBHOOK == nil or config.FEISHU_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.FEISHU_WEBHOOK`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msg_type = "text", content = { text = msg } }
+
+        log.info("util_notify", "POST", config.FEISHU_WEBHOOK)
+        return util_http.fetch(nil, "POST", config.FEISHU_WEBHOOK, header, json.encode(body))
+    end,
+    -- 发送到 wecom
+    ["wecom"] = function(msg)
+        if config.WECOM_WEBHOOK == nil or config.WECOM_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.WECOM_WEBHOOK`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msgtype = "text", text = { content = msg } }
+
+        log.info("util_notify", "POST", config.WECOM_WEBHOOK)
+        local res_code, res_headers, res_body = util_http.fetch(nil, "POST", config.WECOM_WEBHOOK, header, json.encode(body))
+
+        -- 处理响应
+        -- https://developer.work.weixin.qq.com/document/path/90313
+        if res_code == 200 and res_body and res_body ~= "" then
+            local res_data = json.decode(res_body)
+            local res_errcode = res_data.errcode or 0
+            -- 系统繁忙 / 接口调用超过限制
+            if res_errcode == -1 or res_errcode == 45009 then
+                return 500, res_headers, res_body
+            end
+        end
+        return res_code, res_headers, res_body
+    end,
+    -- 发送到 pushover
+    ["pushover"] = function(msg)
+        if config.PUSHOVER_API_TOKEN == nil or config.PUSHOVER_API_TOKEN == "" then
+            log.error("util_notify", "未配置 `config.PUSHOVER_API_TOKEN`")
+            return
+        end
+        if config.PUSHOVER_USER_KEY == nil or config.PUSHOVER_USER_KEY == "" then
+            log.error("util_notify", "未配置 `config.PUSHOVER_USER_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { token = config.PUSHOVER_API_TOKEN, user = config.PUSHOVER_USER_KEY, message = msg }
+        local url = "https://api.pushover.net/1/messages.json"
+
+        log.info("util_notify", "POST", url)
+        return util_http.fetch(nil, "POST", url, header, json.encode(body))
+    end,
+    -- 发送到 inotify
+    ["inotify"] = function(msg)
+        if config.INOTIFY_API == nil or config.INOTIFY_API == "" then
+            log.error("util_notify", "未配置 `config.INOTIFY_API`")
+            return
+        end
+        if not config.INOTIFY_API:endsWith(".send") then
+            log.error("util_notify", "`config.INOTIFY_API` 必须以 `.send` 结尾")
+            return
+        end
+
+        local url = config.INOTIFY_API .. "/" .. string.urlEncode(msg)
+
+        log.info("util_notify", "GET", url)
+        return util_http.fetch(nil, "GET", url)
+    end,
+    -- 发送到 next-smtp-proxy
+    ["next-smtp-proxy"] = function(msg)
+        if config.NEXT_SMTP_PROXY_API == nil or config.NEXT_SMTP_PROXY_API == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_API`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_USER == nil or config.NEXT_SMTP_PROXY_USER == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_USER`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_PASSWORD == nil or config.NEXT_SMTP_PROXY_PASSWORD == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PASSWORD`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_HOST == nil or config.NEXT_SMTP_PROXY_HOST == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_HOST`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_PORT == nil or config.NEXT_SMTP_PROXY_PORT == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PORT`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_TO_EMAIL == nil or config.NEXT_SMTP_PROXY_TO_EMAIL == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_TO_EMAIL`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = {
+            user = config.NEXT_SMTP_PROXY_USER,
+            password = config.NEXT_SMTP_PROXY_PASSWORD,
+            host = config.NEXT_SMTP_PROXY_HOST,
+            port = config.NEXT_SMTP_PROXY_PORT,
+            form_name = config.NEXT_SMTP_PROXY_FORM_NAME,
+            to_email = config.NEXT_SMTP_PROXY_TO_EMAIL,
+            subject = config.NEXT_SMTP_PROXY_SUBJECT,
+            text = msg,
+        }
+
+        log.info("util_notify", "POST", config.NEXT_SMTP_PROXY_API)
+        return util_http.fetch(nil, "POST", config.NEXT_SMTP_PROXY_API, header, urlencodeTab(body))
+    end,
+    ["smtp"] = function(msg)
+        local smtp_config = {
+            host = config.SMTP_HOST,
+            port = config.SMTP_PORT,
+            username = config.SMTP_USERNAME,
+            password = config.SMTP_PASSWORD,
+            mail_from = config.SMTP_MAIL_FROM,
+            mail_to = config.SMTP_MAIL_TO,
+            tls_enable = config.SMTP_TLS_ENABLE,
+        }
+        local result = lib_smtp.send(msg, config.SMTP_MAIL_SUBJECT, smtp_config)
+        log.info("util_notify", "SMTP", result.success, result.message, result.is_retry)
+        if result.success then
+            return 200, nil, result.message
+        end
+        if result.is_retry then
+            return 500, nil, result.message
+        end
+        return 400, nil, result.message
+    end,
+    -- 发送到 serial
+    ["serial"] = function(msg)
+        uart.write(1, msg)
+        log.info("util_notify", "serial", "消息已转发到串口")
+        sys.wait(1000)
+        return 200
+    end,
+}