LumaRoom 离线灯具与最新意图重放技术封面

书桌台灯断网时,用户依次点击“观影”和“专注”。过了一会儿台灯重新上线,应用究竟应该把两条命令原样补发,还是只发最新的“专注”?如果照搬普通消息队列“失败就排队、恢复就全部重试”的习惯,灯可能先调暗,再突然变亮;更糟的是,如果“观影”旧回执在重连后才到,页面可能重新显示成上一轮状态。对灯光交互而言,最后一次有效意图通常比所有历史操作都更重要。

LumaRoom 连载已实现亮度/色温双滑杆、专注78%与4800K、观影28%与3200K、夜灯12%与2700K等预设,以及 desired、confirmed 分离。第四篇增加了主灯 main、台灯 desk、灯带 strip 的设备级发送与部分失败反馈。这次不再复制分组 UI,而是处理一个新维度:设备离线时如何保留最后意图、上线时如何有限重放、不同代次的回复如何隔离。示例依然使用本地构造传输,所谓“在线”只是测试开关,并未调用设备发现或智能家居厂商协议。

一、先定规则:离线队列只保存每设备一份目标

不同业务对重放的要求不同。日志和审计通常需要保留每条事件;灯光目标状态却适合“最后值覆盖”。用户在设备离线时拖动亮度数十次,不应该让重连后的灯具逐条执行这数十个值。我们为每台设备维护一份 pendingTarget,每接收到新的有效控制意图就替换它,而不是往数组后面追加。旧目标从等待队列淘汰,最新目标保留。

离线时最后值覆盖与重连重放状态关系

这条规则只适用于可覆盖的目标状态,例如“开关、亮度、色温”;对于“执行一次定时器”“切换场景序列”“解锁门禁”等具有事件性质的操作,不能简单用最后值覆盖。这也是为什么本例不伪造 HarmonyOS 的统一 IoT 设备 API:不同厂商设备的幂等语义、身份绑定与重连协议并不相同,应用必须在获得合法接入能力后通过适配器执行真实发送。本文实现的是独立于传输框架的业务协调逻辑。

需要保留几组容易混淆的数字。intentVersion 表示用户对某一台灯生成的目标代次,哪怕设备离线也递增;latestIssued 只有真正向适配器调用 send 时才递增。ack.sequence 必须与当时该设备的 latestIssued 匹配,才能修改确认状态;而 ack.intentVersion 必须等于当前目标版本,才能宣布当前意图已完成。一个控制代次负责意图新旧,一个发送序号负责应答归属,二者不能相互替代。

为了说明完整边界,沿用前篇 LightValues 的三个业务字段并补充设备记录。字段 selected 表示本轮是否参与批量控制;online 只是当前测试适配器报告的可达性;pendingTarget 是最新待下发的目标;confirmedText 是上次有效确认值,不因离线而清空。单次失败不能推断设备已经改变到什么状态,所以卡片应显示“未确认”而非“已关闭”。

interface LightValues {
  isOn: boolean;
  brightness: number;
  kelvin: number;
}
interface LightCommand {
  deviceId: string;
  sequence: number;
  intentVersion: number;
  target: LightValues;
}
interface LightAck {
  deviceId: string;
  sequence: number;
  intentVersion: number;
  accepted: boolean;
  applied: LightValues;
}
interface LampRow {
  id: string;
  label: string;
  selected: boolean;
  online: boolean;
  latestIssued: number;
  intentVersion: number;
  pendingTarget: LightValues | null;
  status: string;
  confirmedText: string;
}
function seedLamps(): LampRow[] {
  return [
    { id: 'main', label: '客厅主灯', selected: true, online: true,
      latestIssued: 0, intentVersion: 0, pendingTarget: null,
      status: '已确认', confirmedText: '78% · 4800K' },
    { id: 'desk', label: '书桌台灯', selected: true, online: false,
      latestIssued: 0, intentVersion: 0, pendingTarget: null,
      status: '离线', confirmedText: '—' },
    { id: 'strip', label: '背景灯带', selected: false, online: true,
      latestIssued: 0, intentVersion: 0, pendingTarget: null,
      status: '未参与', confirmedText: '—' }
  ];
}

二、把离线构造限制在传输层,不泄漏进 ArkUI 页面

页面仍使用 entry/src/main/ets/pages/LightPage.ets,可以把传输接口抽至 entry/src/main/ets/service/LightingTransport.ets。页面只知道 LightingTransport 这个接口。为了把重连边界示例清楚,示例适配器返回一次可控的成功回执,并且保留 deviceId、sequence、intentVersion 不变。online 的判断放在协调器里,设备离线时根本不调用 send。真实工程需要把网络断开、设备不响应、账号授权过期和设备主动拒绝区分开来;这四种状态可能需要完全不同的恢复动作,不能全写成“重试”。

