2026-07-01 LoRa 项目整理开发记录

今天主要是在收口一个 LoRa 相关项目的现场稳定性和配置体验。不是只修一个点,而是把几个长期摩擦放到同一条链路里整理:现场 LoRa 轮询被异常设备拖慢、转发模式开关和运行态保活互相打架、LR 配置页信息太散、子设备卡片缺少运行来源,以及 Codex 自身的 worktree、skill、commit scope 规则需要整理。

从本地 Codex 原始会话看,今天 2026-07-01 一共有 9 个 Codex 会话文件,其中 8 个是开发/排查/整理会话,最后 1 个是这篇日志整理会话。可核验的提交记录里,后端项目当天有 19 条 commit(含 merge),前端项目当天有 9 条 commit。下面记录只写机制和脱敏后的链路,不放真实设备编号、内网地址、SSH 用户、镜像仓库和本地绝对路径。

先整理开发现场

第一件事是工作区整理。定时 Codex 会话检查了 10 个项目的 worktree,只移除满足三个条件的登记工作区:

1
2
3
4
5
worktree 已登记
-> 工作区干净
-> HEAD 已经被对应基线分支包含
-> 不是主工作区、不是 dirty 工作区、不是未合入分支
-> remove

最后移除了 3 个已合入基线的 worktree。没有用目录名猜测,也没有全盘扫描;判断依据是 git worktree list --porcelaingit status --porcelainmerge-base --is-ancestor。这个清理本身不大,但它把后面一天的开发环境先降噪了:真正还在进行中的 worktree 被保留,已合入且干净的工作区被清掉。

同一天还整理了两个 Codex 使用规则。

一个是 SSH 排查 skill 的目标解析。之前用户明确说要连某个现场别名,agent 还是优先去了默认测试机。修正后的规则是:只要用户给了 SSH alias、现场设备编号或显式 host,这就是最高优先级目标,默认小电脑只能在没有显式目标时使用。对应文档也补了 ssh -G "$HOST" 这种预检模板,先确认解析结果,再进入排查。

另一个是 commit scope 规则。旧规则强调“提交前看最近 10 条 commit,如果有同义 scope 就沿用”,这容易让 agent 把入口现象当 scope。比如问题入口是 LoRa 扫描失败,但根因可能是系统配置兼容。规则被压缩成两行:

1
2
scope 按本次变更的根因和实际代码责任边界命名。
最近 10 条 commit 只用于复用同义 scope;语义不一致就新建更准确的中文 scope。

这个改动很小,但能减少后续提交历史里的“症状型 scope”。

LoRa 现场稳定性

上午的核心问题是:某类大报文设备不应该进入 LoRa2,却曾经在 LoRa2 上持续收发,拖慢同信道上的其他设备。排查后确认这不是日志误判,而是真实收发。原始会话里统计到异常期间有大量 LoRa2 报文,且包含较大的设备报文。为了脱敏,这里不贴 PID 和现场编号,只保留机制:

1
2
3
4
5
6
7
设备从 LoRa2 发 SYN
-> 通信层按收到 SYN 的串口标记 holder.isLora2 = true
-> reject 判断当时没有挡住
-> holder 进入全局在线设备表
-> lora2Poller 只看 holder.isLora2 && isTarget(holder)
-> 设备类型又在可轮询 target 范围内
-> LoRa2 开始持续轮询它

修正方向不是给某个 PID 打补丁,而是把“设备类型与 LoRa 归属”做成更硬的通用约束。某些设备类型默认只能在 LoRa1 归属下被接纳;即使历史运行态里误登记成 LoRa2,后续接入和轮询也要再次收口,避免一个错误 holder 存活很久。

简化后的核心代码逻辑是这样:

1
2
3
4
5
6
7
8
9
10
11
boolean shouldReject(DeviceHolder holder) {
if (!lora2Enabled()) {
return false;
}

if (holder.isFromLora2() && devicePolicy.isLora1Only(holder.type())) {
return true;
}

return !channelOwnership.allows(holder.type(), holder.pid(), holder.channel());
}

这段示例不是原始代码,只表达机制:通道归属不是按单个 PID 黑名单判断,而是先用设备类型的归属策略兜住,再进入业务槽位判断。

轻量运行态快照

另一个现场问题更隐蔽。某次 LoRa2 轮询里,DGC 类设备约 10 秒没有更新,随后被离线检测移除。继续追日志后发现,无线收发本身没有耗时 8 秒,真正的空窗出现在发包前的应用链路。

可核验的数字是:

1
2
3
4
应用层发包前空窗:约 8.007s
当时打印的运行态 ctx:约 7.5KB
ctx 里的 map entry:951 个
实时离线阈值:10s

旧链路大概是:

1
2
3
4
5
6
7
加料终端轮询
-> 读取吊钩秤运行态
-> getCtx().safeCopy()
-> 在读锁下复制完整 Context
-> 连 lockWeightCntMap 也一起复制
-> 打印完整 ctx
-> 再发送 LoRa 报文

