├── .gitignore ├── LICENSE ├── README.md ├── core ├── LuatOS-SoC_V1002_EC718PV.soc ├── LuatOS-SoC_V1105_EC618.soc ├── LuatOS-SoC_V1105_EC618_FULL.soc ├── LuatOS-SoC_V1105_EC618_TTS.soc ├── LuatOS-SoC_V1109_EC618.soc ├── LuatOS-SoC_V1113_EC618.soc └── LuatOS-SoC_V2001_EC718PV_CLOUD.soc └── script ├── config.lua ├── lib_smtp.lua ├── main.lua ├── util_http.lua ├── util_location.lua ├── util_mobile.lua ├── util_netled.lua ├── util_notify.lua └── util_notify_channel.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .luatide 2 | luatide_project.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mizore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Air700E / Air780E / Air780EP / Air780EPV 短信转发 来电通知 2 | 3 | ## 保姆级教程:https://kdocs.cn/l/coe1ozIlSX70 4 | 5 | ## :sparkles: Feature 6 | 7 | - [x] 多种通知方式 8 | - [x] [Telegram](https://github.com/0wQ/telegram-notify) 9 | - [x] [PushDeer](https://www.pushdeer.com/) 10 | - [x] [Bark](https://github.com/Finb/Bark) 11 | - [x] [钉钉群机器人 DingTalk](https://open.dingtalk.com/document/robots/custom-robot-access) 12 | - [x] [飞书群机器人 Feishu](https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN) 13 | - [x] [企业微信群机器人 WeCom](https://developer.work.weixin.qq.com/document/path/91770) 14 | - [x] [Pushover](https://pushover.net/api) 15 | - [x] [邮件 next-smtp-proxy](https://github.com/0wQ/next-smtp-proxy) 16 | - [x] [Gotify](https://gotify.net) 17 | - [x] [Inotify](https://github.com/xpnas/Inotify) / [合宙官方的推送服务](https://push.luatos.org) 18 | - [x] 邮件 (SMTP协议) 19 | - [x] 通过短信控制设备 20 | - [x] 发短信, 格式: `SMS,10010,余额查询` 21 | - [x] 定时基站定位 22 | - [x] 定时查询流量 23 | - [x] 定时上报存活 24 | - [x] 开机通知 25 | - [x] POW 按键长按短按操作 26 | - [x] 使用消息队列, 测试添加几百条通知, 不会卡死 27 | - [x] 通知发送失败, 自动重发, 断电后再次开机可以恢复重发 28 | - [x] 支持主从模式,一主对多从,从机通过串口转发消息,主机接受消息后转发到通知服务 29 | 30 | ## :hammer: Usage 31 | 32 | https://mizore.notion.site/Air780E-e750efe0d6cc40c3baa276eeb811d534 33 | 34 | -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1002_EC718PV.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1002_EC718PV.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1105_EC618.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1105_EC618.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1105_EC618_FULL.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1105_EC618_FULL.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1105_EC618_TTS.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1105_EC618_TTS.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1109_EC618.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1109_EC618.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V1113_EC618.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V1113_EC618.soc -------------------------------------------------------------------------------- /core/LuatOS-SoC_V2001_EC718PV_CLOUD.soc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lageev/air780e-forwarder/7b50a0432dd83533db33bfe4082ff0e3be9783d2/core/LuatOS-SoC_V2001_EC718PV_CLOUD.soc -------------------------------------------------------------------------------- /script/config.lua: -------------------------------------------------------------------------------- 1 | return { 2 | -- 通知类型, 支持配置多个 3 | -- NOTIFY_TYPE = { "custom_post", "telegram", "pushdeer", "bark", "dingtalk", "feishu", "wecom", "pushover", "inotify", "next-smtp-proxy", "gotify", "serial" }, 4 | NOTIFY_TYPE = "custom_post", 5 | -- 6 | -- 角色类型, 用于区分主从机, 仅当使用串口转发 NOTIFY_TYPE = "serial" 时才需要配置 7 | -- MASTER: 主机, 可主动联网; SLAVE: 从机, 不可主动联网, 通过串口发送数据 8 | ROLE = "MASTER", 9 | -- 10 | -- custom_post 通知配置, 自定义 POST 请求, CUSTOM_POST_BODY_TABLE 中的 {msg} 会被替换为通知内容 11 | CUSTOM_POST_URL = "https://sctapi.ftqq.com/.send", 12 | CUSTOM_POST_CONTENT_TYPE = "application/json", 13 | CUSTOM_POST_BODY_TABLE = { ["title"] = "这里是标题", ["desp"] = "这里是内容, 会被替换掉:\n{msg}\n{msg}" }, 14 | -- 15 | -- telegram 通知配置, https://github.com/0wQ/telegram-notify 或者自行反代 16 | TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage", 17 | TELEGRAM_CHAT_ID = "", 18 | -- 19 | -- pushdeer 通知配置, https://www.pushdeer.com/ 20 | PUSHDEER_API = "https://api2.pushdeer.com/message/push", 21 | PUSHDEER_KEY = "", 22 | -- 23 | -- bark 通知配置, https://github.com/Finb/Bark 24 | BARK_API = "https://api.day.app", 25 | BARK_KEY = "", 26 | -- 27 | -- dingtalk 通知配置, https://open.dingtalk.com/document/robots/custom-robot-access 28 | -- 如果是加签方式, 请填写 DINGTALK_SECRET, 否则留空为自定义关键词方式, https://open.dingtalk.com/document/robots/customize-robot-security-settings 29 | DINGTALK_WEBHOOK = "", 30 | DINGTALK_SECRET = "", 31 | -- 32 | -- feishu 通知配置, https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN 33 | FEISHU_WEBHOOK = "", 34 | -- 35 | -- wecom 通知配置, https://developer.work.weixin.qq.com/document/path/91770 36 | WECOM_WEBHOOK = "", 37 | -- 38 | -- pushover 通知配置, https://pushover.net/api 39 | PUSHOVER_API_TOKEN = "", 40 | PUSHOVER_USER_KEY = "", 41 | -- 42 | -- inotify 通知配置, https://github.com/xpnas/Inotify 或者使用合宙提供的 https://push.luatos.org 43 | INOTIFY_API = "https://push.luatos.org/XXXXXX.send", 44 | -- 45 | -- next-smtp-proxy 通知配置, https://github.com/0wQ/next-smtp-proxy 46 | NEXT_SMTP_PROXY_API = "", 47 | NEXT_SMTP_PROXY_USER = "", 48 | NEXT_SMTP_PROXY_PASSWORD = "", 49 | NEXT_SMTP_PROXY_HOST = "smtp-mail.outlook.com", 50 | NEXT_SMTP_PROXY_PORT = 587, 51 | NEXT_SMTP_PROXY_FORM_NAME = "Air780E", 52 | NEXT_SMTP_PROXY_TO_EMAIL = "", 53 | NEXT_SMTP_PROXY_SUBJECT = "来自 Air780E 的通知", 54 | -- 55 | -- smtp 通知配置(可能不支持加密协议) 56 | SMTP_HOST = "smtp.qq.com", 57 | SMTP_PORT = 25, 58 | SMTP_USERNAME = "", 59 | SMTP_PASSWORD = "", 60 | SMTP_MAIL_FROM = "", 61 | SMTP_MAIL_TO = "", 62 | SMTP_MAIL_SUBJECT = "来自 Air780E 的通知", 63 | SMTP_TLS_ENABLE = false, 64 | -- 65 | -- gotify 通知配置, https://gotify.net/ 66 | GOTIFY_API = "", 67 | GOTIFY_TITLE = "Air780E", 68 | GOTIFY_PRIORITY = 8, 69 | GOTIFY_TOKEN = "", 70 | -- 71 | -- 定时查询流量间隔, 单位毫秒, 设置为 0 关闭 (建议检查 util_mobile.lua 文件中运营商号码和查询代码是否正确, 以免发错短信导致扣费, 收到查询结果短信发送通知会消耗流量) 72 | QUERY_TRAFFIC_INTERVAL = 0, 73 | -- 74 | -- 定时基站定位间隔, 单位毫秒, 设置为 0 关闭 (定位成功后会追加到通知内容后面, 基站定位本身会消耗流量, 通知内容增加也会导致流量消耗增加) 75 | LOCATION_INTERVAL = 0, 76 | -- 77 | -- 定时开关飞行模式间隔, 单位毫秒, 设置为 0 关闭 78 | FLYMODE_INTERVAL = 1000 * 60 * 60 * 12, 79 | -- 80 | -- 定时同步时间间隔, 单位毫秒, 设置为 0 关闭 81 | SNTP_INTERVAL = 1000 * 60 * 60 * 6, 82 | -- 83 | -- 定时上报间隔, 单位毫秒, 设置为 0 关闭 (定时触发消息上报) 84 | REPORT_INTERVAL = 0, 85 | -- 86 | -- 开机通知 (会消耗流量) 87 | BOOT_NOTIFY = true, 88 | -- 89 | -- 通知内容追加更多信息 (通知内容增加会导致流量消耗增加) 90 | NOTIFY_APPEND_MORE_INFO = true, 91 | -- 92 | -- 通知最大重发次数 93 | NOTIFY_RETRY_MAX = 20, 94 | -- 95 | -- 本机号码, 优先使用 mobile.number() 接口获取, 如果获取不到则使用此号码 96 | FALLBACK_LOCAL_NUMBER = "+8618888888888", 97 | -- 98 | -- SIM 卡 pin 码 99 | PIN_CODE = "", 100 | -- 101 | -- 短信控制白名单号码, 为空或注释掉, 表示禁止所有号码 102 | -- 短信格式示例: SMS,10086,查询流量 103 | -- 配置示例: SMS_CONTROL_WHITELIST_NUMBERS = { "18xxxxxxx", "18xxxxxxx", "18xxxxxxx" } 104 | SMS_CONTROL_WHITELIST_NUMBERS = {}, 105 | } 106 | -------------------------------------------------------------------------------- /script/lib_smtp.lua: -------------------------------------------------------------------------------- 1 | local lib_smtp = {} 2 | 3 | lib_smtp.socket_debug_enable = false 4 | lib_smtp.packet_size = 512 5 | lib_smtp.timeout = 1000 * 30 6 | 7 | --- 日志格式化函数 8 | -- @param content string, 日志内容 9 | -- @return string, 处理后的日志内容 10 | local function logFormat(content) 11 | -- 隐藏 AUTH 用户信息 12 | content = content:gsub("AUTH PLAIN (.-)\r\n", "AUTH PLAIN ***\r\n") 13 | -- 替换换行符 14 | content = content:gsub("\r", "\\r"):gsub("\n", "\\n") 15 | -- 截取 16 | content = content:sub(1, 200) .. (#content > 200 and " ..." or "") 17 | return content 18 | end 19 | 20 | --- 转义句号函数 21 | -- @param content string, 需要转义的内容 22 | -- @return string, 转义后的内容 23 | local function escapeDot(content) 24 | return content:gsub("(.-\r\n)", function(line) 25 | if line:sub(1, 1) == "." then line = "." .. line end 26 | return line 27 | end) 28 | end 29 | 30 | --- 接收到数据时的处理函数 31 | -- @param netc userdata, socket.create 返回的 netc 32 | -- @param rxbuf userdata, 接收到的数据 33 | -- @param socket_id string, socket id 34 | -- @param current_command string, 当前要发送的命令 35 | local function recvHandler(netc, rxbuf, socket_id, current_command) 36 | local rx_str = rxbuf:toStr(0, rxbuf:used()) 37 | log.info("lib_smtp", socket_id, "<-", logFormat(rx_str)) 38 | 39 | -- 如果返回非 2xx 或 3xx 状态码, 则断开连接 40 | if not rx_str:match("^[23]%d%d") then 41 | log.error("lib_smtp", socket_id, "服务器返回错误状态码, 断开连接, 请检查日志") 42 | sys.publish(socket_id .. "_disconnect", { success = false, message = "服务器返回错误状态码", is_retry = false }) 43 | return 44 | end 45 | 46 | if current_command == nil then 47 | log.info("lib_smtp", socket_id, "全部发送完成") 48 | sys.publish(socket_id .. "_disconnect", { success = true, message = "发送成功", is_retry = false }) 49 | return 50 | end 51 | 52 | -- 分包发送 53 | local index = 1 54 | sys.taskInit(function() 55 | while index <= #current_command do 56 | local packet = current_command:sub(index, index + lib_smtp.packet_size - 1) 57 | socket.tx(netc, packet) 58 | log.info("lib_smtp", socket_id, "->", logFormat(packet)) 59 | index = index + lib_smtp.packet_size 60 | sys.wait(100) 61 | end 62 | end) 63 | end 64 | 65 | local function validateParameters(smtp_config) 66 | -- 配置参数验证规则 67 | local validation_rules = { 68 | { field = "host", type = "string", required = true }, 69 | { field = "port", type = "number", required = true }, 70 | { field = "username", type = "string", required = true }, 71 | { field = "password", type = "string", required = true }, 72 | { field = "mail_from", type = "string", required = true }, 73 | { field = "mail_to", type = "string", required = true }, 74 | { field = "tls_enable", type = "boolean", required = false }, 75 | } 76 | local result = true 77 | for _, rule in ipairs(validation_rules) do 78 | local value = smtp_config[rule.field] 79 | if rule.type == "string" and (value == nil or value == "") then 80 | log.error("lib_smtp", string.format("`smtp_config.%s` 应为非空字符串", rule.field)) 81 | result = false 82 | elseif rule.required and type(value) ~= rule.type then 83 | log.error("lib_smtp", string.format("`smtp_config.%s` 应为 %s 类型", rule.field, rule.type)) 84 | result = false 85 | end 86 | end 87 | return result 88 | end 89 | 90 | --- 发送邮件 91 | -- @param body string 邮件正文 92 | -- @param subject string 邮件主题 93 | -- @param smtp_config table 配置参数 94 | -- - smtp_config.host string SMTP 服务器地址 95 | -- - smtp_config.username string SMTP 账号用户名 96 | -- - smtp_config.password string SMTP 账号密码 97 | -- - smtp_config.mail_from string 发件人邮箱地址 98 | -- - smtp_config.mail_to string 收件人邮箱地址 99 | -- - smtp_config.port number SMTP 服务器端口号 100 | -- - smtp_config.tls_enable boolean 是否启用 TLS(可选,默认为 false) 101 | -- @return result table 发送结果 102 | -- - result.success boolean 是否发送成功 103 | -- - result.message string 发送结果描述 104 | -- - result.is_retry boolean 是否需要重试 105 | function lib_smtp.send(body, subject, smtp_config) 106 | -- 参数验证 107 | if type(smtp_config) ~= "table" then 108 | log.error("lib_smtp", "`smtp_config` 应为 table 类型") 109 | return { success = false, message = "参数错误", is_retry = false } 110 | end 111 | local valid = validateParameters(smtp_config) 112 | if not valid then return { success = false, message = "参数错误", is_retry = false } end 113 | 114 | subject = type(subject) == "string" and subject or "" 115 | body = type(body) == "string" and escapeDot(body) or "" 116 | 117 | lib_smtp.send_count = (lib_smtp.send_count or 0) + 1 118 | local socket_id = "socket_" .. lib_smtp.send_count 119 | local rxbuf = zbuff.create(256) 120 | 121 | local commands = { 122 | "HELO " .. smtp_config.host .. "\r\n", 123 | "AUTH PLAIN " .. string.toBase64("\0" .. smtp_config.username .. "\0" .. smtp_config.password) .. "\r\n", 124 | "MAIL FROM: <" .. smtp_config.mail_from .. ">\r\n", 125 | "RCPT TO: <" .. smtp_config.mail_to .. ">\r\n", 126 | "DATA\r\n", 127 | table.concat({ 128 | "From: " .. smtp_config.mail_from, 129 | "To: " .. smtp_config.mail_to, 130 | "Subject: " .. subject, 131 | "Content-Type: text/plain; charset=UTF-8", 132 | "", 133 | body, 134 | ".", 135 | "", 136 | }, "\r\n"), 137 | } 138 | local current_command_index = 1 139 | local function getNextCommand() 140 | local command = commands[current_command_index] 141 | current_command_index = current_command_index + 1 142 | return command 143 | end 144 | 145 | -- socket 回调 146 | local function netCB(netc, event, param) 147 | if param ~= 0 then 148 | sys.publish(socket_id .. "_disconnect", { success = false, message = "param~=0", is_retry = true }) 149 | return 150 | end 151 | if event == socket.LINK then 152 | log.info("lib_smtp", socket_id, "LINK") 153 | elseif event == socket.ON_LINE then 154 | log.info("lib_smtp", socket_id, "ON_LINE") 155 | elseif event == socket.EVENT then 156 | socket.rx(netc, rxbuf) 157 | socket.wait(netc) 158 | if rxbuf:used() > 0 then recvHandler(netc, rxbuf, socket_id, getNextCommand()) end 159 | rxbuf:del() 160 | elseif event == socket.TX_OK then 161 | socket.wait(netc) 162 | elseif event == socket.CLOSE then 163 | log.info("lib_smtp", socket_id, "CLOSED") 164 | sys.publish(socket_id .. "_disconnect", { success = false, message = "服务器断开连接", is_retry = true }) 165 | end 166 | end 167 | 168 | -- 初始化 socket 169 | local netc = socket.create(nil, netCB) 170 | socket.debug(netc, lib_smtp.socket_debug_enable) 171 | socket.config(netc, nil, nil, smtp_config.tls_enable) 172 | -- 连接 smtp 服务器 173 | local is_connect_success = socket.connect(netc, smtp_config.host, smtp_config.port) 174 | if not is_connect_success then 175 | socket.close(netc) 176 | return { success = false, message = "未知错误", is_retry = true } 177 | end 178 | -- 等待发送结果 179 | local is_send_success, send_result = sys.waitUntil(socket_id .. "_disconnect", lib_smtp.timeout) 180 | socket.close(netc) 181 | if is_send_success then 182 | return send_result 183 | else 184 | log.error("lib_smtp", socket_id, "发送超时") 185 | return { success = false, message = "发送超时", is_retry = true } 186 | end 187 | end 188 | 189 | return lib_smtp 190 | -------------------------------------------------------------------------------- /script/main.lua: -------------------------------------------------------------------------------- 1 | PROJECT = "air780e_forwarder" 2 | VERSION = "1.0.0" 3 | 4 | log.setLevel("DEBUG") 5 | log.info("main", PROJECT, VERSION) 6 | log.info("main", "开机原因", pm.lastReson()) 7 | 8 | sys = require "sys" 9 | sysplus = require "sysplus" 10 | 11 | -- 添加硬狗防止程序卡死 12 | wdt.init(9000) 13 | sys.timerLoopStart(wdt.feed, 3000) 14 | 15 | -- 设置电平输出 3.3V 16 | -- pm.ioVol(pm.IOVOL_ALL_GPIO, 3300) 17 | 18 | -- 设置 DNS 19 | socket.setDNS(nil, 1, "119.29.29.29") 20 | socket.setDNS(nil, 2, "223.5.5.5") 21 | 22 | -- SIM 自动恢复, 周期性获取小区信息, 网络遇到严重故障时尝试自动恢复等功能 23 | mobile.setAuto(10000, 30000, 8, true, 60000) 24 | 25 | -- 开启 IPv6 26 | -- mobile.ipv6(true) 27 | 28 | -- 初始化 fskv 29 | log.info("main", "fskv.init", fskv.init()) 30 | 31 | -- POWERKEY 32 | local rtos_bsp = rtos.bsp() 33 | local pin_table = { ["EC618"] = 35, ["EC718P"] = 46, ["EC718PV"] = 46 } 34 | local powerkey_pin = pin_table[rtos_bsp] 35 | 36 | if powerkey_pin then 37 | local button_last_press_time, button_last_release_time = 0, 0 38 | gpio.setup(powerkey_pin, function() 39 | local current_time = mcu.ticks() 40 | -- 按下 41 | if gpio.get(powerkey_pin) == 0 then 42 | button_last_press_time = current_time -- 记录最后一次按下时间 43 | return 44 | end 45 | -- 释放 46 | if button_last_press_time == 0 then -- 开机前已经按下, 开机后释放 47 | return 48 | end 49 | if current_time - button_last_release_time < 250 then -- 防止连按 50 | return 51 | end 52 | local duration = current_time - button_last_press_time -- 按键持续时间 53 | button_last_release_time = current_time -- 记录最后一次释放时间 54 | if duration > 2000 then 55 | log.debug("EVENT.POWERKEY_LONG_PRESS", duration) 56 | sys.publish("POWERKEY_LONG_PRESS", duration) 57 | elseif duration > 50 then 58 | log.debug("EVENT.POWERKEY_SHORT_PRESS", duration) 59 | sys.publish("POWERKEY_SHORT_PRESS", duration) 60 | end 61 | end, gpio.PULLUP, gpio.FALLING) 62 | end 63 | 64 | -- 加载模块 65 | config = require "config" 66 | util_http = require "util_http" 67 | util_netled = require "util_netled" 68 | util_mobile = require "util_mobile" 69 | util_location = require "util_location" 70 | util_notify = require "util_notify" 71 | 72 | -- 由于 NOTIFY_TYPE 支持多个配置, 需按照包含来判断 73 | local containsValue = function(t, value) 74 | if t == value then return true end 75 | if type(t) ~= "table" then return false end 76 | for k, v in pairs(t) do if v == value then return true end end 77 | return false 78 | end 79 | 80 | if containsValue(config.NOTIFY_TYPE, "serial") then 81 | -- 串口配置 82 | uart.setup(1, 115200, 8, 1, uart.NONE) 83 | -- 串口接收回调 84 | uart.on(1, "receive", function(id, len) 85 | local data = uart.read(id, len) 86 | log.info("uart read:", id, len, data) 87 | if config.ROLE == "MASTER" then 88 | -- 主机, 通过队列发送数据 89 | util_notify.add(data) 90 | else 91 | -- 从机, 通过串口发送数据 92 | uart.write(1, data) 93 | end 94 | end) 95 | end 96 | 97 | -- 判断一个元素是否在一个表中 98 | local function isElementInTable(myTable, target) 99 | for _, value in ipairs(myTable) do 100 | if value == target then 101 | return true 102 | end 103 | end 104 | return false 105 | end 106 | 107 | -- 判断白名单号码是否符合触发短信控制的条件 108 | local function isWhiteListNumber(sender_number) 109 | -- 判断如果未设置白名单号码, 禁止所有号码触发 110 | if type(config.SMS_CONTROL_WHITELIST_NUMBERS) ~= "table" or #config.SMS_CONTROL_WHITELIST_NUMBERS == 0 then 111 | return false 112 | end 113 | -- 已设置白名单号码, 判断是否在白名单中 114 | return isElementInTable(config.SMS_CONTROL_WHITELIST_NUMBERS, sender_number) 115 | end 116 | 117 | -- 短信接收回调 118 | sms.setNewSmsCb(function(sender_number, sms_content, m) 119 | local time = string.format("%d/%02d/%02d %02d:%02d:%02d", m.year + 2000, m.mon, m.day, m.hour, m.min, m.sec) 120 | log.info("smsCallback", time, sender_number, sms_content) 121 | 122 | -- 短信控制 123 | local is_sms_ctrl = false 124 | -- 判断发送者是否为白名单号码 125 | if isWhiteListNumber(sender_number) then 126 | local receiver_number, sms_content_to_be_sent = sms_content:match("^SMS,(+?%d+),(.+)$") 127 | receiver_number, sms_content_to_be_sent = receiver_number or "", sms_content_to_be_sent or "" 128 | if sms_content_to_be_sent ~= "" and receiver_number ~= "" and #receiver_number >= 5 and #receiver_number <= 20 then 129 | sms.send(receiver_number, sms_content_to_be_sent) 130 | is_sms_ctrl = true 131 | end 132 | end 133 | 134 | -- 发送通知 135 | util_notify.add({ sms_content, "", "发件号码: " .. sender_number, "发件时间: " .. time, "#SMS" .. (is_sms_ctrl and " #CTRL" or "") }) 136 | end) 137 | 138 | sys.taskInit(function() 139 | -- 等待网络环境准备就绪 140 | sys.waitUntil("IP_READY", 1000 * 60 * 5) 141 | 142 | util_netled.init() 143 | 144 | -- 开机通知 145 | if config.BOOT_NOTIFY then 146 | sys.timerStart(util_notify.add, 1000 * 5, "#BOOT_" .. pm.lastReson()) 147 | end 148 | 149 | -- 定时同步时间 150 | if os.time() < 1714500000 then 151 | socket.sntp() 152 | end 153 | if type(config.SNTP_INTERVAL) == "number" and config.SNTP_INTERVAL >= 1000 * 60 then 154 | sys.timerLoopStart(socket.sntp, config.SNTP_INTERVAL) 155 | end 156 | 157 | -- 定时查询流量 158 | if type(config.QUERY_TRAFFIC_INTERVAL) == "number" and config.QUERY_TRAFFIC_INTERVAL >= 1000 * 60 then 159 | sys.timerLoopStart(util_mobile.queryTraffic, config.QUERY_TRAFFIC_INTERVAL) 160 | end 161 | 162 | -- 定时基站定位 163 | if type(config.LOCATION_INTERVAL) == "number" and config.LOCATION_INTERVAL >= 1000 * 60 then 164 | util_location.refresh(nil, true) 165 | sys.timerLoopStart(util_location.refresh, config.LOCATION_INTERVAL) 166 | end 167 | 168 | -- 定时上报 169 | if type(config.REPORT_INTERVAL) == "number" and config.REPORT_INTERVAL >= 1000 * 60 then 170 | sys.timerLoopStart(function() util_notify.add("#ALIVE_REPORT") end, config.REPORT_INTERVAL) 171 | end 172 | 173 | -- 电源键短按发送测试通知 174 | sys.subscribe("POWERKEY_SHORT_PRESS", function() util_notify.add("#ALIVE") end) 175 | -- 电源键长按查询流量 176 | sys.subscribe("POWERKEY_LONG_PRESS", util_mobile.queryTraffic) 177 | 178 | sys.wait(60000); 179 | -- EC618配置小区重选信号差值门限,不能大于15dbm,必须在飞行模式下才能用 180 | mobile.flymode(0, true) 181 | mobile.config(mobile.CONF_RESELTOWEAKNCELL, 10) 182 | mobile.config(mobile.CONF_STATICCONFIG, 1) -- 开启网络静态优化 183 | mobile.flymode(0, false) 184 | end) 185 | 186 | sys.taskInit(function() 187 | if type(config.PIN_CODE) ~= "string" or config.PIN_CODE == "" then 188 | return 189 | end 190 | -- 开机等待 5 秒仍未联网, 再进行 pin 验证 191 | if not sys.waitUntil("IP_READY", 1000 * 5) then 192 | util_mobile.pinVerify(config.PIN_CODE) 193 | end 194 | end) 195 | 196 | -- 定时开关飞行模式 197 | if type(config.FLYMODE_INTERVAL) == "number" and config.FLYMODE_INTERVAL >= 1000 * 60 then 198 | sys.timerLoopStart(function() 199 | sys.taskInit(function() 200 | log.info("main", "定时开关飞行模式") 201 | mobile.reset() 202 | sys.wait(1000) 203 | mobile.flymode(0, true) 204 | mobile.flymode(0, false) 205 | end) 206 | end, config.FLYMODE_INTERVAL) 207 | end 208 | 209 | -- 通话相关 210 | local is_calling = false 211 | 212 | sys.subscribe("CC_IND", function(status) 213 | if cc == nil then return end 214 | 215 | if status == "INCOMINGCALL" then 216 | -- 来电事件, 期间会重复触发 217 | if is_calling then return end 218 | is_calling = true 219 | 220 | log.info("cc_status", "INCOMINGCALL", "来电事件", cc.lastNum()) 221 | 222 | -- 发送通知 223 | util_notify.add({ "来电号码: " .. cc.lastNum(), "来电时间: " .. os.date("%Y-%m-%d %H:%M:%S"), "#CALL #CALL_IN" }) 224 | return 225 | end 226 | 227 | if status == "DISCONNECTED" then 228 | -- 挂断事件 229 | is_calling = false 230 | log.info("cc_status", "DISCONNECTED", "挂断事件", cc.lastNum()) 231 | 232 | -- 发送通知 233 | util_notify.add({ "来电号码: " .. cc.lastNum(), "挂断时间: " .. os.date("%Y-%m-%d %H:%M:%S"), "#CALL #CALL_DISCONNECTED" }) 234 | return 235 | end 236 | 237 | log.info("cc_status", status) 238 | end) 239 | 240 | sys.run() 241 | -------------------------------------------------------------------------------- /script/util_http.lua: -------------------------------------------------------------------------------- 1 | local util_http = {} 2 | 3 | -- 用于生成 http 请求的 id 4 | local http_count = 0 5 | -- 记录正在运行的 http 请求数量 6 | local http_running_count = 0 7 | 8 | local luat_http_code_desc = { 9 | [0] = "HTTP_OK", 10 | [-1] = "HTTP_ERROR_STATE", 11 | [-2] = "HTTP_ERROR_HEADER", 12 | [-3] = "HTTP_ERROR_BODY", 13 | [-4] = "HTTP_ERROR_CONNECT", 14 | [-5] = "HTTP_ERROR_CLOSE", 15 | [-6] = "HTTP_ERROR_RX", 16 | [-7] = "HTTP_ERROR_DOWNLOAD", 17 | [-8] = "HTTP_ERROR_TIMEOUT", 18 | [-9] = "HTTP_ERROR_FOTA", 19 | } 20 | 21 | --- 对 http.request 的封装 22 | -- @param timeout 超时时间(单位: 毫秒) 23 | -- @param method 请求方法 24 | -- @param url 请求地址 25 | -- @param headers 请求头 26 | -- @param body 请求体 27 | function util_http.fetch(timeout, method, url, headers, body) 28 | collectgarbage("collect") 29 | 30 | timeout = timeout or 1000 * 20 31 | local opts = { timeout = timeout } 32 | 33 | http_count = http_count + 1 34 | http_running_count = http_running_count + 1 35 | 36 | local id = "http_" .. http_count 37 | local res_code, res_headers, res_body = -99, {}, "" 38 | 39 | util_netled.blink(50, 50) 40 | 41 | log.debug("util_http.fetch", "开始请求", "id:", id) 42 | res_code, res_headers, res_body = http.request(method, url, headers, body, opts).wait() 43 | log.debug("util_http.fetch", "请求结束", "id:", id, "code:", res_code, "desc:", luat_http_code_desc[res_code]) 44 | 45 | http_running_count = http_running_count - 1 46 | if http_running_count == 0 then 47 | util_netled.blink() 48 | end 49 | 50 | collectgarbage("collect") 51 | 52 | return res_code, res_headers, res_body 53 | end 54 | 55 | return util_http 56 | -------------------------------------------------------------------------------- /script/util_location.lua: -------------------------------------------------------------------------------- 1 | local util_location = {} 2 | 3 | PRODUCT_KEY = "v32xEAKsGTIEQxtqgwCldp5aPlcnPs3K" 4 | local lbsLoc = require("lbsLoc") 5 | 6 | local cache = { lbs_data = { lat = 0, lng = 0 } } 7 | 8 | --- 格式化经纬度 (保留小数点后 6 位, 去除末尾的 0) 9 | -- @param value 经纬度 10 | -- @return 格式化后的经纬度 11 | local function formatCoord(value) 12 | local str = string.format("%.6f", tonumber(value) or 0) 13 | str = str:gsub("%.?0+$", "") 14 | return tonumber(str) 15 | end 16 | 17 | --- 生成地图链接 18 | -- @param lat 纬度 19 | -- @param lng 经度 20 | -- @return 地图链接 or "" 21 | local function getMapLink(lat, lng) 22 | lat, lng = lat or 0, lng or 0 23 | local map_link = "" 24 | if lat ~= 0 and lng ~= 0 then map_link = "http://apis.map.qq.com/uri/v1/marker?coord_type=1&marker=title:+;coord:" .. lat .. "," .. lng end 25 | log.debug("util_location.getMapLink", map_link) 26 | return map_link 27 | end 28 | 29 | --- lbsLoc.request 回调 30 | local function getLocCb(result, lat, lng, addr, time, locType) 31 | log.info("util_location.getLocCb", "result,lat,lng,time,locType:", result, lat, lng, time and time:toHex(), locType) 32 | -- 获取经纬度成功, 坐标系WGS84 33 | if result == 0 and lat and lng then 34 | cache.lbs_data = { lat, lng } 35 | end 36 | end 37 | 38 | --- 刷新基站信息 39 | -- @param timeout 超时时间(单位: 秒) 40 | local function refreshCellInfo(timeout) 41 | log.info("util_location.refreshCellInfo", "start") 42 | if cache.is_req_cell_info_running then 43 | log.info("util_location.refreshCellInfo", "running, wait...") 44 | else 45 | cache.is_req_cell_info_running = true 46 | mobile.reqCellInfo(timeout or 20) -- 单位: 秒 47 | end 48 | sys.waitUntil("CELL_INFO_UPDATE") 49 | cache.is_req_cell_info_running = false 50 | log.info("util_location.refreshCellInfo", "end") 51 | end 52 | 53 | --- 刷新基站定位信息 54 | -- @param timeout 超时时间(单位: 毫秒) 55 | function util_location.refresh(timeout) 56 | timeout = type(timeout) == "number" and timeout or nil 57 | 58 | sys.taskInit(function() 59 | refreshCellInfo() 60 | lbsLoc.request(getLocCb, nil, timeout) 61 | end) 62 | end 63 | 64 | --- 获取位置信息 65 | -- @return lat 66 | -- @return lng 67 | -- @return map_link 68 | function util_location.get() 69 | local lat, lng = unpack(cache.lbs_data) 70 | lat, lng = formatCoord(lat), formatCoord(lng) 71 | return lat, lng, getMapLink(lat, lng) 72 | end 73 | 74 | sys.taskInit(refreshCellInfo) 75 | 76 | sys.subscribe("CELL_INFO_UPDATE", function() log.debug("EVENT.CELL_INFO_UPDATE") end) 77 | 78 | return util_location 79 | -------------------------------------------------------------------------------- /script/util_mobile.lua: -------------------------------------------------------------------------------- 1 | local util_mobile = {} 2 | 3 | --- 验证 pin 码 4 | -- @param pin_code string, pin 码 5 | function util_mobile.pinVerify(pin_code) 6 | local sim_id = mobile.simid() 7 | 8 | pin_code = tostring(pin_code or "") 9 | if #pin_code < 4 or #pin_code > 8 then 10 | log.warn("util_mobile.pinVerify", "pin 码长度不正确") 11 | return 12 | end 13 | 14 | local cpin_is_ready = mobile.simPin(sim_id) 15 | if cpin_is_ready then 16 | log.info("util_mobile.pinVerify", "无需验证 pin 码") 17 | return 18 | end 19 | 20 | cpin_is_ready = mobile.simPin(sim_id, mobile.PIN_VERIFY, pin_code) 21 | log.info("util_mobile.pinVerify", "验证 pin 码" .. (cpin_is_ready and "成功" or "失败")) 22 | end 23 | 24 | -- 运营商数据 25 | local oper_data = { 26 | -- 中国移动 27 | ["46000"] = { "CM", "中国移动", { "10086", "CXLL" } }, 28 | ["46002"] = { "CM", "中国移动", { "10086", "CXLL" } }, 29 | ["46007"] = { "CM", "中国移动", { "10086", "CXLL" } }, 30 | ["46008"] = { "CM", "中国移动", { "10086", "CXLL" } }, 31 | -- 中国联通 32 | ["46001"] = { "CU", "中国联通", { "10010", "2082" } }, 33 | ["46006"] = { "CU", "中国联通", { "10010", "2082" } }, 34 | ["46009"] = { "CU", "中国联通", { "10010", "2082" } }, 35 | ["46010"] = { "CU", "中国联通", { "10010", "2082" } }, 36 | -- 中国电信 37 | ["46003"] = { "CT", "中国电信", { "10001", "108" } }, 38 | ["46005"] = { "CT", "中国电信", { "10001", "108" } }, 39 | ["46011"] = { "CT", "中国电信", { "10001", "108" } }, 40 | ["46012"] = { "CT", "中国电信", { "10001", "108" } }, 41 | -- 中国广电 42 | ["46015"] = { "CB", "中国广电" }, 43 | } 44 | 45 | --- 获取 MCC 和 MNC 46 | -- @return MCC or -1 47 | -- @return MNC or -1 48 | function util_mobile.getMccMnc() 49 | local imsi = mobile.imsi(mobile.simid()) or "" 50 | return string.sub(imsi, 1, 3) or -1, string.sub(imsi, 4, 5) or -1 51 | end 52 | 53 | --- 获取 Band 54 | -- @return Band or -1 55 | function util_mobile.getBand() 56 | local info = mobile.getCellInfo()[1] or {} 57 | return info.band or -1 58 | end 59 | 60 | --- 获取运营商 61 | -- @param is_zh 是否返回中文 62 | -- @return 运营商 or "" 63 | function util_mobile.getOper(is_zh) 64 | local imsi = mobile.imsi(mobile.simid()) or "" 65 | local mcc, mnc = string.sub(imsi, 1, 3), string.sub(imsi, 4, 5) 66 | local mcc_mnc = mcc .. mnc 67 | 68 | local oper = oper_data[mcc_mnc] 69 | if oper then 70 | return is_zh and oper[2] or oper[1] 71 | else 72 | return mcc_mnc 73 | end 74 | end 75 | 76 | --- 发送查询流量短信 77 | function util_mobile.queryTraffic() 78 | local imsi = mobile.imsi(mobile.simid()) or "" 79 | local mcc_mnc = string.sub(imsi, 1, 5) 80 | 81 | local oper = oper_data[mcc_mnc] 82 | if oper and oper[3] then 83 | sms.send(oper[3][1], oper[3][2]) 84 | else 85 | log.warn("util_mobile.queryTraffic", "查询流量代码未配置") 86 | end 87 | end 88 | 89 | --- 获取网络状态 90 | -- @return 网络状态 91 | function util_mobile.status() 92 | local codes = { 93 | [0] = "网络未注册", 94 | [1] = "网络已注册", 95 | [2] = "网络搜索中", 96 | [3] = "网络注册被拒绝", 97 | [4] = "网络状态未知", 98 | [5] = "网络已注册,漫游", 99 | [6] = "网络已注册,仅SMS", 100 | [7] = "网络已注册,漫游,仅SMS", 101 | [8] = "网络已注册,紧急服务", 102 | [9] = "网络已注册,非主要服务", 103 | [10] = "网络已注册,非主要服务,漫游", 104 | } 105 | local mobile_status = mobile.status() 106 | if mobile_status and mobile_status >= 0 and mobile_status <= 10 then 107 | return codes[mobile_status] or "未知网络状态" 108 | end 109 | return "未知网络状态" 110 | end 111 | 112 | --- 追加设备信息 113 | --- @return string 114 | function util_mobile.appendDeviceInfo() 115 | local msg = "\n" 116 | 117 | -- 本机号码 118 | local number = mobile.number(mobile.simid()) or config.FALLBACK_LOCAL_NUMBER 119 | if number then 120 | msg = msg .. "\n本机号码: " .. number 121 | end 122 | 123 | -- 开机时长 124 | local ms = mcu.ticks() 125 | local seconds = math.floor(ms / 1000) 126 | local minutes = math.floor(seconds / 60) 127 | local hours = math.floor(minutes / 60) 128 | seconds = seconds % 60 129 | minutes = minutes % 60 130 | local boot_time = string.format("%02d:%02d:%02d", hours, minutes, seconds) 131 | if ms >= 0 then 132 | msg = msg .. "\n开机时长: " .. boot_time 133 | end 134 | 135 | -- 运营商 136 | local oper = util_mobile.getOper(true) 137 | if oper ~= "" then 138 | msg = msg .. "\n运营商: " .. oper 139 | end 140 | 141 | -- 信号 142 | msg = msg .. "\n信号: " .. mobile.rsrp() .. "dBm" 143 | 144 | -- 频段 145 | -- local band = util_mobile.getBand() 146 | -- if band >= 0 then 147 | -- msg = msg .. "\n频段: B" .. band 148 | -- end 149 | 150 | -- 电压, 读取 VBAT 供电电压, 单位为 mV 151 | -- adc.open(adc.CH_VBAT) 152 | -- local vbat = adc.get(adc.CH_VBAT) 153 | -- adc.close(adc.CH_VBAT) 154 | -- if vbat >= 0 then 155 | -- msg = msg .. "\n电压: " .. string.format("%.1f", vbat / 1000) .. "V" 156 | -- end 157 | 158 | -- 温度 159 | -- adc.open(adc.CH_CPU) 160 | -- local temp = adc.get(adc.CH_CPU) 161 | -- adc.close(adc.CH_CPU) 162 | -- if temp >= 0 then 163 | -- msg = msg .. "\n温度: " .. string.format("%.1f", temp / 1000) .. "°C" 164 | -- end 165 | 166 | -- 基站信息 167 | -- msg = msg .. "\nECI: " .. mobile.eci() 168 | -- msg = msg .. "\nTAC: " .. mobile.tac() 169 | -- msg = msg .. "\nENBID: " .. mobile.enbid() 170 | 171 | -- 流量统计 172 | -- local uplinkGB, uplinkB, downlinkGB, downlinkB = mobile.dataTraffic() 173 | -- uplinkB = uplinkGB * 1024 * 1024 * 1024 + uplinkB 174 | -- downlinkB = downlinkGB * 1024 * 1024 * 1024 + downlinkB 175 | -- local function formatBytes(bytes) 176 | -- if bytes < 1024 then 177 | -- return bytes .. "B" 178 | -- elseif bytes < 1024 * 1024 then 179 | -- return string.format("%.2fKB", bytes / 1024) 180 | -- elseif bytes < 1024 * 1024 * 1024 then 181 | -- return string.format("%.2fMB", bytes / 1024 / 1024) 182 | -- else 183 | -- return string.format("%.2fGB", bytes / 1024 / 1024 / 1024) 184 | -- end 185 | -- end 186 | -- msg = msg .. "\n流量: ↑" .. formatBytes(uplinkB) .. " ↓" .. formatBytes(downlinkB) 187 | 188 | -- 位置 189 | local _, _, map_link = util_location.get() 190 | if map_link ~= "" then 191 | msg = msg .. "\n位置: " .. map_link -- 这里使用 U+00a0 防止换行 192 | end 193 | 194 | return msg 195 | end 196 | 197 | return util_mobile 198 | -------------------------------------------------------------------------------- /script/util_netled.lua: -------------------------------------------------------------------------------- 1 | local util_netled = {} 2 | 3 | local netled_default_duration = 200 4 | local netled_default_interval = 3000 5 | 6 | local netled_duration = netled_default_duration 7 | local netled_interval = netled_default_interval 8 | 9 | local netled_inited = false 10 | 11 | -- 开机时呼吸灯效果 12 | sys.taskInit(function() 13 | local nums = { 0, 1, 2, 4, 6, 12, 16, 21, 27, 34, 42, 51, 61, 72, 85, 100, 100 } 14 | local len = #nums 15 | while true do 16 | for i = 1, len, 1 do 17 | pwm.open(4, 1000, nums[i]) 18 | result = sys.waitUntil("NET_LED_INIT", 25) 19 | if result then 20 | pwm.close(4) 21 | return 22 | end 23 | end 24 | for i = len, 1, -1 do 25 | pwm.open(4, 1000, nums[i]) 26 | result = sys.waitUntil("NET_LED_INIT", 25) 27 | if result then 28 | pwm.close(4) 29 | return 30 | end 31 | end 32 | end 33 | end) 34 | 35 | -- 注册网络后开始闪烁 36 | function util_netled.init() 37 | if netled_inited then return end 38 | netled_inited = true 39 | sys.publish("NET_LED_INIT") 40 | 41 | sys.taskInit(function() 42 | local netled = gpio.setup(27, 0, gpio.PULLUP) 43 | while true do 44 | netled(1) 45 | sys.waitUntil("NET_LED_UPDATE", netled_duration) 46 | netled(0) 47 | sys.waitUntil("NET_LED_UPDATE", netled_interval) 48 | end 49 | end) 50 | end 51 | 52 | function util_netled.blink(duration, interval, restore) 53 | if duration == netled_duration and interval == netled_interval then return end 54 | netled_duration = duration or netled_default_duration 55 | netled_interval = interval or netled_default_interval 56 | log.debug("EVENT.NET_LED_UPDATE", duration, interval, restore) 57 | sys.publish("NET_LED_UPDATE") 58 | if restore then sys.timerStart(util_netled.blink, restore) end 59 | end 60 | 61 | return util_netled 62 | -------------------------------------------------------------------------------- /script/util_notify.lua: -------------------------------------------------------------------------------- 1 | local util_notify_channel = require "util_notify_channel" 2 | 3 | local util_notify = {} 4 | 5 | -- 消息队列 6 | local msg_queue = {} 7 | -- 发送计数 8 | local msg_count = 0 9 | local error_count = 0 10 | 11 | --- 发送通知 12 | -- @param msg 消息内容 13 | -- @param channel 通知渠道 14 | -- @return true: 无需重发, false: 需要重发 15 | local function send(msg, channel) 16 | log.info("util_notify.send", "发送通知", channel) 17 | 18 | -- 判断消息内容 msg 19 | if type(msg) ~= "string" or msg == "" then 20 | log.error("util_notify.send", "发送通知失败", "msg 参数错误", type(msg)) 21 | return true 22 | end 23 | 24 | -- 判断通知渠道 channel 25 | if channel and util_notify_channel[channel] == nil then 26 | log.error("util_notify.send", "发送通知失败", "未知通知渠道", channel) 27 | return true 28 | end 29 | 30 | -- 发送通知 31 | local code, headers, body = util_notify_channel[channel](msg) 32 | if code == nil then 33 | log.info("util_notify.send", "发送通知失败, 无需重发", "code:", code, "body:", body) 34 | return true 35 | end 36 | if code >= 200 and code < 500 and code ~= 408 and code ~= 409 and code ~= 425 and code ~= 429 then 37 | log.info("util_notify.send", "发送通知成功", "code:", code, "body:", body) 38 | return true 39 | end 40 | log.error("util_notify.send", "发送通知失败, 等待重发", "code:", code, "body:", body) 41 | return false 42 | end 43 | 44 | --- 添加到消息队列 45 | -- @param msg 消息内容 46 | -- @param channels 通知渠道 47 | -- @param id 消息唯一标识 48 | function util_notify.add(msg, channels, id) 49 | msg_count = msg_count + 1 50 | 51 | if id == nil or id == "" then 52 | id = "msg-t" .. os.time() .. "c" .. msg_count .. "r" .. math.random(9999) 53 | end 54 | 55 | if type(msg) == "table" then 56 | msg = table.concat(msg, "\n") 57 | end 58 | 59 | channels = channels or config.NOTIFY_TYPE 60 | if type(channels) ~= "table" then 61 | channels = { channels } 62 | end 63 | 64 | for _, channel in ipairs(channels) do 65 | table.insert(msg_queue, { id = id, channel = channel, msg = msg, retry = 0 }) 66 | end 67 | sys.publish("NEW_MSG") 68 | log.debug("util_notify.add", "添加到消息队列, 当前队列长度:", #msg_queue, "消息内容:", msg:gsub("\r", "\\r"):gsub("\n", "\\n")) 69 | end 70 | 71 | --- 轮询消息队列, 发送成功则从队列中删除, 发送失败则等待下次 72 | local function poll() 73 | -- 打印网络状态 74 | if mobile.status() ~= 1 then 75 | log.warn("util_notify.poll", "mobile.status", mobile_status, util_mobile.status()) 76 | end 77 | 78 | -- 消息队列非空 79 | if next(msg_queue) == nil then 80 | sys.waitUntil("NEW_MSG", 1000 * 10) 81 | return 82 | end 83 | 84 | local item = msg_queue[1] 85 | table.remove(msg_queue, 1) 86 | local msg = item.msg 87 | log.info("util_notify.poll", "轮询消息队列中", "总长度: " .. #msg_queue, "当前ID: " .. item.id, "当前重发次数: " .. item.retry, "连续失败次数: " .. error_count) 88 | 89 | -- 通知内容添加设备信息 90 | if config.NOTIFY_APPEND_MORE_INFO and not string.find(msg, "开机时长:") then 91 | msg = msg .. util_mobile.appendDeviceInfo() 92 | end 93 | -- 通知内容添加重发次数 94 | if error_count > 0 then 95 | msg = msg .. "\n重发次数: " .. error_count 96 | end 97 | 98 | -- 超过最大重发次数 99 | if item.retry > (config.NOTIFY_RETRY_MAX or 20) then 100 | log.warn("util_notify.poll", "超过最大重发次数, 放弃重发", item.msg) 101 | return 102 | end 103 | 104 | -- 开始发送 105 | local result = send(msg, item.channel) 106 | 107 | -- 发送成功 108 | if result then 109 | error_count = 0 110 | -- 检查 fskv 中如果存在则删除 111 | if fskv.get(item.id) then 112 | fskv.del(item.id) 113 | end 114 | return 115 | end 116 | 117 | -- 发送失败 118 | error_count = error_count + 1 119 | item.retry = item.retry + 1 120 | table.insert(msg_queue, item) 121 | log.info("util_notify.poll", "等待下次重发", "当前重发次数", item.retry, "连续失败次数", error_count) 122 | sys.waitUntil("IP_READY", 1000 * 5) 123 | 124 | -- 每连续失败 2 次, 开关飞行模式 125 | if error_count % 2 == 0 then 126 | -- 开关飞行模式 127 | log.warn("util_notify.poll", "连续失败次数过多, 重启协议栈 & 开关飞行模式") 128 | mobile.reset() 129 | sys.wait(1000) 130 | mobile.flymode(0, true) 131 | mobile.flymode(0, false) 132 | sys.wait(1000) 133 | end 134 | 135 | -- 每条消息第 1 次重发失败后, 保存到 fskv, 断电开机可恢复重发 136 | if item.retry == 1 then 137 | if not (string.find(item.msg, "#SMS") or string.find(item.msg, "#CALL")) then 138 | return 139 | end 140 | log.info("util_notify.poll", "当前第 1 次重发失败, 保存到 fskv", item.id) 141 | if fskv.get(item.id) then 142 | log.info("util_notify.poll", "fskv 已存在, 跳过写入", item.id) 143 | return 144 | end 145 | local kv_set_result = fskv.set(item.id, item.msg) 146 | log.info("util_notify.poll", "fskv.set", kv_set_result, "used,total,count:", fskv.status()) 147 | end 148 | end 149 | 150 | sys.taskInit(function() 151 | while true do 152 | poll() 153 | sys.wait(100) 154 | end 155 | end) 156 | 157 | sys.taskInit(function() 158 | sys.waitUntil("IP_READY") 159 | sys.wait(10000) 160 | 161 | local iter = fskv.iter() 162 | while iter do 163 | local k = fskv.next(iter) 164 | if not k then 165 | break 166 | end 167 | local v = fskv.get(k) 168 | if not (v and v ~= "" and string.find(k, "msg-")) then 169 | break 170 | end 171 | log.info("util_notify", "检查到 fskv 中有历史消息", k, v:gsub("\r", "\\r"):gsub("\n", "\\n")) 172 | util_notify.add(v .. "\n#FSKV", nil, k) 173 | end 174 | end) 175 | 176 | return util_notify 177 | -------------------------------------------------------------------------------- /script/util_notify_channel.lua: -------------------------------------------------------------------------------- 1 | local lib_smtp = require "lib_smtp" 2 | 3 | local function urlencodeTab(params) 4 | local msg = {} 5 | for k, v in pairs(params) do 6 | table.insert(msg, string.urlEncode(k) .. "=" .. string.urlEncode(v)) 7 | table.insert(msg, "&") 8 | end 9 | table.remove(msg) 10 | return table.concat(msg) 11 | end 12 | 13 | return { 14 | -- 发送到 custom_post 15 | ["custom_post"] = function(msg) 16 | if config.CUSTOM_POST_URL == nil or config.CUSTOM_POST_URL == "" then 17 | log.error("util_notify", "未配置 `config.CUSTOM_POST_URL`") 18 | return 19 | end 20 | if type(config.CUSTOM_POST_BODY_TABLE) ~= "table" then 21 | log.error("util_notify", "未配置 `config.CUSTOM_POST_BODY_TABLE`") 22 | return 23 | end 24 | 25 | local header = { ["content-type"] = config.CUSTOM_POST_CONTENT_TYPE } 26 | local body = json.decode(json.encode(config.CUSTOM_POST_BODY_TABLE)) 27 | -- 遍历并替换其中的变量 28 | local function traverse_and_replace(t) 29 | for k, v in pairs(t) do 30 | if type(v) == "table" then 31 | traverse_and_replace(v) 32 | elseif type(v) == "string" then 33 | t[k] = string.gsub(v, "{msg}", msg) 34 | end 35 | end 36 | end 37 | traverse_and_replace(body) 38 | 39 | -- 根据 content-type 进行编码, 默认为 application/x-www-form-urlencoded 40 | if string.find(config.CUSTOM_POST_CONTENT_TYPE, "json") then 41 | body = json.encode(body) 42 | else 43 | body = urlencodeTab(body) 44 | end 45 | 46 | log.info("util_notify", "POST", config.CUSTOM_POST_URL, config.CUSTOM_POST_CONTENT_TYPE, body) 47 | return util_http.fetch(nil, "POST", config.CUSTOM_POST_URL, header, body) 48 | end, 49 | -- 发送到 telegram 50 | ["telegram"] = function(msg) 51 | if config.TELEGRAM_API == nil or config.TELEGRAM_API == "" then 52 | log.error("util_notify", "未配置 `config.TELEGRAM_API`") 53 | return 54 | end 55 | if config.TELEGRAM_CHAT_ID == nil or config.TELEGRAM_CHAT_ID == "" then 56 | log.error("util_notify", "未配置 `config.TELEGRAM_CHAT_ID`") 57 | return 58 | end 59 | 60 | local header = { ["content-type"] = "application/json" } 61 | local body = { ["chat_id"] = config.TELEGRAM_CHAT_ID, ["disable_web_page_preview"] = true, ["text"] = msg } 62 | 63 | log.info("util_notify", "POST", config.TELEGRAM_API) 64 | return util_http.fetch(nil, "POST", config.TELEGRAM_API, header, json.encode(body)) 65 | end, 66 | -- 发送到 gotify 67 | ["gotify"] = function(msg) 68 | if config.GOTIFY_API == nil or config.GOTIFY_API == "" then 69 | log.error("util_notify", "未配置 `config.GOTIFY_API`") 70 | return 71 | end 72 | if config.GOTIFY_TOKEN == nil or config.GOTIFY_TOKEN == "" then 73 | log.error("util_notify", "未配置 `config.GOTIFY_TOKEN`") 74 | return 75 | end 76 | 77 | local url = config.GOTIFY_API .. "/message?token=" .. config.GOTIFY_TOKEN 78 | local header = { ["Content-Type"] = "application/json; charset=utf-8" } 79 | local body = { title = config.GOTIFY_TITLE, message = msg, priority = config.GOTIFY_PRIORITY } 80 | 81 | log.info("util_notify", "POST", config.GOTIFY_API) 82 | return util_http.fetch(nil, "POST", url, header, json.encode(body)) 83 | end, 84 | -- 发送到 pushdeer 85 | ["pushdeer"] = function(msg) 86 | if config.PUSHDEER_API == nil or config.PUSHDEER_API == "" then 87 | log.error("util_notify", "未配置 `config.PUSHDEER_API`") 88 | return 89 | end 90 | if config.PUSHDEER_KEY == nil or config.PUSHDEER_KEY == "" then 91 | log.error("util_notify", "未配置 `config.PUSHDEER_KEY`") 92 | return 93 | end 94 | 95 | local header = { ["Content-Type"] = "application/x-www-form-urlencoded" } 96 | local body = { pushkey = config.PUSHDEER_KEY or "", type = "text", text = msg } 97 | 98 | log.info("util_notify", "POST", config.PUSHDEER_API) 99 | return util_http.fetch(nil, "POST", config.PUSHDEER_API, header, urlencodeTab(body)) 100 | end, 101 | -- 发送到 bark 102 | ["bark"] = function(msg) 103 | if config.BARK_API == nil or config.BARK_API == "" then 104 | log.error("util_notify", "未配置 `config.BARK_API`") 105 | return 106 | end 107 | if config.BARK_KEY == nil or config.BARK_KEY == "" then 108 | log.error("util_notify", "未配置 `config.BARK_KEY`") 109 | return 110 | end 111 | 112 | local header = { ["Content-Type"] = "application/x-www-form-urlencoded" } 113 | local body = { body = msg } 114 | local url = config.BARK_API .. "/" .. config.BARK_KEY 115 | 116 | log.info("util_notify", "POST", url) 117 | return util_http.fetch(nil, "POST", url, header, urlencodeTab(body)) 118 | end, 119 | -- 发送到 dingtalk 120 | ["dingtalk"] = function(msg) 121 | if config.DINGTALK_WEBHOOK == nil or config.DINGTALK_WEBHOOK == "" then 122 | log.error("util_notify", "未配置 `config.DINGTALK_WEBHOOK`") 123 | return 124 | end 125 | 126 | local url = config.DINGTALK_WEBHOOK 127 | -- 如果配置了 config.DINGTALK_SECRET 则需要签名(加签), 没配置则为自定义关键词 128 | if (config.DINGTALK_SECRET and config.DINGTALK_SECRET ~= "") then 129 | -- 时间异常则等待同步 130 | if os.time() < 1714500000 then 131 | socket.sntp() 132 | sys.waitUntil("NTP_UPDATE", 1000 * 10) 133 | end 134 | local timestamp = tostring(os.time()) .. "000" 135 | local sign = crypto.hmac_sha256(timestamp .. "\n" .. config.DINGTALK_SECRET, config.DINGTALK_SECRET):fromHex():toBase64():urlEncode() 136 | url = url .. "×tamp=" .. timestamp .. "&sign=" .. sign 137 | end 138 | 139 | local header = { ["Content-Type"] = "application/json; charset=utf-8" } 140 | local body = { msgtype = "text", text = { content = msg } } 141 | body = json.encode(body) 142 | 143 | log.info("util_notify", "POST", url) 144 | local res_code, res_headers, res_body = util_http.fetch(nil, "POST", url, header, body) 145 | 146 | -- 处理响应 147 | -- https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages 148 | if res_code == 200 and res_body and res_body ~= "" then 149 | local res_data = json.decode(res_body) 150 | local res_errcode = res_data.errcode or 0 151 | local res_errmsg = res_data.errmsg or "" 152 | -- 系统繁忙 / 发送速度太快而限流 153 | if res_errcode == -1 or res_errcode == 410100 then 154 | return 500, res_headers, res_body 155 | end 156 | -- timestamp 无效 157 | if res_errcode == 310000 and (string.find(res_errmsg, "timestamp") or string.find(res_errmsg, "过期")) then 158 | socket.sntp() 159 | return 500, res_headers, res_body 160 | end 161 | end 162 | return res_code, res_headers, res_body 163 | end, 164 | -- 发送到 feishu 165 | ["feishu"] = function(msg) 166 | if config.FEISHU_WEBHOOK == nil or config.FEISHU_WEBHOOK == "" then 167 | log.error("util_notify", "未配置 `config.FEISHU_WEBHOOK`") 168 | return 169 | end 170 | 171 | local header = { ["Content-Type"] = "application/json; charset=utf-8" } 172 | local body = { msg_type = "text", content = { text = msg } } 173 | 174 | log.info("util_notify", "POST", config.FEISHU_WEBHOOK) 175 | return util_http.fetch(nil, "POST", config.FEISHU_WEBHOOK, header, json.encode(body)) 176 | end, 177 | -- 发送到 wecom 178 | ["wecom"] = function(msg) 179 | if config.WECOM_WEBHOOK == nil or config.WECOM_WEBHOOK == "" then 180 | log.error("util_notify", "未配置 `config.WECOM_WEBHOOK`") 181 | return 182 | end 183 | 184 | local header = { ["Content-Type"] = "application/json; charset=utf-8" } 185 | local body = { msgtype = "text", text = { content = msg } } 186 | 187 | log.info("util_notify", "POST", config.WECOM_WEBHOOK) 188 | local res_code, res_headers, res_body = util_http.fetch(nil, "POST", config.WECOM_WEBHOOK, header, json.encode(body)) 189 | 190 | -- 处理响应 191 | -- https://developer.work.weixin.qq.com/document/path/90313 192 | if res_code == 200 and res_body and res_body ~= "" then 193 | local res_data = json.decode(res_body) 194 | local res_errcode = res_data.errcode or 0 195 | -- 系统繁忙 / 接口调用超过限制 196 | if res_errcode == -1 or res_errcode == 45009 then 197 | return 500, res_headers, res_body 198 | end 199 | end 200 | return res_code, res_headers, res_body 201 | end, 202 | -- 发送到 pushover 203 | ["pushover"] = function(msg) 204 | if config.PUSHOVER_API_TOKEN == nil or config.PUSHOVER_API_TOKEN == "" then 205 | log.error("util_notify", "未配置 `config.PUSHOVER_API_TOKEN`") 206 | return 207 | end 208 | if config.PUSHOVER_USER_KEY == nil or config.PUSHOVER_USER_KEY == "" then 209 | log.error("util_notify", "未配置 `config.PUSHOVER_USER_KEY`") 210 | return 211 | end 212 | 213 | local header = { ["Content-Type"] = "application/json; charset=utf-8" } 214 | local body = { token = config.PUSHOVER_API_TOKEN, user = config.PUSHOVER_USER_KEY, message = msg } 215 | local url = "https://api.pushover.net/1/messages.json" 216 | 217 | log.info("util_notify", "POST", url) 218 | return util_http.fetch(nil, "POST", url, header, json.encode(body)) 219 | end, 220 | -- 发送到 inotify 221 | ["inotify"] = function(msg) 222 | if config.INOTIFY_API == nil or config.INOTIFY_API == "" then 223 | log.error("util_notify", "未配置 `config.INOTIFY_API`") 224 | return 225 | end 226 | if not config.INOTIFY_API:endsWith(".send") then 227 | log.error("util_notify", "`config.INOTIFY_API` 必须以 `.send` 结尾") 228 | return 229 | end 230 | 231 | local url = config.INOTIFY_API .. "/" .. string.urlEncode(msg) 232 | 233 | log.info("util_notify", "GET", url) 234 | return util_http.fetch(nil, "GET", url) 235 | end, 236 | -- 发送到 next-smtp-proxy 237 | ["next-smtp-proxy"] = function(msg) 238 | if config.NEXT_SMTP_PROXY_API == nil or config.NEXT_SMTP_PROXY_API == "" then 239 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_API`") 240 | return 241 | end 242 | if config.NEXT_SMTP_PROXY_USER == nil or config.NEXT_SMTP_PROXY_USER == "" then 243 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_USER`") 244 | return 245 | end 246 | if config.NEXT_SMTP_PROXY_PASSWORD == nil or config.NEXT_SMTP_PROXY_PASSWORD == "" then 247 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PASSWORD`") 248 | return 249 | end 250 | if config.NEXT_SMTP_PROXY_HOST == nil or config.NEXT_SMTP_PROXY_HOST == "" then 251 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_HOST`") 252 | return 253 | end 254 | if config.NEXT_SMTP_PROXY_PORT == nil or config.NEXT_SMTP_PROXY_PORT == "" then 255 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PORT`") 256 | return 257 | end 258 | if config.NEXT_SMTP_PROXY_TO_EMAIL == nil or config.NEXT_SMTP_PROXY_TO_EMAIL == "" then 259 | log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_TO_EMAIL`") 260 | return 261 | end 262 | 263 | local header = { ["Content-Type"] = "application/x-www-form-urlencoded" } 264 | local body = { 265 | user = config.NEXT_SMTP_PROXY_USER, 266 | password = config.NEXT_SMTP_PROXY_PASSWORD, 267 | host = config.NEXT_SMTP_PROXY_HOST, 268 | port = config.NEXT_SMTP_PROXY_PORT, 269 | form_name = config.NEXT_SMTP_PROXY_FORM_NAME, 270 | to_email = config.NEXT_SMTP_PROXY_TO_EMAIL, 271 | subject = config.NEXT_SMTP_PROXY_SUBJECT, 272 | text = msg, 273 | } 274 | 275 | log.info("util_notify", "POST", config.NEXT_SMTP_PROXY_API) 276 | return util_http.fetch(nil, "POST", config.NEXT_SMTP_PROXY_API, header, urlencodeTab(body)) 277 | end, 278 | ["smtp"] = function(msg) 279 | local smtp_config = { 280 | host = config.SMTP_HOST, 281 | port = config.SMTP_PORT, 282 | username = config.SMTP_USERNAME, 283 | password = config.SMTP_PASSWORD, 284 | mail_from = config.SMTP_MAIL_FROM, 285 | mail_to = config.SMTP_MAIL_TO, 286 | tls_enable = config.SMTP_TLS_ENABLE, 287 | } 288 | local result = lib_smtp.send(msg, config.SMTP_MAIL_SUBJECT, smtp_config) 289 | log.info("util_notify", "SMTP", result.success, result.message, result.is_retry) 290 | if result.success then 291 | return 200, nil, result.message 292 | end 293 | if result.is_retry then 294 | return 500, nil, result.message 295 | end 296 | return 400, nil, result.message 297 | end, 298 | -- 发送到 serial 299 | ["serial"] = function(msg) 300 | uart.write(1, msg) 301 | log.info("util_notify", "serial", "消息已转发到串口") 302 | sys.wait(1000) 303 | return 200 304 | end, 305 | } 306 | --------------------------------------------------------------------------------