interface LightingTransport {
  send(command: LightCommand): Promise<LightAck>;
}
class LocalLampTransport implements LightingTransport {
  send(command: LightCommand): Promise<LightAck> {
    return Promise.resolve({
      deviceId: command.deviceId,
      sequence: command.sequence,
      intentVersion: command.intentVersion,
      accepted: true,
      applied: { ...command.target }
    });
  }
}

这样定义的一个好处是无需真实设备也能对回执乱序进行确定性测试:测试层可以先保留 Promise,在第二条请求返回后再释放第一条,然后检查界面有没有把新状态冲掉。不能把这种本地构造写成“现场设备重连成功”;它只是证明业务代码在哪些条件下应该接纳结果。@State 的用途也要说清:它让组件数组替换后刷新列表,不是一个可靠消息队列,更不会自动把命令写到系统层。

三、用两个版本门禁挡住最容易出现的倒灌

先定义一个辅助方法替换单行数组,避免嵌套字段修改不触发 V1 状态观察。然后实现 queueIntent:每次预设被应用到已选灯具,都构造新的 LightValues 对象,令该设备 intentVersion+1。如果在线,立即尝试发送;如果离线,仅保留目标和“离线·待重放”文案。只有该设备被选中时才生成新意图;灯带没被选中时,不会因为其他灯具的操作而悄悄进入队列。

@Entry
@Component
struct LightPage {
  @State private brightness: number = 78;
  @State private kelvin: number = 4800;
  @State private activePresetId: string = 'focus';
  @State private lamps: LampRow[] = seedLamps();
  private transport: LightingTransport = new LocalLampTransport();

  private replaceLamp(next: LampRow): void {
    this.lamps = this.lamps.map((row: LampRow): LampRow =>
      row.id === next.id ? next : row);
  }
  private currentLamp(id: string): LampRow | undefined {
    return this.lamps.find((row: LampRow): boolean => row.id === id);
  }
  private queueIntent(id: string, values: LightValues): void {
    const row: LampRow | undefined = this.currentLamp(id);
    if (row === undefined || !row.selected) { return; }
    const next: LampRow = { ...row,
      intentVersion: row.intentVersion + 1,
      pendingTarget: { ...values },
      status: row.online ? '准备发送' : '离线·待重放'
    };
    this.replaceLamp(next);
    if (next.online) { this.sendLatest(id); }
  }
  private async sendLatest(id: string): Promise<void> {
    const row: LampRow | undefined = this.currentLamp(id);
    if (row === undefined || !row.online || row.pendingTarget === null) { return; }
    const seq: number = row.latestIssued + 1;
    const intentVersion: number = row.intentVersion;
    const command: LightCommand = { deviceId: id, sequence: seq,
      intentVersion: intentVersion, target: { ...row.pendingTarget } };
    this.replaceLamp({ ...row, latestIssued: seq, status: '等待回执' });
    try {
      const ack: LightAck = await this.transport.send(command);
      const latest: LampRow | undefined = this.currentLamp(id);
      if (latest === undefined || !latest.online ||
        ack.deviceId !== id || ack.sequence !== latest.latestIssued ||
        ack.intentVersion !== latest.intentVersion) { return; }
      this.replaceLamp({ ...latest,
        pendingTarget: ack.accepted ? null : latest.pendingTarget,
        status: ack.accepted ? '已确认' : '未接纳',
        confirmedText: ack.accepted
          ? `${ack.applied.brightness}% · ${ack.applied.kelvin}K`
          : latest.confirmedText });
    } catch (error) {
      const latest: LampRow | undefined = this.currentLamp(id);
      if (latest !== undefined && latest.latestIssued === seq &&
        latest.intentVersion === intentVersion) {
        this.replaceLamp({ ...latest, status: '发送失败·待人工重试' });
      }
    }
  }
  private setConnected(id: string, online: boolean): void {
    const row: LampRow | undefined = this.currentLamp(id);
    if (row === undefined) { return; }
    this.replaceLamp({ ...row, online: online,
      status: online
        ? (row.pendingTarget !== null ? '在线·待同步' : '在线')
        : (row.pendingTarget !== null ? '离线·待重放' : '离线') });
    if (online && row.pendingTarget !== null) { this.sendLatest(id); }
  }

这段 setConnected 不是一个凭空出现的系统生命周期回调,而是示例 UI 按钮触发的业务方法。它与第三、四篇的“按设备过滤旧回执”保持同一逻辑,只是在离线场景多保留了一个 pendingTarget。若在某次发送尚未完成时再次发起更新,latestIssued 和 intentVersion 都可能变化;旧结果到了以后不能更新当前灯具。设备重新上线时只取最新待重放对象,避免历史队列依次执行。

DevEco Studio 白色主题:LightPage.ets 重连状态与指令门禁

需要注意失败情况下的策略。示例中 catch 只标记“待人工重试”,不自动无限递归发送;因为离线刚恢复时若设备仍然拒绝,自动重放可能形成高频循环。实际项目应增加可配置的退避、次数预算、超时与用户可见的取消按钮。尤其用户在等待过程中切换了新的预设,旧请求的结果必须自然淘汰,但设备端是否已经执行旧指令仍须通过可靠读回或服务端协调确认,客户端过滤并不能改变物理世界。

四、把结果显示为“哪盏灯、哪个目标、何种证据”

页面保留原有光境控制台的亮度和色温信息,以及三个预设按钮。作为第五篇的新交互,增加“让台灯上线”的本地构造按钮,供读者验证离线重放路径。示例操作为:先在台灯离线时点“观影”,再点“专注”,最后点“让台灯上线”。主灯在两轮中正常发送;台灯只应发送最后一份78%/4800K目标;背景灯带由于未选中,仍显示“未参与”。如果再次切换场景,新的 intentVersion 必须替代上次值。这份页面示意也不能被解读为系统真实探测到了台灯的上线状态。