业务上真正需要的只是当前重量、锁重状态、休眠状态、电量、更新时间等轻量字段,不需要把计数 map 复制到实时轮询线程里。于是把“完整 Context”拆出一个轻量只读投影:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public final class HookScaleRuntimeSnapshot {
private final int currentWeight;
private final int lockWeight;
private final boolean dormant;
private final boolean locked;
private final Instant updatedAt;
private final int batteryPercentage;
}

public HookScaleRuntimeSnapshot runtimeSnapshot(int subDeviceNo) {
lock.readLock().lock();
try {
Context ctx = getOrCreateMutableCtx(subDeviceNo);
return new HookScaleRuntimeSnapshot(
ctx.getCurrWeight(),
ctx.getLockWeight(),
ctx.isDormant(),
ctx.isLock(),
ctx.getUpdatedAt(),
ctx.getBatteryLevelPercentage()
);
} finally {
lock.readLock().unlock();
}
}

这里的重点不是少复制几 KB,而是把实时轮询链路从“完整业务上下文”改成“最小运行态投影”。这类接口应该服务轮询稳定性,不应该顺手暴露页面统计、锁重分布、holder 等重量级对象。

转发模式和运行态生命周期

下午对 LoRa 转发服务做了一轮结构整理。旧代码里,转发模式、LoRa 开关、串口运行态、host 配置释放转发层几个概念混在多个地方。实际问题表现为:关闭转发模式时,Java runtime 的保活线程可能又把 systemd 转发层拉起来,导致用户看到“关闭超时”或“关闭后又启动”。

旧链路:

1
2
3
4
5
前端关闭转发模式
-> stop/disable serial-forward-0/1
-> Java LoRa runtime 仍在运行
-> 保活线程检测到串口不通
-> 又尝试 start 转发层

整理后的顺序是:

1
2
3
4
5
切换转发模式
-> 先停止 LoRa1/LoRa2 runtime
-> 切断 Java 保活重开入口
-> 再 stop/disable systemd 转发层
-> 最后落库 ENABLE_LORA_FORWARD_MODE

脱敏后的代码形态大概是:

1
2
3
4
5
6
7
8
9
10
11
12
public void switchForwardMode(boolean enabled) {
if (!enabled) {
runtimeLifecycle.disableAll();
forwardService.stopAndDisableAll();
configService.setForwardMode(false);
return;
}

forwardService.enableAndStartAll();
configService.setForwardMode(true);
runtimeLifecycle.reopenAll();
}

随后又继续重构了运行时边界:把 LoRa1/LoRa2 原来类似复制的 manager 收口到 slot runtime、runtime registry、lifecycle service 这一组对象里。收益是后续关闭、重启、转发模式切换、host LoRa config 前释放转发层,都能走同一个运行态入口,而不是散落在 controller、service、manager 之间。

这轮重构不是为了抽象而抽象。它解决的是一个很具体的职责错位:systemd 转发层是底层资源,Java runtime 是使用者,业务配置只是期望状态。关闭底层资源前,必须先让使用者停下来。

1 秒保活和防重入

后面还补了一版 LoRa 运行时健康保活。现场有一种情况:关闭 LR2、写配置、再开启 LR2 后,LR1 可能进入无响应状态,直到人工重开 LR1 才恢复。讨论后没有让外部 activity sampler 暴露更多细节,而是让 LoRa runtime 内部维护自己的端口健康状态。

核心机制是:

1
2
3
4
5
6
7
8
LoRa 数据监听器 / 轮询器
-> 更新 LoraPortHealthState
-> 记录 lastActiveAt、lastOpenState、recovering
-> 保活每 1s 检查
-> 如果端口长时间无响应且不在 recovering
-> 触发一次 reopen
-> recovering=true 防止下一秒重复叠加
-> reopen 完成后清理 recovering

测试时分了 RAW 和 FORWARD 两种模式。RAW 模式下,关闭 LR2、读写配置、再开启 LR2,LR1 没进入 NO_RESP,LR2 触发一次重建后恢复。FORWARD 模式下,重启后转发层首轮打开大约需要 31 到 33 秒,但 recovering=true 挡住了 1 秒保活的重复触发;后续没有出现“触发、完成、再触发”的循环。

这段机制的关键点是防重入。1 秒保活本身很容易从“兜底”变成“重启风暴”,所以状态机里必须有 recovering 或等价状态,明确表达“当前已经有人在修,不要再派第二个任务”。

前端配置页整理

前端主要围绕 LR 设置页做了几轮压缩和信息分层。

当天的前端提交集中在这些点:

1
2
3
4
5
6
7
信道组概览展示收口
全局配置卡片收窄
一屏显示高度压缩
LR 标题行对齐
转发模式卡片样式统一
LoRa 固件版本展示
版本、信道、信道标签位置调整

其中一个比较典型的问题是“当前信道”和“用户正在编辑的信道”混在同一个字段里。旧链路是:

