XingClass 智慧教室系统:用 FRB 实现可确认的本地控制策略
项目仓库:https://atomgit.com/xiaohong-ai/XingClass
FRB 项目:https://atomgit.com/oh-flutter/flutter_rust_bridge
Rust 社区:https://xuanwu.openatom.cn/
智慧教室 Demo 很容易做成“检测到有人就开灯,温度高就开风扇”。问题是,界面把开关画成绿色,只能说明软件想开灯,不能说明墙上的模块真的执行了。
XingClass 在这个地方多走了一步:策略运行在小鸿 SE 本地,Rust 同时维护期望状态和硬件确认状态,HarmonyOS PC 只做监控和人工覆盖。即使 PC 离线,教室策略也不能停。
本文按“理解本地自治边界、准备 Flutter-OH 与 Rust 工具链、阅读策略状态机、验证 ACK 闭环、提交问题与贡献”的顺序展开。现有的租约、迟滞、人工覆盖和验收内容全部保留。
技术选型与复现入口
1. Flutter、Rust 与 FRB 的职责
| 组成 | 在 XingClass 中的职责 | 选择它的价值 |
|---|---|---|
| Flutter | 教室状态、策略原因、人工覆盖和告警展示 | 让值守人员看清期望状态与确认状态 |
| Rust | 占用租约、温控迟滞、动作 ID、ACK 和统计 | 将时间规则和控制状态集中在可测试核心中 |
| FRB | 生成控制台与 Rust 核心的绑定 | 传递快照、动作和错误,不把策略复制到 Dart |
| 网关与模块 | 本地采样、命令传输和执行反馈 | 保证 PC 离线时关键控制仍可继续运行 |
2. 工具链检查
复现前应冻结 Flutter-OH、Rust、FRB、HarmonyOS SDK、HAP、网关和模块固件版本:
flutter --version
flutter doctor -v
rustc --version
cargo --version
rustup target list --installed
flutter pub get
flutter_rust_bridge_codegen generate
cargo test --workspace
主机测试负责验证虚拟时间和状态边界,真机记录负责验证传感器、命令、ACK 和现场动作。两类证据不能互相替代。
3. 旋武社区与 FRB
开放原子旋武开源社区提供 Rust 学习、工具和开源协作入口;flutter_rust_bridge负责生成 Dart/Rust 跨语言绑定。教室策略和硬件协议问题进入项目仓库,最小工程中的绑定问题再进入 FRB 仓库。
真机最终效果
配套演示视频约 25 秒,展示了智慧教室控制台、环境数据、设备控制及状态反馈。视频适合核对实际界面和操作流程,长时间租约、阈值边界与异常分支则由状态机测试和现场记录共同验证。
视频是实际系统演示素材。它适合展示控制台与现场状态变化,但短视频无法证明 15 分钟租约、120 秒陈旧数据保护和长时间稳定性;这些时间相关逻辑仍要靠可控时钟测试和单独的真机记录。
工程目录与职责
XingClass/
|-- crates/classroom-core/src/controller.rs 教室策略状态机
|-- crates/classroom-core/src/protocol.rs 二进制帧和命令
|-- crates/classroom-core/src/stats.rs 统计聚合
|-- apps/classroom_console/rust/src/api/ FRB 手写 API
|-- apps/classroom_console/lib/domain/ Dart 状态仓库
|-- apps/classroom_console/lib/platform/ NearLink 传输
|-- apps/classroom_console/lib/features/ 控制台页面
|-- firmware/modules/ 人在、温湿度、风扇、照明
|-- firmware/xiaohong-se-p4/ 小鸿 SE 侧固件
`-- artifacts/validation/ 构建和联调证据
classroom-core 不依赖 Flutter,也不应该知道按钮在哪里。这样同一组策略可以在 Rust 单测里用虚拟时间跑几小时,而不用真的等 15 分钟,也能被网关或控制台复用。
运行架构
真正的控制闭环在小鸿 SE 和四个模块之间,PC 不在安全关键路径上。Flutter 端仍复用同一套 Rust 领域核心做演示、协议编码和快照解码,避免 PC 与网关各写一份不同规则。
FRB 接口不是“开灯函数”
项目配置:
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust
跨语言快照同时包含原始输入、推导状态和执行状态:
pub struct DashboardSnapshot {
pub raw_presence: String,
pub room_occupancy: String,
pub lease_remaining_seconds: u64,
pub temperature_c: Option<f32>,
pub humidity_rh: Option<f32>,
pub schedule_open: bool,
pub session_active: bool,
pub desired_fan: String,
pub confirmed_fan: String,
pub desired_lighting: String,
pub confirmed_lighting: String,
pub pending_commands: u32,
// 其余诊断字段省略
}
desired_fan = on、confirmed_fan = off、pending_commands = 1 的含义很清楚:策略已经要求开风扇,但硬件还没有确认。这个中间态必须显示出来。
本项目使用的 Rust 特性
XingClass 用 Rust 类型表达策略状态、时间边界和硬件确认关系:
| Rust 特性 | 在 XingClass 中的实际用法 |
|---|---|
struct 与 enum | DashboardSnapshot 组合原始输入、期望状态和确认状态;PresenceState、BinaryState、ActuatorKind 表达有限且可检查的业务值。 |
Option<T> 与 let Some | 温湿度样本、占用租约等数据可能缺失,策略在缺样本时走保守分支,而不是读取未初始化值。 |
Result<T, E> 与自定义错误 | 非法时间、未知 action、执行失败等情况返回 ControlError,FRB 和测试都能区分拒绝原因。 |
| 所有权与借用 | &mut self 只允许状态机独占修改,&self 只读计算自动策略,避免期望状态与确认状态被并发写乱。 |
| 模式匹配 | 按执行器种类把 ACK 写回风扇或照明的确认字段,编译器会检查枚举分支是否完整。 |
| 整数安全运算 | saturating_add 计算占用租约截止时间,避免时间戳接近上限时发生整数溢出。 |
| 集合与动作身份 | pending_commands 按 action_id 保存待确认动作,ACK 只能移除并确认对应的一条命令,重复或未知 ID 会被拒绝。 |
| FRB 同步 API | evaluate、setPresence、recordExecutionAck 等接口把 Rust 策略核心复用到 Flutter 控制台,PC 不承担安全规则。 |
15 分钟空置租约
人在传感器偶尔漏报很正常。如果一次 vacant 就立刻关灯,课堂上会出现明显闪断。控制器在收到 Occupied 时把占用租约延长 15 分钟:
pub fn ingest_presence(
&mut self,
state: PresenceState,
now_ms: u64,
) -> Result<(), ControlError> {
self.advance_clock(now_ms)?;
self.raw_presence = state;
if state == PresenceState::Occupied {
self.occupied_until_ms = Some(
now_ms.saturating_add(self.config.vacancy_lease_ms)
);
if self.schedule_open {
self.session_active = true;
}
}
Ok(())
}
默认 vacancy_lease_ms 是 15 * 60 * 1000,配置只允许 5 到 30 分钟。界面同时显示 raw_presence 与 room_occupancy,因此“传感器刚报无人,但房间租约仍有效”不会被误解成程序故障。
温控需要迟滞,不能卡在一个阈值
默认开风扇阈值为 27℃ 或 70%RH,关闭阈值为 25℃ 且不高于 65%RH:
fn automatic_fan(&self, control_allowed: bool) -> BinaryState {
if !control_allowed { return BinaryState::Off; }
let Some(sample) = self.climate else {
return BinaryState::Off;
};
if self.desired_fan == BinaryState::On {
if sample.temperature_c <= self.config.fan_off_temperature_c
&& sample.humidity_rh <= self.config.fan_off_humidity_rh {
BinaryState::Off
} else {
BinaryState::On
}
} else if sample.temperature_c >= self.config.fan_on_temperature_c
|| sample.humidity_rh >= self.config.fan_on_humidity_rh {
BinaryState::On
} else {
BinaryState::Off
}
}
27℃ 开、25℃ 关,中间两度就是迟滞区。没有这段迟滞,温度在 26.9℃ 和 27.1℃ 之间波动时,继电器会频繁动作。
环境数据还有新鲜度:超过 10 秒算陈旧。若风扇原本已开,最多保守维持到 120 秒;再没有新数据才关闭。这里不是简单的“超时即关”,而是把舒适性和故障保护分成两个时间尺度。
期望状态必须等 ACK 才能变成确认状态
每次策略变化都会产生带 ID 的 Action:
pub fn record_execution_ack(
&mut self,
action_id: u32,
executed: bool,
) -> Result<(), ControlError> {
let (actuator, target) = self.pending_commands
.remove(&action_id)
.ok_or(ControlError::UnknownAction(action_id))?;
if !executed {
return Err(ControlError::ExecutionFailed(action_id));
}
match actuator {
ActuatorKind::Fan => self.confirmed_fan = target,
ActuatorKind::Lighting => self.confirmed_lighting = target,
}
Ok(())
}
Flutter 侧实操可以直接走 FRB:
final now = BigInt.from(DateTime.now().millisecondsSinceEpoch);
setPresence(state: 'occupied', nowMs: now);
setClimate(temperatureC: 28.4, humidityRh: 68, nowMs: now);
final actions = evaluate(nowMs: now, scheduleOpen: true);
for (final action in actions) {
// 真机模式:先将 action 编码并下发,收到模块 ACK 后再记录
recordExecutionAck(actionId: action.id, executed: true);
}
final snapshot = getSnapshot();
在真实链路中,不能像 Demo 一样立刻写 executed: true。要等网关返回对应 action/command 的 ACK,超时则保留未确认或进入故障提示。
手动覆盖也必须有期限
老师可以临时强制开风扇或关灯,但覆盖不能永远压住自动策略。set_override 要求 expires_at_ms 大于当前时间,且最长不超过 120 分钟。协议下发使用 build_override_command(sequence, actuator, mode, duration_seconds),Rust 会校验设备种类、模式和序列号,再编码为二进制帧。
实操与验证
cd XingClass
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
bash scripts/test_firmware_host.sh
bash scripts/test_flash_firmware.sh
cd apps/classroom_console
flutter pub get
flutter_rust_bridge_codegen generate
dart analyze lib test
flutter test
建议按时间轴验证一次完整场景:上课时有人进入,灯和风扇出现 pending;收到执行 ACK 后 confirmed 改变;传感器报无人后观察 15 分钟租约;设置 10 分钟强制关闭,确认到期后自动策略重新接管。
用虚拟时间把 15 分钟压缩成一个测试
状态机 API 接收 now_ms,是为了可测性,不是让 UI 随意伪造时间。测试中可以从固定的 1_000_000 开始,每一步精确推进:
#[test]
fn vacancy_does_not_turn_off_room_before_lease_expires() {
let mut controller = ClassroomController::default();
let t0 = 1_000_000;
controller.ingest_presence(PresenceState::Occupied, t0).unwrap();
controller.evaluate(t0, true);
controller.ingest_presence(PresenceState::Vacant, t0 + 1_000).unwrap();
let during_lease = controller.snapshot(t0 + 14 * 60 * 1_000);
assert_eq!(during_lease.room_occupancy, OccupancyState::Occupied);
let after_lease = controller.snapshot(t0 + 15 * 60 * 1_000 + 1);
assert_eq!(after_lease.room_occupancy, OccupancyState::Vacant);
}
示例中的具体构造方法应按仓库当前公开 API 调整,但测试边界要保留:租约到期前一毫秒和到期后一毫秒都要断言。只测中间某个时间点,最容易漏掉 >= 与 > 的边界错误。
一次真机控制闭环应记录什么
以温度 28.4℃ 触发风扇为例,完整证据不是“风扇卡片变绿”,而是下面一串事实:
温湿度模块上报:temperature=28.4, humidity=68, sample_time=T0
Rust 策略输出:desired_fan=on, action_id=37
网关下发:command_id=37, sequence=...
风扇模块执行并回复:ACK command_id=37, executed=true
Rust 快照更新:confirmed_fan=on, pending_commands=0
现场观察:风扇实际转动
如果 ACK 超时,desired_fan 仍可为 on,但 confirmed_fan 不能变。控制台应该把这个差异突出显示并允许追查 command ID。重复 ACK 要保持幂等;未知 action ID 则必须记录为协议异常,不能顺手修改确认状态。
手动覆盖的几个坑
手动覆盖适合临时处理课堂情况,但要满足三个约束。第一,开始和过期时间使用同一单调时间基准,不能被系统时间回拨延长。第二,覆盖只影响指定执行器,强制关灯不应顺便关闭风扇。第三,过期后重新运行自动策略,不能简单恢复覆盖前的旧值,因为期间温度和占用情况可能已改变。
实际联调可以做一轮“自动开风扇 -> 手动强制关 10 分钟 -> 第 10 分钟前仍关闭 -> 到期后重新依据实时温湿度决定”。如果温度仍高,自动策略应再次提出开风扇动作,并重新等待 ACK。
故障场景要比正常场景多测一步
| 故障 | 预期行为 |
|---|---|
| 温湿度 10 秒未更新 | 标记陈旧,不把旧样本当新样本 |
| 风扇已开且样本短暂陈旧 | 最多保守维持到 120 秒 |
| 超过保护时长仍无数据 | 进入安全退化策略并给出诊断 |
| 执行器 ACK 失败 | 保留 desired/confirmed 差异 |
| 重复 ACK | 不重复改变状态或统计 |
| PC 控制台退出 | 小鸿 SE 本地策略继续运行 |
| 日程关闭但房间有人 | 按明确产品规则处理,不由 UI 猜测 |
面向验收的结论表
| 检查项 | 证据方式 | 判定 |
|---|---|---|
| Rust 策略和协议 | cargo test --workspace | 以实际退出码为准 |
| FRB 绑定可调用 | Flutter 测试与控制台运行 | 不能只看生成文件 |
| 期望/确认状态分离 | action + ACK 时间线 | 必须保留中间态 |
| 现场演示 | 配套真机演示视频 | 已完成操作流程记录 |
| 15 分钟租约 | 虚拟时钟边界测试 | 短视频不覆盖 |
| 传感器阈值 | 现场标定记录 | Demo 默认值不可直接验收 |
| PC 离线自治 | 断开 PC 后观察网关策略 | 需交付现场复测 |
别只统计“开了多少次”
智慧教室后续优化需要可解释指标。建议按天保存传感器在线率、样本陈旧次数、动作下发数、ACK 成功率、平均确认延迟、手动覆盖时长和策略抑制次数。只统计风扇开关次数,会把网络抖动造成的重复动作误认为真实使用。
统计也要围绕 action ID 去重:同一命令重试三次,业务上仍是一个控制意图。ACK 延迟从首次下发开始计,最终失败要保留重试次数。这样现场发现“界面总是 pending”时,能区分是模块离线、信号差、执行器拒绝,还是控制台漏收 ACK。
这些指标不应反向改变安全规则。比如近期 ACK 成功率低,系统可以提示维护,却不能因此把 confirmed_fan 自动追平 desired_fan。监控负责暴露事实,状态机仍以真实回包为准。
当前状态
现场策略标定不能照搬 Demo 数字
智慧教室最容易出现的一种误解,是把阈值写进配置后就认为策略已经完成。以温控为例,同样是 28 摄氏度,朝阳教室、西晒教室和顶层教室的体感、升温速度都不一样;人数、门窗状态和空调出风位置也会改变传感器读数。Demo 中的开关阈值只能证明状态机可以工作,正式值必须来自现场采样,不能从另一间教室复制。
标定时我会先让自动控制保持观察模式,只记录而不动作。至少覆盖上课前空教室、学生陆续进入、满员授课、课间开门以及下课后空置几个阶段。每个阶段记录温湿度、人数或占用信号、执行器当前状态和人工感受,再看温度上升速度与设备作用后的回落速度。这样得到的不是一个孤立阈值,而是一条能够解释“为什么此时开、为什么此时关”的曲线。
传感器本身也需要和参考仪表对照。安装高度、靠墙距离、阳光直射和风口都会产生稳定偏差,不能只看模块上报的小数位很多就认为准确。若偏差在可接受范围内保持稳定,可以保存校准量;若读数持续跳变或响应明显滞后,则应调整安装位置或更换传感器。软件补偿不能掩盖硬件选点错误。
迟滞区间要结合设备惯性设置。开阈值和关阈值太近,风扇会在边界附近频繁启停,既影响课堂,也增加继电器动作次数;区间过宽又会让室内长时间偏离舒适范围。比较合适的办法是根据现场曲线先给出保守值,运行数天后复盘动作频率和持续时间,再逐步收敛,而不是第一天就追求一个看起来精确的数字。
人体存在或人数信号同样不是绝对事实。遮挡、静坐、课间走动都可能造成漏报和突变,因此空置判断才需要租约。租约长度应结合课程节奏:短到足以避免下课后长期空转,又不能因学生十几分钟安静听课就关闭设备。15 分钟是本文演示规则,不是适合所有学校的行业标准,这一点在验收文档中应写清楚。
期望状态和确认状态怎样帮助现场排障
操作人员点击“打开风扇”后,页面可以立即显示正在执行,但不能马上把设备画成已开启。此时系统只产生 desired 状态和 action ID,命令还可能处于排队、发送、等待 ACK 或重试阶段。只有收到与 action ID 对应且内容一致的确认,confirmed 状态才发生变化。这个区分不是界面上的文字游戏,它决定了故障发生时能否说清命令走到了哪一步。
例如 desired 为开、confirmed 仍为关,最近一次错误是超时,说明策略已经作出决定,但硬件结果没有被证实。若 desired 和 confirmed 都为开,而传感器反馈环境没有预期变化,则问题更可能在设备执行效果、安装位置或传感器侧。把两类情况混成一个绿色开关,会让维护人员反复猜测究竟是网络没发到,还是设备开了却没有效果。
ACK 还要核对对象和版本。迟到的旧 ACK 不能覆盖新动作,同一个 ACK 重复到达也不能重复统计。设备重连后若主动上报当前状态,系统可以用它校正 confirmed,但不能伪造某条历史 action 已成功。历史动作的结果与当前设备状态是两类事实,应分别保存。前者用于审计,后者用于继续控制。
页面呈现最好保留足够克制的信息:正常时显示最终状态;等待时出现明确的执行中标识;超过期限后显示未确认并允许查看原因。没有必要把每一帧协议都展示给老师,但维护页面必须能根据 action ID 找到发送次数、最近时间、ACK 和模块身份。用户界面保持简单,不意味着底层记录可以含糊。
PC 退出后,离线自治到底由谁负责
教室控制如果完全依赖 PC 控制台,窗口关闭、系统休眠或网络中断都会使自动策略停摆。更合理的结构是把必要的短周期规则放在小鸿 SE 或现场网关执行,PC 负责配置、观察和较复杂的管理。这样即使上位机暂时离线,已有租约、温控迟滞和安全退化规则仍可以继续运行。
离线自治不等于网关可以无限期自行决定。日程、策略版本和人工覆盖都有有效期,超期后要进入事先定义的保守状态。比如网关长时间收不到最新课程表,是继续按上一周日程,还是只根据占用信号控制,必须由产品规则明确。代码不能在断网时临时选择一个“看起来合理”的答案。
重新上线时也不要简单用 PC 状态覆盖现场。控制台先读取网关当前策略版本、设备确认状态、离线期间动作摘要和未结束租约,再决定是否下发新配置。若双方配置版本不同,应展示差异并按确定的优先级合并。PC 在离线期间缓存的旧命令通常已经失去时效,不应恢复网络后成批发送,否则可能在下课后突然执行上课时的动作。
手动覆盖尤其需要在离线场景下测试。教师临时关闭风扇后,覆盖期限应由执行策略的一侧计时,而不是仅存在 Flutter 页面内。页面关掉再打开,仍要读到覆盖原因和剩余时间;网关重启后是否恢复覆盖,则取决于产品约定,并需要通过持久化测试验证。若这项状态只在内存里存在,就不能宣称它能跨重启保持。
用一节真实课堂复盘整条链路
假设课程 08:00 开始,07:50 教室仍无人。日程表示即将上课,但占用租约尚未建立,系统可以预热必要设备,也可以等待第一名学生进入,具体取决于学校的节能要求。重要的是日志要记录触发源,不能只留下一条“自动打开”。维护人员之后应能看到这是日程预热,而不是传感器误报。
07:55 检测到人员进入,占用租约被续期。室温超过开启阈值并维持规定时间后,Rust 策略产生风扇开启动作。Flutter 先显示期望开启,网关发出命令,执行器返回匹配 ACK 后才显示确认开启。如果 ACK 丢失,重试仍沿用同一个业务动作身份,避免统计成多次自动决策。
08:20 老师认为噪声影响听课,手动关闭风扇并选择覆盖 20 分钟。此后即使温度仍高,策略也应记录“因人工覆盖被抑制”,而不是不断向设备发送开启命令。08:40 覆盖到期,状态机重新依据新鲜传感器样本计算;若样本已陈旧,则先请求或等待新数据,不能使用二十分钟前的高温读数立即启动。
下课后学生陆续离开,最后一次占用信号消失并不立刻关闭所有设备,空置租约开始计时。期间有人返回取东西,租约应重新续期。只有持续空置达到条件,系统才产生关闭动作。这个过程能检验租约是否真正由单调时钟驱动,也能检验重复进入、短暂离开和日程结束同时发生时的优先级。
完整复盘后,至少应能回答:策略使用了哪份配置、每个动作由什么条件触发、命令有没有收到确认、人工覆盖抑制了哪些动作、传感器是否新鲜,以及 PC 离线时谁继续执行。能回答这些问题,智慧教室才从“远程按钮面板”变成可维护的控制系统。
验收时怎样把长时间规则变成可复现证据
15 分钟租约和多小时课程无法靠一段短视频完整证明。主机测试使用虚拟时钟覆盖边界,现场则保留真实时间线,两者结合使用。虚拟时钟证明 14 分 59 秒不触发、15 分钟到点触发以及中途续租会重新计算;现场记录证明真实传感器和执行器在目标环境中参与了同一流程。
现场演示前应冻结候选版本,包括 HAP、Rust 核心、网关固件、模块固件和策略配置。视频开始时拍到或导出版本信息,随后按用例编号操作,每次动作保留 action ID。演示结束后导出日志和状态快照,避免只剩一段无法检索的视频。若中途换了固件或临时修改阈值,应重新开始对应测试,不能把不同版本的片段拼成一次通过。
验收结论也要分层。策略状态机通过主机测试,只能说明规则实现符合用例;模块 ACK 和真机画面可以说明通信链路工作;现场温控效果还受设备功率、空间和安装影响,需要单独测量。把三种结论写在各自证据后面,甲方抽查时才能快速复现,也不会因为一句范围过大的“全部通过”而产生争议。
XingClass 已有 Rust 教室策略、二进制协议、Flutter 控制台、FRB API、四类模块与主机侧测试。视频展示的是现有系统演示。文章中的阈值是 Demo 默认值,不能未经现场标定就直接当作真实教室的最终参数。
这套设计想解决的不是“怎么远程按开关”,而是如何让每一次自动决策都能回答三个问题:为什么要这样做、命令有没有发出、硬件到底执行了没有。
FAQ:从问题定位到 Issue 与 PR
1. 页面显示开启,为什么设备没有动作?
先比较 desired_* 和 confirmed_*。若期望状态已变化但确认状态未变化,应沿 action ID 检查命令发送、模块 ACK 和超时,不要直接把页面颜色当作硬件事实。
2. 时间规则怎样复现?
15 分钟租约、环境数据新鲜度和覆盖到期应使用虚拟时间测试精确跨越边界;真机只补充真实采样和执行证据,不需要等待视频完整覆盖所有长时间分支。
3. 如何提交策略修复?
PR 应包含触发条件、旧行为、新行为、边界测试、策略配置影响和真机结果。修改阈值时要说明它是 Demo 默认值还是现场标定值,不能混为同一结论。
后续扩展与项目入口
后续可以增加多教室调度、策略版本迁移和更完整的离线自治日志,但 action ID、期望状态和确认状态仍应贯穿全链路。项目源码、FRB 入口与 Rust 社区链接已在文章开头列出。
更多推荐




所有评论(0)