VisualHMI - 分期应用(RW-自定义)
概述
💡 系统方案 :前置密码输入模式(“到期前锁定”)

描述:当前系统时间尚未到达某期截止时间,但 HMI 已提前弹出密码输入画面,要求用户预先输入该期密码才能继续使用。
示例:第一期截止时间:2023-11-01,系统时间:2023-10-01, 2023-10-01 起即锁定,必须输入第一期密码方可操作。
✅ 专业特征:
- 触发条件:
当前时间 ≥ 分期启用起始窗口(隐含逻辑:分期一旦配置即“待激活”)
💡 自定义方案 :准时密码输入模式(“到期前锁定”)
若用户需求是 准时密码输入模式(“到期时锁定”),即当本地系统时间达到或超过某期设定时间点时,HMI 才弹出密码输入画面。
示例:第一期截止时间:2023-11-01,系统时间:2023-10-01 ,正常使用。若系统时间:2023-11-01 00:00:00 → 立即锁定,需输入第一期密码。
✅ 专业特征:
- 触发条件:
当前时间 ≥ 分期截止时间(Deadline)。
区别
| 维度 | 系统方案 :前置密码输入 | 自定义方案 :准时密码输入 |
|---|---|---|
| 触发时机 | 分期配置后即生效 | 严格等于/超过分期时间点 |
| 授权行为 | “先验证,后使用”(前置) | “到期即锁,验证续用” |
| 用户感知 | “提前被锁” | “刚好到期被锁” |
使用范围:VisualHMI - HMI&M&DX系列 自定义分期
1.设计思路
本章节,我们设计“自定义分期”的应用,在Lua采样元表方式,在 HMI 上实现了“时间精准触发 + 动态密码验证 ”的分期授权方案。关键元素有以下几点:
| 元素 | 说明 |
|---|---|
| 分期数量 | 1~10 期,可动态配置 |
| 分期时间 | 每期一个 {年,月,日},必须严格递增 |
| 动态密码 | 由外部函数 password_func(date) 实时计算 |
| 超级密码 | 单一 uint32 值,用于全局解锁 |
| 使能开关 | 可远程启用/禁用整个分期功能 |
| 已解锁期 | 记录当前最高已验证通过的期号 |
💡 自定义特点
此设计避免了静态密码预存的脆弱性,也规避了提前锁定的体验缺陷,精准匹配设备租赁、试用、分阶段交付等典型工业场景。
🔑 1. 到期即锁
通过 get_eligible_stage() 严格比对系统时间与分期截止日,仅当 now ≥ deadline 时才触发锁屏
🔑 2. 动态密码,算法外置
密码由外部传入函数实时计算(_pwd_func(date)),无需预存,支持任意策略(如 y+m+d、哈希、UUID 衍生),灵活且防逆向。
🔑 3. 超级密码兜底
独立超级密码(_super_password)可一键全局解锁,兼顾现场运维与安全控制
🔑 4. 自校验热更新
启动时自动检测日期变更(_check_dates_changed),配置修改后自动重写存储,确保运行逻辑与主程序一致。
2.应用说明
本 stage.lua 模块采用闭包封装 + 原表(metatable)面向对象实现 HMI 分期授权功能:支持 1~10 期截止日期配置,仅当系统时间 ≥ 某期日期时才锁定界面;密码由外部函数动态计算(如年+月+日),不预存,防泄露;含超级密码应急解锁;所有状态(时间、开关、解锁进度等)持久化存储于 VT_RW 非易失区,掉电不丢;
2.1 初始化
在 main.lua 的 on_init 函数中,加载stage.lua,通过Stage.new(base_addr, stage_dates, password_func, super_password)加载并实例化分期模块:
| 参数 | 类型 | 作用说明 |
|---|---|---|
base_addr |
number(uint16) |
VT_RW 起始地址。所有分期配置和状态(如日期、开关、解锁进度等)将从该地址开始连续存储,支持多实例隔离。 |
stage_dates |
table |
3 期截止日期列表。设备在到达对应日期当天 00:00 起自动锁定,需输入当期密码才能继续使用。日期必须递增。 |
password_func |
function |
动态密码生成函数。运行时根据当前系统日期计算密码(本例为 年+月+日),不预存,安全灵活。 |
super_password) |
number(uint32) |
超级密码。由设备唯一 UUID 的 ASCII 码累加生成,实现“一机一密”,用于应急全局解锁。 |
✅ 关键优势:
- 普通密码随日期变化,无法提前破解;
- 超级密码绑定设备硬件标识,防止通用万能码滥用。
🛠️Step 1:加载模块
dofile('stage.lua')
- 执行
stage.lua,定义全局Stage表及其new方法; - 内部通过闭包封装
Stage类,外部无法访问私有字段或地址。
🛠️Step 2:超级密码
local uuid = get_device_uuid()
set_string(VT_LW, 0x3000, uuid)
- 读取 HMI 设备的唯一 UUID(如 MAC 或序列号);
- 将其写入
VT_LW[0x3000]供调试或显示使用。
🛠️Step 3:调用 Stage.new() 创建实例
▶ 3.1 参数校验(防御性编程)
- 检查
base_addr是否为有效数字且 ≤0x7FFF; - 检查
stage_dates数量(1~10)、格式(三元组)、顺序(递增); - 检查
password_func是函数,super_password是数字。
若任一校验失败,打印错误并返回
nil,避免后续异常。
▶ 3.2 计算内部存储偏移地址
基于 base_addr = 0x1000,分配如下:
0x1000:初始化标志(0x1234表示已初始化)0x1001~0x1014:10 期时间戳(每期占 2 个 uint16)0x1015:分期总数(=3)0x1016:使能开关(默认=1)0x1017:已解锁期(初始=0)0x1018:超级解锁标志(初始=0)0x1019~0x101A:超级密码(uint32)
▶ 3.3 检查是否需要初始化 FLASH
调用 _check_dates_changed():
- 读取
VT_RW[0x1015]获取已存分期数; - 若数量 ≠ 3,或任一期日期 ≠ 存储值 → 判定配置变更;
- 或
VT_RW[0x1000] != 0x1234→ 首次运行。
✅ 只要满足任一条件,就执行 _init_persistent()。
▶ 3.4 执行 _init_persistent()(原子写入)
- 将 3 个日期转为 Unix 时间戳(00:00:00),补足 10 期(后7期填0);
- 批量写入 20 个 uint16 到
0x1001~0x1014; - 写入控制参数(分期数=3、启用=1、未解锁=0、超级未触发=0);
- 最后写入
0x1234到0x1000,确保掉电不会导致半初始化。
Step 4:返回可用实例
g_stage成为一个封装好的对象;- 外部可通过
g_stage:verify_password(input, stage_idx)验证密码; - 所有底层地址和逻辑被闭包隐藏,安全、防误操作。
--main.lua
-- 普通密码,动态计算
local function password_sum_y_m_d(date_table)
-- 例如:密码 = 年 + 月 + 日
--local idx = g_stage:set_unlocked_stage(idx)
y, m, d = get_date_time()
print('password_sum_y_m_d : '..(string.format('%04d/%02d/%02d', y, m, d))..' = '..( y + m + d))
return y + m + d
end
--超级密码
function calculate_uuid_sum(uuid_str)
local sum = 0
-- 遍历字符串中的每一个字符
for i = 1, #uuid_str do
local char = uuid_str:sub(i, i) -- 获取第i个字符
local ascii_val = string.byte(char) -- 获取字符的ASCII码
sum = sum + ascii_val -- 累加到总和
end
return sum
end
function on_init()
dofile('stage.lua') -- 加载分期模块(定义 Stage 表)
local uuid = get_device_uuid()
set_string(VT_LW, 0x3000, uuid)
-- 创建分期管理实例
-- 传入各期解锁日期,
g_stage = Stage.new(.....)
end
--stage.lua
-- 构造函数:创建分期管理实例
-- 参数 base_addr: uint16,VT_RW 中的起始寄存器地址(例如 0x1000)
-- 参数 stage_dates: 表,格式为 {...},最多10期,且日期必须递增
-- 参数 password_func: 函数,接收 {year, month, day} 返回密码(如 function(d) return d[1]+d[2]+d[3] end)
-- 参数 super_password: uint32,初始的超级密码
function Stage.new(base_addr, stage_dates, password_func, super_password)
-- 校验基地址类型
if type(base_addr) ~= "number" then
--print("错误:base_addr 必须是一个数字")
return nil
end
if base_addr > 0x7FFF then
--print("base_addr 必须在0x0000~0x7FFF")
return nil
end
-- 校验分期数量:必须在 1~10 之间
if #stage_dates < 1 or #stage_dates > 10 then
--print("错误:分期数量必须为 1~10")
return nil
end
-- 校验每期日期格式:必须是 {y, m, d} 三元组
for i, d in ipairs(stage_dates) do
if #d ~= 3 then
--print("错误:日期格式错误,必须为 {年,月,日}")
return nil
end
end
-- 校验日期是否递增
for i = 2, #stage_dates do
local prev_y, prev_m, prev_d = stage_dates[i-1][1], stage_dates[i-1][2], stage_dates[i-1][3]
local curr_y, curr_m, curr_d = stage_dates[i][1], stage_dates[i][2], stage_dates[i][3]
local prev_ts = make_timestamp(prev_y, prev_m, prev_d, 0, 0, 0)
local curr_ts = make_timestamp(curr_y, curr_m, curr_d, 0, 0, 0)
if prev_ts >= curr_ts then
--print(string.format("错误:日期必须递增,第%d期 (%04d/%02d/%02d) 不能大于或等于 第%d期 (%04d/%02d/%02d)",i-1, prev_y, prev_m, prev_d, i, curr_y, curr_m, curr_d))
return nil
end
end
-- 校验密码函数是否存在
if type(password_func) ~= "function" then
--print("错误:password_func 必须是一个函数")
return nil
end
-- 校验超级密码类型
if type(super_password) ~= "number" then
--print("错误:super_password 必须是一个数字")
return nil
end
-- 计算实际存储地址
local addr_init_flag = base_addr + 0x0000 -- 初始化完成标志,值为 0x1234 表示已初始化
local addr_timestamp_base = base_addr + 0x0001 -- 时间戳数组起始地址
-- 每期时间戳为 uint32(4字节),拆分为 2 个 uint16
-- 10 期 × 2 = 20 个寄存器(+0x0001 ~ +0x0014)
local addr_stage_count = base_addr + 0x0015 -- 当前启用的分期数量(1~10)
local addr_enable_flag = base_addr + 0x0016 -- 分期功能使能开关(1=启用,0=禁用)
local addr_unlocked_stage = base_addr + 0x0017 -- 当前已解锁的分期编号(0=未解锁,1~10=对应期数)
local addr_super_unlock = base_addr + 0x0018 -- 超级密码是否已触发(1=已解锁全部功能)
local addr_super_pwd = base_addr + 0x0019 -- 超级密码
-- 创建新对象并绑定元表,支持 self:method() 调用
local self = setmetatable({
_dates = stage_dates,
_pwd_func = password_func, -- 保存密码计算函数
_super_password = super_password, -- 保存初始超级密码
-- 将计算好的地址存储在实例中,方便后续方法使用
_addr_init_flag = addr_init_flag,
_addr_timestamp_base = addr_timestamp_base,
_addr_stage_count = addr_stage_count,
_addr_enable_flag = addr_enable_flag,
_addr_unlocked_stage = addr_unlocked_stage,
_addr_super_unlock = addr_super_unlock,
_addr_super_pwd = addr_super_pwd
}, Stage)
-- 检查是否首次运行 或 日期数据不一致:若 INIT_FLAG 不等于 0x1234 或者 日期数量/内容不同,则需初始化 FLASH
if self:_check_dates_changed() or get_uint16(VT_RW, self._addr_init_flag) ~= 0x1234 then
self:_init_persistent()
end
return self
end
-- 私有方法:检查当前日期数据是否与存储在Flash中的不一致
function Stage:_check_dates_changed()
-- 读取已存储的分期数量
local stored_count = get_uint16(VT_RW, self._addr_stage_count)
-- 数量不同,直接返回 true
if stored_count ~= #self._dates then
print("检测到分期数量改变,需要重新初始化")
return true
end
-- 逐个比较日期(由于时间戳是以 uint32 存储,拆成两个 uint16)
for i = 1, #self._dates do
local stored_timestamp = get_uint32(VT_RW, self._addr_timestamp_base + (i - 1) * 2)
local y, m, d = self._dates[i][1], self._dates[i][2], self._dates[i][3]
local calculated_timestamp = make_timestamp(y, m, d, 0, 0, 0)
if stored_timestamp ~= calculated_timestamp then
print("检测到第 "..i.." 期日期改变,需要重新初始化")
return true
end
end
-- 所有日期都一致,返回 false
return false
end
-- 私有方法:首次运行时将配置写入 VT_RW(FLASH)
function Stage:_init_persistent()
local ts_data = {} -- 用于批量写入时间戳的数组
-- 循环处理 10 期(不足10期的补0)
for i = 1, 10 do
if self._dates[i] then
local y, m, d = self._dates[i][1], self._dates[i][2], self._dates[i][3]-- 提取年、月、日
local ts = make_timestamp(y, m, d, 0, 0, 0)-- 转换为 Unix 时间戳(秒),时分秒设为 00:00:00
table.insert(ts_data, (ts >> 16) & 0xFFFF) -- 高16位
table.insert(ts_data, ts & 0xFFFF) -- 低16位
else
-- 未定义的期数填充 0
table.insert(ts_data, 0)
table.insert(ts_data, 0)
end
end
-- 批量写入 20 个 uint16(10 期时间戳)
set_array(VT_RW, self._addr_timestamp_base, ts_data)
-- 写入控制参数(按紧凑顺序)
set_uint16(VT_RW, self._addr_stage_count, #self._dates) -- 实际分期数
set_uint16(VT_RW, self._addr_enable_flag, 1) -- 默认启用功能
set_uint16(VT_RW, self._addr_unlocked_stage, 0) -- 初始未解锁
set_uint16(VT_RW, self._addr_super_unlock, 0) -- 超级密码未触发
set_uint32(VT_RW, self._addr_super_pwd, self._super_password) -- 初始超级密码(uint32)
-- 最后写入初始化标志,防止掉电导致半初始化状态(原子性保障)
set_uint16(VT_RW, self._addr_init_flag, 0x1234)
end
2.2 分期”锁“
run_stage() 函数是分期授权系统的核心检测逻辑,通常由 HMI 的 系统定时器(如 on_systick,每 1 秒调用一次) 触发。其目标是:在设备时间到达某期截止日时,自动跳转到密码输入界面(锁屏)。
以下是该函数的分步执行流程与检测逻辑说明:
🛠️Step 1:安全前置检查
if not g_stage then return end
- 如果
g_stage实例未创建(如初始化失败),直接退出,避免空指针错误。
🛠️ Step 2:跳过锁屏的豁免条件
if g_stage:is_super_unlocked() or not g_stage:is_enabled() then return end
超级密码已触发 → 全局解锁,无需再锁;
分期功能被禁用
满足任一条件即退出,不进入锁屏。
🛠️ Step 3:获取“当前应解锁的最高分期编号”
local eligible = g_stage:get_eligible_stage(y, m, d)
- get_eligible_stage 内部
- 将当前日期转为时间戳(00:00:00);
- 遍历所有分期截止日,找到最后一个 ≤ 当前时间的期号;
- 返回该期号(如今天是 2026-02-20,则返回
2,因为第 2 期截止日 2026-02-15 已过,第 3 期 2026-03-15 未到); - 若所有期都未到,返回
0。
📌 关键逻辑:只有当
eligible > 0时,才表示“已有到期未解锁的期”。
🛠️ Step 4:获取当前实际已解锁的期号
local current = g_stage:get_unlocked_stage()
- 从
VT_RW读取当前已通过密码验证的最高期号(初始为0); - 例如:用户已输入第 1 期密码 →
current = 1。
🛠️ Step 5:判断是否需要触发锁屏
if eligible > 0 and eligible ~= current and get_screen() ~= LOCK_SCREEN_ID then
同时满足以下三个条件才跳转:
- 有应解锁的期 →
eligible > 0(说明至少有一期已到期) - 尚未解锁该期 →
eligible ~= current(例如:应解锁第 2 期,但当前只解锁到第 1 期) - 当前不在锁屏页 →
get_screen() ~= LOCK_SCREEN_ID(防止重复跳转或循环)
⚠️ 注意:这里 不要求逐期解锁。只要当前时间跨过多期(如跳过第 1、2 期直接到第 3 期),
eligible = 3,而current = 0,就会要求输入第 3 期密码(而非逐期输入)。这是典型“到期即锁”行为。
🛠️ Step 6:执行锁屏跳转
set_uint16(VT_LW, 0x1100, eligible) -- 通知 HMI 画面:当前需解锁第几期set_screen(LOCK_SCREEN_ID) -- 切换到密码输入界面
- 将目标期号写入
VT_LW[0x1100],供锁屏画面显示提示(如“请输入第 2 期密码”); - 强制切换到锁屏页面,阻止用户操作主功能。
🔚 总结:检测逻辑本质
每秒检查一次:如果“当前日期 ≥ 某期截止日”,且“该期尚未解锁”,就立即锁屏要求输入对应动态密码。
function run_stage()
-- 安全检查:若分期未初始化,直接退出
if not g_stage then return end
-- 若已通过超级密码解锁,或功能被禁用,则无需锁屏
if g_stage:is_super_unlocked() or not g_stage:is_enabled() then return end
-- 获取当前时间应解锁的最高分期编号(基于系统日期)
local test = get_uint16(VT_LW, 0x2003)
local y, m, d = nil, nil, nil
if test == 1
then
y, m, d = get_uint16(VT_LW, 0x2000), get_uint16(VT_LW, 0x2001), get_uint16(VT_LW, 0x2002)
end
local eligible = g_stage:get_eligible_stage(y, m, d )
-- 获取当前已解锁的分期编号(0 表示未解锁)
local current = g_stage:get_unlocked_stage()
-- 判断是否需要触发锁屏:
-- 1. 有应解锁的期(eligible > 0)
-- 2. 当前未解锁该期(eligible ~= current)
-- 3. 当前不在锁屏页(防止重复跳转)
if eligible > 0 and eligible ~= current and get_screen() ~= LOCK_SCREEN_ID
then
set_uint16(VT_LW, 0x1100, eligible) -- 通知 HMI 画面:当前应解锁第几期(用于显示提示)
-- 跳转到锁屏页面(用户需输入密码)
set_screen(LOCK_SCREEN_ID)
end
end
-- 系统定时器回调(通常每 100ms 调用一次)
function on_systick()
run_stage()
end
2.3 分期“解”
🛠️Step 1:读取用户输入与目标分期
local pwd = get_uint32(VT_LW, addr) -- 读取用户输入的 32 位密码
local target = get_uint16(VT_LW, 0x1100) -- 读取当前应解锁的期号(由 run_stage 设置)
pwd:用户在 HMI 密码框中输入的值;target:此前由run_stage()写入,表示当前因时间到期需验证的分期编号(如第 2 期)。
🛠️Step 2:多种密码验证
if g_stage:verify_password(pwd, target) or g_stage:verify_super_password(pwd) then
- 普通密码验证:调用动态函数(如
年+月+日)计算第target期的正确密码,比对用户输入; - 超级密码验证:比对是否等于基于设备 UUID 生成的唯一万能密码;
验证成功后:
- 普通密码 → 自动更新已解锁分期;
- 超级密码 → 全局解锁并禁用分期功能。
🛠️ Step 3:解锁后跳转主界面
set_screen(3)
- 立即退出锁屏页面,返回主操作界面(ID=3);
- 后续定时检测将不再触发锁屏(因状态已同步)。
🛠️Step 4:协同逻辑
该函数与 run_stage() 形成闭环:
run_stage()负责何时锁(基于系统时间);do_stage()负责如何解(响应用户输入);- 两者通过
VT_LW[0x1100]和VT_LW[0x1110]与 HMI 画面交互,实现全自动授权控制。