1
2
3
4
5
6
用户选择新信道
-> selectedChannel 改为新值
-> 后端缓存结果回读
-> applyHostLoRaConfResult
-> selectedChannel 又被旧值覆盖
-> 用户看起来无法编辑

修正后把草稿值和已生效值拆开:

1
2
3
4
5
6
7
8
9
selectedChannel = 用户正在编辑的草稿值
activeChannel = 后端确认已经生效的标题 tag 值

用户选择新信道
-> 只改 selectedChannel
-> 标题 tag 仍显示 activeChannel

写设置成功并回读到新配置
-> selectedChannel / activeChannel 一起同步为新值

对应的脱敏前端状态逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function applyDraftSelection(state, nextChannel) {
return {
...state,
selectedChannel: nextChannel,
hasDraft: nextChannel !== state.activeChannel,
};
}

export function applyConfirmedConfig(state, config) {
const confirmedChannel = config.channel;
return {
...state,
selectedChannel: confirmedChannel,
activeChannel: confirmedChannel,
hasDraft: false,
};
}

这个改动的边界很清楚:下拉框服务编辑态,标题 tag 服务已生效态。不要用一个字段同时表达“我想写什么”和“现场已经是什么”。

子设备卡片显示 LR 来源

晚上又补了一个 UI/数据链路问题:子设备管理卡片需要显示当前设备最近由 LR1 还是 LR2 收到。前端共享卡片先做了展示逻辑:

1
2
3
4
在线 + LoRa1 -> LR1 蓝色
在线 + LoRa2 -> LR2 绿色
非在线 -> LR- 弱化置灰
未知来源 -> LR- 弱化

随后现场发现某个在线设备仍显示 LR-。继续追后发现不是前端颜色判断错,而是部分设备列表接口的 option 没带 loraWho

旧链路:

1
2
3
4
5
6
7
8
在线状态表
-> 有 loraWho

某些设备扫描卡片
-> optionList
-> holder.toOption()
-> 没有 loraWho
-> 前端只能显示 LR-

修复后把运行来源下沉到通用 DTO:

1
2
3
4
5
SerialPortSubDeviceHolder.toOption()
-> 从 holder 读取最近接收 LR
-> SubDevicePidHexOption.loraWho
-> 所有设备列表 optionList 统一带字段
-> 前端共享卡片统一显示 LR1 / LR2 / LR-

这里同样避免了“只修某个页面”。主路径、手写 option copy、加料终端已配置吊钩秤列表都补了字段,避免后续其他设备卡片还是缺值。

验证方式

今天验证比较杂,但都围绕“改完后现场链路是否真的闭环”。

后端主要跑了针对性 Maven 测试,例如 LoRa 通道归属、运行时生命周期、串口转发服务、轻量快照、子设备 option 透传等测试。前端主要跑了 LoRa 配置相关的 Node 单测、运行态显示测试,以及 npm run build。现场侧的验证包括:

1
2
3
4
5
6
7
8
9
10
LoRa2 静态窗口:
lora2Poller 日志 43940 条
>=1s 轮询间隔 0
>=5s 轮询间隔 0
LoRa2 上异常设备 pause / SynAck 0

LoRa2 实时窗口:
新增日志 2618 条
>=1s 轮询间隔 0
>=5s 轮询间隔 0

还有多次部署后检查:容器 runtime commit、Java 启动时间、转发层 active 状态、接口返回字段、页面卡片依赖字段。发布链路里也明确避开了一个已经不可用的旧 build flow,改成等待提交触发的 CI 产物,再用部署脚本更新目标服务。

今天真正沉淀下来的东西

今天看起来改了很多文件,但核心沉淀其实是几条机制:

  1. LoRa 归属判断必须是设备类型和业务槽位共同决定,不能只按“从哪个串口收到 SYN”决定。
  2. 实时轮询线程只能依赖轻量运行态投影,不能复制和打印完整业务 Context。
  3. 转发模式切换要先停 Java runtime,再动 systemd 转发层,避免保活线程和关闭流程互相打架。
  4. 1 秒保活必须带 recovering 防重入,否则保活会变成重复重启。
  5. 前端配置页要区分草稿态和已生效态,不能让旧缓存覆盖用户正在编辑的值。
  6. 共享卡片需要共享 DTO 字段兜底,不能只在某个页面上拼展示逻辑。
  7. Codex 工作流也需要整理:显式目标优先、commit scope 按根因命名、worktree 清理只移除已被基线包含的干净工作区。

如果只看单点修复,今天像是在修 LoRa 和页面;如果看项目整理,今天实际是在把“现场运行态、配置状态、展示状态、agent 工作流”这几条容易互相污染的链路拆清楚。拆清楚之后,后面的 bug 就更容易定位到具体层级,而不是继续在 controller、manager、页面和部署脚本之间来回猜。


2026-07-01 LoRa 项目整理开发记录
https://willfordzhan.github.io/2026/07/01/2026-07-01-lora/
作者
詹文杰
发布于
2026年7月1日
许可协议