  private applyPreset(id: string, brightness: number, kelvin: number): void {
    this.activePresetId = id;
    this.brightness = brightness;
    this.kelvin = kelvin;
    const target: LightValues = {
      isOn: true, brightness: brightness, kelvin: kelvin
    };
    this.lamps.filter((row: LampRow): boolean => row.selected)
      .forEach((row: LampRow): void => this.queueIntent(row.id, target));
  }
  build() {
    Column({ space: 16 }) {
      Text('光境控制台').fontSize(28).fontWeight(FontWeight.Bold)
      Text(`当前目标 ${this.brightness}% · ${this.kelvin}K`)
      Row({ space: 8 }) {
        Button('专注').onClick(() => this.applyPreset('focus', 78, 4800))
        Button('观影').onClick(() => this.applyPreset('cinema', 28, 3200))
        Button('夜灯').onClick(() => this.applyPreset('night', 12, 2700))
      }
      ForEach(this.lamps, (row: LampRow) => {
        Column({ space: 7 }) {
          Text(row.label).fontSize(19).fontWeight(FontWeight.Medium)
          Text(row.online ? '在线' : '离线').fontColor('#64748B')
          Text(row.status).fontColor('#56627B')
          Text('上次确认 ' + row.confirmedText)
        }.padding(16).width('100%').backgroundColor('#FFFFFF')
      }, (row: LampRow): string => row.id)
      Button('让台灯上线(本地构造)')
        .onClick(() => this.setConnected('desk', true))
    }.padding(22).width('100%').height('100%')
      .backgroundColor('#F7F5FC')
  }
}

尽管本例不暴露完整滑杆代码,brightness、kelvin 和 activePresetId 的语义始终与第二篇一致。真实项目可以把本篇的重放协调器拆到 service/LightingCoordinator.ets,让页面只负责渲染和发起用户意图;本文为了突出方法间依赖,集中展示关键代码。更进一步的工程解耦,应让协调器通过状态流向 UI 通知每盏灯的最新状态,而不是把设备网络状态塞进 @State 的每个组件里。把“页面组件重新出现”直接等同于“设备重连”,会导致重复发送,应明确分开两类生命周期。

五、验证重连模型时,不要只看最终颜色

建议至少验证以下四段事件时间线。第一段,台灯离线时连续收到两个目标,队列长度始终为一份,并只保留最新的78%/4800K;第二段,构造上线后应只发一次最新目标,旧目标不会被补发;第三段,在先前在线发送还未返回时构造掉线,随后生成新意图并重连,构造旧序号回执晚于新回执的顺序,检查 ack.sequence 或 ack.intentVersion 不匹配时页面状态保持原值;第四段,在等待中再次标记离线,任何仍在途回执都不能把当前设备卡片标为在线。使用可控的 Promise 可以把这些操作变成稳定的自动化测试,而不是依赖真实无线网络偶然出错。

离线、重连、旧回执与最终确认的事件时间线

本方案仍有三个清晰边界。其一,latestIssued 只是组件运行期的本地序号,跨进程重启后若需要恢复等待意图,应另设具有明确保存策略的数据模型,并评估隐私与安全;其二,设备可能在离线期间被其他客户端操作过,重连后直接覆盖不一定符合用户期待,需要接入设备真实状态、版本比较或二次确认;其三,用户解除选择、注销账号或丢失设备权限时,应主动清除不再授权的重放目标,不能把排队命令无限期留在本地。

从第五篇可以得到一个可迁移的设计结论:设备期望值可以合并,执行证据不可凭空推断。应用可以保证自己只展示被当前代次接纳的回执,却不能用一组本地状态字段声明物理灯具一定已经达到目标。下一篇将把连载收束到状态恢复、异常分级与发布前测试清单,检查所有阶段之间是否存在遗漏的责任边界。

相关基础接口见华为开发者官方https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-state/@State状态变量、ArkUI状态管理概览以及Slider组件说明。本篇不使用未经核实的系统级智能灯接入接口,不依赖 HarmonyOS 7 特定宣传能力。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