Browse Source

:tada: 初始化项目

Mizore 2 years ago
commit
0857ca6b60

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+.luatide
+luatide_project.json

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Mizore
+
+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.

BIN
LuatOS-SoC_V1003_EC618_TEMP_20220106_174000.soc


+ 9 - 0
README.md

@@ -0,0 +1,9 @@
+# Air780E 短信转发
+
+## 使用方法
+
+### 1. 按注释修改 `script/config.lua` 配置文件
+
+### 2. 烧录脚本
+
+根据 [air780e.cn](http://air780e.cn) 官方指引下载 LuaTools 并写入 `script` 目录下文件

+ 23 - 0
script/config.lua

@@ -0,0 +1,23 @@
+return {
+    -- 通知类型 telegram, pushdeer, bark
+    NOTIFY_TYPE = "pushdeer",
+    --
+    -- telegram 通知配置, https://github.com/0wQ/telegram-notify
+    TELEGRAM_PROXY_API = "",
+    TELEGRAM_TOKEN = "",
+    TELEGRAM_CHAT_ID = "",
+    --
+    -- pushdeer 通知配置, https://www.pushdeer.com/
+    PUSHDEER_API = "https://api2.pushdeer.com/message/push",
+    PUSHDEER_KEY = "",
+    --
+    -- bark 通知配置, https://github.com/Finb/Bark
+    BARK_API = "https://api.day.app",
+    BARK_KEY = "",
+    --
+    -- 定时查询流量间隔, 单位毫秒, 设置为 0 关闭
+    QUERY_TRAFFIC_INTERVAL = 1000 * 60 * 60 * 6,
+    --
+    -- 定时基站定位间隔, 单位毫秒, 设置为 0 关闭
+    LOCATION_INTERVAL = 1000 * 60 * 30
+}

+ 330 - 0
script/lbsLoc.lua

@@ -0,0 +1,330 @@
+--[[
+@module lbsLoc
+@summary lbsLoc 发送基站定位请求
+@version 1.0
+@date    2022.12.16
+@author  luatos
+@usage
+--注意:因使用了sys.wait()所有api需要在协程中使用
+--用法实例
+PRODUCT_KEY = "VmhtOb81EgZau6YyuuZJzwF6oUNGCbXi"
+local lbsLoc = require("lbsLoc")
+local function reqLbsLoc()
+    lbsLoc.request(getLocCb)
+end
+-- 功能:获取基站对应的经纬度后的回调函数
+-- 参数:-- result:number类型,0表示成功,1表示网络环境尚未就绪,2表示连接服务器失败,3表示发送数据失败,4表示接收服务器应答超时,5表示服务器返回查询失败;为0时,后面的5个参数才有意义
+		-- lat:string类型,纬度,整数部分3位,小数部分7位,例如031.2425864
+		-- lng:string类型,经度,整数部分3位,小数部分7位,例如121.4736522
+        -- addr:目前无意义
+        -- time:string类型或者nil,服务器返回的时间,6个字节,年月日时分秒,需要转为十六进制读取
+            -- 第一个字节:年减去2000,例如2017年,则为0x11
+            -- 第二个字节:月,例如7月则为0x07,12月则为0x0C
+            -- 第三个字节:日,例如11日则为0x0B
+            -- 第四个字节:时,例如18时则为0x12
+            -- 第五个字节:分,例如59分则为0x3B
+            -- 第六个字节:秒,例如48秒则为0x30
+        -- locType:numble类型或者nil,定位类型,0表示基站定位成功,255表示WIFI定位成功
+function getLocCb(result, lat, lng, addr, time, locType)
+    log.info("testLbsLoc.getLocCb", result, lat, lng)
+    -- 获取经纬度成功
+    if result == 0 then
+        log.info("服务器返回的时间", time:toHex())
+        log.info("定位类型,基站定位成功返回0", locType)
+    end
+    sys.timerStart(lbsLoc,20000)
+end
+reqLbsLoc()
+]]
+
+local lbsLoc = {}
+local d1Name = "D1_TASKL"
+--- 阻塞等待网卡的网络连接上,只能用于任务函数中
+-- @string 任务标志
+-- @int 超时时间,如果==0或者空,则没有超时一致等待
+-- @... 其他参数和socket.linkup一致
+-- @return 失败或者超时返回false 成功返回true
+local function waitLink(taskName, timeout, ...)
+	local is_err, result = socket.linkup(...)
+	if is_err then
+		return false
+	end
+	if not result then
+		result = sys_wait(taskName, socket.LINK, timeout)
+	else
+		return true
+	end
+	if type(result) == 'table' and result[2] == 0 then
+		return true
+	else
+		return false
+	end
+end
+
+--- 阻塞等待IP或者域名连接上,如果加密连接还要等握手完成,只能用于任务函数中
+-- @string 任务标志
+-- @int 超时时间,如果==0或者空,则没有超时一致等待
+-- @... 其他参数和socket.connect一致
+-- @return 失败或者超时返回false 成功返回true
+local function connect(taskName,timeout, ... )
+	local is_err, result = socket.connect(...)
+	if is_err then
+		return false
+	end
+	if not result then
+		result = sys_wait(taskName, socket.ON_LINE, timeout)
+	else
+		return true
+	end
+	if type(result) == 'table' and result[2] == 0 then
+		return true
+	else
+		return false
+	end
+end
+
+--- 阻塞等待数据发送完成,只能用于任务函数中
+-- @string 任务标志
+-- @int 超时时间,如果==0或者空,则没有超时一致等待
+-- @... 其他参数和socket.tx一致
+-- @return
+-- @boolean 失败或者超时返回false,缓冲区满了或者成功返回true
+-- @boolean 缓存区是否满了
+local function tx(taskName,timeout, ...)
+	local is_err, is_full, result = socket.tx(...)
+	if is_err then
+		return false, is_full
+	end
+	if is_full then
+		return true, true
+	end
+	if not result then
+		result = sys_wait(taskName, socket.TX_OK, timeout)
+	else
+		return true, is_full
+	end
+	if type(result) == 'table' and result[2] == 0 then
+		return true, false
+	else
+		return false, is_full
+	end
+end
+
+--- ASCII字符串 转化为 BCD编码格式字符串(仅支持数字)
+-- @string inStr 待转换字符串
+-- @number destLen 转换后的字符串期望长度,如果实际不足,则填充F
+-- @return string data,转换后的字符串
+-- @usage
+local function numToBcdNum(inStr,destLen)
+    local l,t,num = string.len(inStr or ""),{}
+
+    destLen = destLen or (inStr:len()+1)/2
+
+    for i=1,l,2 do
+        num = tonumber(inStr:sub(i,i+1),16)
+
+        if i==l then
+            num = 0xf0+num
+        else
+            num = (num%0x10)*0x10 + (num-(num%0x10))/0x10
+        end
+
+        table.insert(t,num)
+    end
+
+    local s = string.char(unpack(t))
+
+    l = string.len(s)
+    if l < destLen then
+        s = s .. string.rep("\255",destLen-l)
+    elseif l > destLen then
+        s = string.sub(s,1,destLen)
+    end
+
+    return s
+end
+
+--- BCD编码格式字符串 转化为 号码ASCII字符串(仅支持数字)
+-- @string num 待转换字符串
+-- @return string data,转换后的字符串
+-- @usage
+local function bcdNumToNum(num)
+	local byte,v1,v2
+	local t = {}
+
+	for i=1,num:len() do
+		byte = num:byte(i)
+		v1,v2 = bit.band(byte,0x0f),bit.band(bit.rshift(byte,4),0x0f)
+
+		if v1 == 0x0f then break end
+		table.insert(t,v1)
+
+		if v2 == 0x0f then break end
+		table.insert(t,v2)
+	end
+
+	return table.concat(t)
+end
+
+
+local function netCB(msg)
+	log.info("未处理消息", msg[1], msg[2], msg[3], msg[4])
+end
+
+
+local function enCellInfo(s)
+    local ret,t,mcc,mnc,lac,ci,rssi,k,v,m,n,cntrssi = "",{}
+        for k,v in pairs(s) do
+            mcc,mnc,lac,ci,rssi = v.mcc,v.mnc,v.tac,v.cid,((v.rsrq + 144) >31) and 31 or (v.rsrq + 144)
+            local handle = nil
+            for k,v in pairs(t) do
+                if v.lac == lac and v.mcc == mcc and v.mnc == mnc then
+                    if #v.rssici < 8 then
+                        table.insert(v.rssici,{rssi=rssi,ci=ci})
+                    end
+                    handle = true
+                break
+                end
+            end
+            if not handle then
+                table.insert(t,{mcc=mcc,mnc=mnc,lac=lac,rssici={{rssi=rssi,ci=ci}}})
+            end
+            log.info("rssi、mcc、mnc、lac、ci", rssi,mcc,mnc,lac,ci)
+        end
+        for k,v in pairs(t) do
+            ret = ret .. pack.pack(">HHb",v.lac,v.mcc,v.mnc)
+            for m,n in pairs(v.rssici) do
+                cntrssi = bit.bor(bit.lshift(((m == 1) and (#v.rssici-1) or 0),5),n.rssi)
+                ret = ret .. pack.pack(">bi",cntrssi,n.ci)
+            end
+        end
+        return string.char(#t)..ret
+end
+
+local function enWifiInfo(tWifi)
+    local ret,cnt,k,v = "",0
+    if tWifi then
+        for k,v in pairs(tWifi) do
+            log.info("lbsLoc.enWifiInfo",k,v)
+            ret = ret..pack.pack("Ab",(k:gsub(":","")):fromHex(),(v<0) and (v+255) or v)
+            cnt = cnt+1
+        end
+    end
+    return string.char(cnt)..ret
+end
+
+local function enMuid()   --获取模块MUID
+    local muid = mobile.muid()
+    return string.char(muid:len())..muid
+end
+
+local function trans(str)
+    local s = str
+    if str:len()<10 then
+        s = str..string.rep("0",10-str:len())
+    end
+
+    return s:sub(1,3).."."..s:sub(4,10)
+end
+
+
+local function taskClient(cbFnc, reqAddr, timeout, productKey, host, port,reqTime, reqWifi)
+    while mobile.status() == 0 do
+        if not sys.waitUntil("IP_READY", timeout) then return cbFnc(1) end
+    end
+    local retryCnt  = 0
+    sys.wait(3000)
+    local reqStr = pack.pack("bAbAAAAA", productKey:len(), productKey,
+                             (reqAddr and 2 or 0) + (reqTime and 4 or 0) + 8 +(reqWifi and 16 or 0) + 32, "",
+                             numToBcdNum(mobile.imei()), enMuid(),
+                             enCellInfo(mobile.getCellInfo()),
+                             enWifiInfo(reqWifi))
+    log.info("reqStr", reqStr:toHex())
+    local rx_buff = zbuff.create(17)
+    -- sys.wait(5000)
+    while true do
+        netc = socket.create(nil, d1Name) -- 创建socket对象
+        if not netc then cbFnc(6) return end -- 创建socket失败
+        socket.debug(netc, false)
+        socket.config(netc, nil, true, nil)
+        waitLink(d1Name, 0, netc)
+        local result = connect(d1Name, 15000, netc, host, port)
+        if result then
+            while true do
+                log.info(" lbsloc socket_service connect true")
+                sys.wait(2000);
+                local result, _ = tx(d1Name, 0, netc, reqStr) ---发送数据
+                if result then
+                    sys.wait(5000);
+                    local is_err, param, _, _ = socket.rx(netc, rx_buff) -- 接收数据
+                    log.info("是否接收和数据长度", not is_err, param)
+                    if not is_err then -- 如果接收成功
+                        socket.close(netc) -- 关闭连接
+                        socket.release(netc)
+                        local read_buff = rx_buff:toStr(0, param)
+                        rx_buff:clear()
+                        log.info("lbsLoc receive", read_buff:toHex())
+                        if read_buff:len() >= 11 and(read_buff:byte(1) == 0 or read_buff:byte(1) == 0xFF) then
+                            local locType = read_buff:byte(1)
+                            cbFnc(0, trans(bcdNumToNum(read_buff:sub(2, 6))),
+                                trans(bcdNumToNum(read_buff:sub(7, 11))), reqAddr and
+                                read_buff:sub(13, 12 + read_buff:byte(12)) or nil,
+                                read_buff:sub(reqAddr and (13 + read_buff:byte(12)) or 12, -1),
+                                locType)
+                        else
+                            log.warn("lbsLoc.query", "根据基站查询经纬度失败")
+                            if read_buff:byte(1) == 2 then
+                                log.warn("lbsLoc.query","main.lua中的PRODUCT_KEY和此设备在iot.openluat.com中所属项目的ProductKey必须一致,请去检查")
+                            else
+                                log.warn("lbsLoc.query","基站数据库查询不到所有小区的位置信息")
+                                log.warn("lbsLoc.query","在trace中向上搜索encellinfo,然后在电脑浏览器中打开http://bs.openluat.com/,手动查找encellinfo后的所有小区位置")
+                                log.warn("lbsLoc.query","如果手动可以查到位置,则服务器存在BUG,直接向技术人员反映问题")
+                                log.warn("lbsLoc.query","如果手动无法查到位置,则基站数据库还没有收录当前设备的小区位置信息,向技术人员反馈,我们会尽快收录")
+                            end
+                            cbFnc(5)
+                        end
+                        return
+                    else
+                        socket.close(netc)
+                        socket.release(netc)
+                        retryCnt = retryCnt+1
+                        if retryCnt>=3 then return cbFnc(4) end
+                        break
+                    end
+                else
+                    socket.close(netc)
+                    socket.release(netc)
+                    retryCnt = retryCnt+1
+                    if retryCnt>=3 then return cbFnc(3) end
+                    break
+                end
+            end
+        else
+            socket.close(netc)
+            socket.release(netc)
+            retryCnt = retryCnt + 1
+            if retryCnt >= 3 then return cbFnc(2) end
+        end
+    end
+end
+
+
+--[[
+发送基站/WIFI定位请求(仅支持中国区域的位置查询)
+@api lbsLoc.request(cbFnc,reqAddr,timeout,productKey,host,port,reqTime,reqWifi)
+@function cbFnc 用户回调函数,回调函数的调用形式为:cbFnc(result,lat,lng,addr,time,locType)
+@bool reqAddr 是否请求服务器返回具体的位置字符串信息,目前此功能不完善,参数可以传nil
+@number timeout 请求超时时间,单位毫秒,默认20000毫秒
+@string productKey IOT网站上的产品证书,如果在main.lua中定义了PRODUCT_KEY变量,则此参数可以传nil
+@string host 服务器域名,此参数可选,目前仅lib中agps.lua使用此参数。应用脚本可以直接传nil
+@string port 服务器端口,此参数可选,目前仅lib中agps.lua使用此参数。应用脚本可以直接传nil
+@bool reqTime 是否需要服务器返回时间信息,true返回,false或者nil不返回,此参数可选,目前仅lib中agps.lua使用此参数。应用脚本可以直接传nil
+@table reqWifi 搜索到的WIFI热点信息(MAC地址和信号强度),如果传入了此参数,后台会查询WIFI热点对应的经纬度,此参数格式如下:
+{["1a:fe:34:9e:a1:77"] = -63,["8c:be:be:2d:cd:e9"] = -81,["20:4e:7f:82:c2:c4"] = -70,}
+@return nil
+]]
+function lbsLoc.request(cbFnc,reqAddr,timeout,productKey,host,port,reqTime,reqWifi)
+    sysplus.taskInitEx(taskClient, d1Name, netCB, cbFnc,reqAddr or nil,timeout or 20000,productKey or _G.PRODUCT_KEY,host or "bs.openluat.com",port or "12411",reqTime,reqWifi)
+end
+
+return lbsLoc

+ 109 - 0
script/main.lua

@@ -0,0 +1,109 @@
+PROJECT = "air780e_forwarder"
+VERSION = "1.0.0"
+
+log.setLevel("DEBUG")
+log.info("main", PROJECT, VERSION)
+
+sys = require "sys"
+sysplus = require "sysplus"
+require "sysplus"
+
+-- 添加硬狗防止程序卡死, 在支持的设备上启用这个功能
+if wdt then
+    -- 初始化 watchdog 设置为 9s
+    wdt.init(9000)
+    -- 3s 喂一次狗
+    sys.timerLoopStart(wdt.feed, 3000)
+end
+
+-- 设置 DNS
+socket.setDNS(nil, 1, "119.29.29.29")
+socket.setDNS(nil, 2, "223.5.5.5")
+
+-- 设置 SIM 自动恢复, 搜索小区信息间隔, 最大搜索时间
+mobile.setAuto(1000 * 10, 1000 * 60, 1000 * 5)
+
+-- POWERKEY
+local powerkey_timer = 0
+gpio.setup(
+    35,
+    function()
+        local powerkey_state = gpio.get(35)
+        if powerkey_state == 0 then
+            powerkey_timer = os.time()
+        else
+            if powerkey_timer == 0 then
+                return
+            end
+            local time = os.time() - powerkey_timer
+            if time >= 2 then
+                log.info("POWERKEY_LONG_PRESS", time)
+                sys.publish("POWERKEY_LONG_PRESS")
+            else
+                log.info("POWERKEY_SHORT_PRESS", time)
+                sys.publish("POWERKEY_SHORT_PRESS")
+            end
+            powerkey_timer = 0
+        end
+    end,
+    gpio.PULLUP,
+    gpio.FALLING
+)
+
+config = require "config"
+util_netled = require "util_netled"
+util_mobile = require "util_mobile"
+util_location = require "util_location"
+util_notify = require "util_notify"
+
+-- 短信回调
+sms.setNewSmsCb(
+    function(num, txt, metas)
+        log.info("smsCallback", num, txt, metas and json.encode(metas) or "")
+        util_netled.blink(200, 200, 1000)
+        util_notify.send({txt, "", "发件人号码: " .. num, "#SMS"})
+    end
+)
+
+sys.taskInit(
+    function()
+        -- 等待网络环境准备就绪
+        sys.waitUntil("IP_READY")
+
+        util_netled.blink(200, 200, 5000)
+
+        -- 开机基站定位
+        util_location.getCoord(
+            function()
+                log.info("publish", "COORD_INIT_DONE")
+                sys.publish("COORD_INIT_DONE")
+            end
+        )
+        sys.waitUntil("COORD_INIT_DONE", 1000 * 20)
+
+        -- 开机通知
+        util_notify.send("#BOOT")
+
+        -- 定时查询流量
+        if config.QUERY_TRAFFIC_INTERVAL 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 * 10 then
+            sys.timerLoopStart(util_location.getCoord, config.LOCATION_INTERVAL)
+        end
+
+        -- 电源键短按发送测试通知
+        sys.subscribe(
+            "POWERKEY_SHORT_PRESS",
+            function()
+                util_notify.send("#ALIVE")
+            end
+        )
+        -- 电源键长按查询流量
+        sys.subscribe("POWERKEY_LONG_PRESS", util_mobile.queryTraffic)
+    end
+)
+
+sys.run()

+ 50 - 0
script/util_location.lua

@@ -0,0 +1,50 @@
+local lbsLoc = require "lbsLoc"
+
+local util_location = {}
+
+local last_lat, last_lng = 0, 0
+local last_time = 0
+
+-- 获取坐标
+function util_location.getCoord(callback, type, wifi, timeout)
+    local is_callback = callback ~= nil
+    if callback == nil then
+        callback = function()
+        end
+    end
+
+    sys.taskInit(
+        function()
+            local current_time = os.time()
+            if not is_callback then
+                if current_time - last_time < 30 then
+                    log.info("util_location.getCoord", "距离上次定位时间太短", current_time - last_time)
+                    return
+                end
+                sys.wait(1000)
+            end
+            last_time = current_time
+            lbsLoc.request(
+                function(result, lat, lng, addr, time, locType)
+                    log.info("util_location.getCoord", result, lat, lng, locType)
+                    if result == 0 and lat and lng then
+                        last_lat, last_lng = lat, lng
+                        return callback(lat, lng)
+                    end
+                    return callback(last_lat, last_lng)
+                end,
+                nil,
+                timeout,
+                "v32xEAKsGTIEQxtqgwCldp5aPlcnPs3K",
+                nil,
+                nil,
+                nil,
+                wifi
+            )
+        end
+    )
+
+    return last_lat, last_lng
+end
+
+return util_location

+ 54 - 0
script/util_mobile.lua

@@ -0,0 +1,54 @@
+local util_mobile = {mcc = 99, mnc = 99, band = 99}
+
+-- 查询流量代码
+local trafficCode = {
+    CU = {"10010", "1071"},
+    CM = {"10086", "cxll"},
+    CT = {"10001", "108"}
+}
+
+-- 获取运营商
+function util_mobile.getOper(is_zh)
+    if util_mobile.mcc ~= 460 then
+        return ""
+    end
+
+    if util_mobile.mnc == 1 then
+        return is_zh and "中国联通" or "CU"
+    end
+
+    if util_mobile.mnc == 0 then
+        return is_zh and "中国移动" or "CM"
+    end
+
+    if util_mobile.mnc == 11 then
+        return is_zh and "中国电信" or "CT"
+    end
+
+    if util_mobile.mnc == 15 then
+        return is_zh and "中国广电" or "CB"
+    end
+
+    return ""
+end
+
+-- 发送查询流量短信
+function util_mobile.queryTraffic()
+    local oper = util_mobile.getOper()
+    if oper and trafficCode[oper] then
+        sms.send(trafficCode[oper][1], trafficCode[oper][2])
+    else
+        log.warn("queryTraffic", "查询流量代码未配置")
+    end
+end
+
+sys.subscribe(
+    "CELL_INFO_UPDATE",
+    function()
+        local info = mobile.getCellInfo()[1] or {}
+        util_mobile.mcc, util_mobile.mnc, util_mobile.band = info.mcc, info.mnc, info.band
+        log.info("cell", "mcc:", util_mobile.mcc, "mnc:", util_mobile.mnc, "band:", util_mobile.band)
+    end
+)
+
+return util_mobile

+ 36 - 0
script/util_netled.lua

@@ -0,0 +1,36 @@
+local util_netled = {}
+
+local netled = gpio.setup(27, 0, gpio.PULLUP)
+
+local netled_default_duration = 200
+local netled_default_interval = 2000
+
+local netled_duration = netled_default_duration
+local netled_interval = netled_default_interval
+
+sys.taskInit(
+    function()
+        while true do
+            netled(1)
+            sys.wait(netled_duration)
+            netled(0)
+            sys.wait(netled_interval)
+        end
+    end
+)
+
+function util_netled.blink(duration, interval, restore)
+    netled_duration = duration or netled_default_duration
+    netled_interval = interval or netled_default_interval
+    if restore then
+        sys.timerStart(
+            function()
+                netled_duration = netled_default_duration
+                netled_interval = netled_default_interval
+            end,
+            restore
+        )
+    end
+end
+
+return util_netled

+ 151 - 0
script/util_notify.lua

@@ -0,0 +1,151 @@
+local util_notify = {}
+
+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
+
+-- 发送到 telegram
+local function notifyToTelegram(msg)
+    if config.TELEGRAM_PROXY_API == nil or config.TELEGRAM_PROXY_API == "" then
+        log.error("util_notify.notifyToTelegram", "未配置 `config.TELEGRAM_PROXY_API`")
+        return
+    end
+
+    local header = {
+        ["content-type"] = "text/plain",
+        ["x-disable-web-page-preview"] = "1",
+        ["x-chat-id"] = config.TELEGRAM_CHAT_ID or "",
+        ["x-token"] = config.TELEGRAM_TOKEN or ""
+    }
+
+    log.info("util_notify.notifyToTelegram", "POST", config.TELEGRAM_PROXY_API)
+    return http.request("POST", config.TELEGRAM_PROXY_API, header, msg).wait()
+end
+
+-- 发送到 pushdeer
+local function notifyToPushDeer(msg, httpCallback)
+    if config.PUSHDEER_API == "" then
+        log.error("util_notify.notifyToPushDeer", "未配置 `config.PUSHDEER_API`")
+        return
+    end
+    if config.PUSHDEER_KEY == "" then
+        log.error("util_notify.notifyToPushDeer", "未配置 `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.notifyToPushDeer", "POST", config.PUSHDEER_API)
+    return http.request("POST", config.PUSHDEER_API, header, urlencodeTab(body)).wait()
+end
+
+-- 发送到 bark
+local function notifyToBark(msg, httpCallback)
+    if config.BARK_API == "" then
+        log.error("util_notify.notifyToBark", "未配置 `config.BARK_API`")
+        return
+    end
+    if config.BARK_KEY == "" then
+        log.error("util_notify.notifyToBark", "未配置 `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.notifyToBark", "POST", url)
+    return http.request("POST", url, header, urlencodeTab(body)).wait()
+end
+
+function util_notify.send(msg)
+    log.info("util_notify.send", "发送通知", config.NOTIFY_TYPE)
+
+    if type(msg) == "table" then
+        msg = table.concat(msg, "\n")
+    end
+    if type(msg) ~= "string" then
+        log.error("util_notify.send", "发送通知失败", "参数类型错误", type(msg))
+        return
+    end
+
+    local model = hmeta.model() or ""
+    local simid = mobile.simid()
+    local iccid = mobile.iccid(simid) or ""
+    local rsrp = mobile.rsrp()
+    local mcc, mnc, band = util_mobile.mcc, util_mobile.mnc, util_mobile.band
+    local oper = util_mobile.getOper(true)
+    local lat, lng = util_location.getCoord()
+    local map_url = "https://apis.map.qq.com/uri/v1/marker?coord_type=1&marker=title:+;coord:" .. lat .. "," .. lng
+
+    msg = msg .. "\n"
+    if model then
+        msg = msg .. "\nMODEL: " .. model
+    end
+    if iccid then
+        msg = msg .. "\nICCID: " .. iccid
+    end
+    if oper then
+        msg = msg .. "\n运营商: " .. oper
+    end
+    msg = msg .. "\n信号: " .. rsrp .. "dBm"
+    if band ~= "" then
+        msg = msg .. "\n频段: B" .. band
+    end
+    if lat ~= 0 and lng ~= 0 then
+        msg = msg .. "\n位置: " .. map_url
+    end
+
+    -- 判断通知类型
+    local notify
+    if config.NOTIFY_TYPE == "telegram" then
+        notify = notifyToTelegram
+    elseif config.NOTIFY_TYPE == "pushdeer" then
+        notify = notifyToPushDeer
+    elseif config.NOTIFY_TYPE == "bark" then
+        notify = notifyToBark
+    else
+        log.error("util_notify.send", "发送通知失败", "未配置 `config.NOTIFY_TYPE`")
+        return
+    end
+
+    sys.taskInit(
+        function()
+            sys.wait(100)
+            local max_retry = 10
+            local retry_count = 0
+
+            while retry_count < max_retry do
+                local code, headers, body = notify(msg)
+                if code == 200 then
+                    log.info("util_notify.send", "发送通知成功", "retry_count:", retry_count)
+                    break
+                else
+                    retry_count = retry_count + 1
+                    log.error("util_notify.send", "发送通知失败", "retry_count:", retry_count, "code:", code, "body:", body)
+                    util_netled.blink(500, 200, 3000)
+                    sys.wait(10000)
+                end
+            end
+        end
+    )
+end
+
+return util_notify