# remote-control **Repository Path**: code-ape-hc/remote-control ## Basic Information - **Project Name**: remote-control - **Description**: remote-control · 手机红外空调遥控器 基于 uni-app(Vue3)的 Android 应用,用手机自带红外发射头直接控制空调,不依赖任何云端、不加载 bin 码库、不跑 Java/C 引擎——整个编码过程由纯 JS 完成。 内置 103 个品牌 / 462 个型号的红外编码数据,覆盖格力、美的、海尔、大金、三菱电机等主流空调。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 1 - **Created**: 2026-09-04 - **Last Updated**: 2026-09-08 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # remote-control · 手机红外空调遥控器 基于 uni-app(Vue3)的 Android 应用,用手机自带红外发射头直接控制空调,**不依赖任何云端、不加载 bin 码库、不跑 Java/C 引擎**——整个编码过程由纯 JS 完成。 内置 **103 个品牌 / 462 个型号**的红外编码数据,覆盖格力、美的、海尔、大金、三菱电机等主流空调。 | 项 | 值 | |---|---| | appid | `__UNI__92C2028` | | 框架 | uni-app + Vue 3(`vueVersion: 3`) | | 平台 | Android(需设备带 `ConsumerIrManager` 红外发射头) | | 版本 | 1.0.0(versionCode 100) | | 编码引擎 | `utils/ir.js`,纯 JS,约 240 行 | --- ## 一、快速开始 ### 环境要求 - HBuilderX(推荐,本项目由 HBuilderX 创建,含 `.hbuilderx/launch.json`) - 或 uni-app CLI - 测试机:**必须带红外发射头**(小米/华为/荣耀部分机型、红外遥控手机)。无红外硬件时「检测红外」会提示「无红外头」,但编码与预览功能仍可正常调试。 ### 运行 1. HBuilderX 中打开项目根目录 2. `运行` → `运行到手机或模拟器` → `运行到 Android App 基座` 3. 手机需开启 USB 调试 ### 打包 `发行` → `原生App-云打包` 或本地打包。已有产物:`unpackage/debug/android_debug.apk`。 ### 权限 `manifest.json` 的 `app-plus.distribute.android.permissions` 中已声明: ```xml ``` > `TRANSMIT_IR` 是**普通权限(normal)**,在 manifest 声明后安装即授予,无需运行时动态申请。代码里 `checkTransmitIr()` 只做校验不做 request,这是**正确**的,不要改成动态申请。 --- ## 二、目录结构 ``` remote-control/ ├── App.vue 应用生命周期钩子 ├── main.js Vue3 入口(createSSRApp) ├── index.html H5 模板 ├── manifest.json 应用配置(权限、appid、Vue 版本) ├── pages.json 页面路由(仅一个页面) ├── uni.scss 全局样式变量 ├── pages/ │ └── index/ │ └── index.vue 唯一页面:全部 UI + plus.android 原生调用 ├── utils/ │ └── ir.js ★ 核心编码库 ├── static/ │ └── ir/ │ ├── brands.json 索引:103 个品牌 + 中文标签定义 │ └── *.json 103 个品牌码库文件(共 462 个型号) └── unpackage/ 构建产物(已在 .gitignore 中) ``` --- ## 三、核心架构:编码链路 从用户点按到红外发射,共三步: ``` ┌─ 输入 ─────┐ ┌─ 编码 ──────────┐ ┌─ 展开 ─────────┐ ┌─ 发射 ──────┐ │ UI 状态 │ │ encodeAc() │ │ expandHex() │ │ transmit() │ │ power │──▶│ 查表 → 置位 │──▶│ hex → bits │──▶│ 38kHz 发射 │ │ mode temp │ │ → 校验 │ │ → 脉宽数组 │ │ │ │ speed swing │ │ 产出 hex │ │ 产出 pulses[] │ │ │ └─────────────┘ └────────────────┘ └────────────────┘ └─────────────┘ ▲ │ 编码配方 ┌────────────────────┐ │ static/ir/*.json │ │ 103 品牌 / 462 型号 │ └────────────────────┘ ``` ### 第 1 步:`encodeAc(enc, state)` → hex 把 5 个状态维度编码成一帧十六进制数据。 **关键机制是混合基数 `clusterIndex`**。每个字段 `f` 声明自己依赖哪些维度(`f.d`),只有声明了的维度参与累加,基数是固定的: | 维度 | 基数 | 取值范围 | |---|---|---| | power | 2 | 0=开,1=关 | | mode | 5 | 制冷/制热/自动/送风/除湿 | | temp | 15 | 0~14(对应 16~30℃) | | speed | 4 | 自动/低/中/高 | | swing | 2 | 摆风开/摆风关 | 计算方式(按 `order` 固定顺序 `power → mode → temp → speed → swing` 累乘累加): ```js // f.d = ['mode', 'temp'] 时: ci = mode * 15 + temp // 表长需 ≥ 75 // f.d = ['power','mode','temp','speed','swing'] 时: ci = ((((power*5 + mode)*15 + temp)*4 + speed)*2 + swing) // 表长需 ≥ 1200 ``` 拿到 `ci` 后查 `f.v[ci]` 得到该字段的十六进制值,按 `f.n` 位宽拆成 bit 串,再用 `setBits()` 打到 `f.p` 声明的 bit 位置上。所有字段打完后,用 `applyCs()` 计算校验字节。 > 温度是**偏移量**:`state.temp = 摄氏度 - 16`,即 16℃ → 0,30℃ → 14。全库 462 个型号的 `tempMin/tempMax` 统一为 16/30,与基数 15 刚好吻合,不会溢出。 ### 第 2 步:`expandHex(model, hex)` → 脉宽数组 把 hex 按 `layout` 描述展开成 Android `ConsumerIrManager` 需要的脉宽数组(微秒)。 `timing` 是 6 元组,定义基础波形(**注意 0 在前、1 在后**): ``` timing = "leadH, leadL, 0H, 0L, 1H, 1L" 引导码高 引导码低 0高 0低 1高 1低 ``` `layout` 由三种 token 组成: | token | 含义 | |---|---| | `L` | 引导码,push `leadH, leadL` | | `G` | 间隔码,push `0H`,再 push `gap[]` 的下一个值 | | 数字 `N` | 连续 N 个数据 bit,每个 bit 按 0/1 push `(0H,0L)` 或 `(1H,1L)` | > `gap` 数组按**帧顺序**排布:元素个数 = layout 中 `G` 的个数 × `model.repeat`。例如 `layout="L48G"`、`repeat=2` 时,`gap="5360,5360"` 提供两帧各自的间隔。全库 462 个型号的 gap 数量与需求量**完全匹配,无缺口**。 最后补齐:`expandHex()` 在脉宽数为偶数时补一个 `0H`,`encodePulses()` 再补 `TAIL_IDLE = 160000`(160ms 静默)。**保证最终为偶数段**——这是 `ConsumerIrManager.transmit()` 的硬性要求。 ### 第 3 步:`transmit(38000, pulses)` ```js const mgr = activity.getSystemService('consumer_ir') plus.android.importClass(mgr) if (!mgr.hasIrEmitter()) throw new Error('无红外头') mgr.transmit(38000, r.pulses) ``` --- ## 四、码库数据格式 ### `brands.json`(索引) ```json { "v": 3, "enc": "encode.js", "flag": { "1": "支持,控件可点", "0": "不支持,控件禁用或隐藏" }, "mode": ["制冷", "制热", "自动", "送风", "除湿"], "speed": ["自动", "低", "中", "高"], "swing": ["摆风开", "摆风关"], "list": [ { "file": "gree", "name": "格力", "count": 26 } ] } ``` `mode/speed/swing` 三组中文标签由页面启动时读取,覆盖组件内的默认值。`flag` 是能力位说明,决定了 UI 控件是禁用还是隐藏。 ### 品牌文件(如 `gree.json`) ```json { "brand": "gree", "list": [ { /* 型号对象 */ } ] } ``` #### 型号对象字段 | 字段 | 类型 | 说明 | |---|---|---| | `id` | number | 型号唯一 ID,也是 picker 里显示的文字 | | `timing` | string | 6 元组:`leadH,leadL,0H,0L,1H,1L` | | `gap` | string | 间隔码数组,按帧顺序排列,可为空串 | | `layout` | string | 帧结构,由 `L` / `G` / 数字 组成 | | `repeat` | number | 整帧重复次数,1 / 2 / 3 | | `mode` | number[5] | 5 种模式的 flag,1=可用 0=禁用 | | `speed` | number[4] | 4 档风速的 flag | | `swing` | number[2] | 2 种摆风的 flag | | `tempMin` / `tempMax` | number | 温度范围,全库统一 16 / 30 | | `windDir` | number | 风向能力位(当前代码**未使用**) | | `enc` | object | 编码配方,见下 | #### `enc` 编码配方 | 字段 | 说明 | |---|---| | `kind` | `recipe`(查表编码,456 个)/ `const`(固定帧,6 个) | | `nbits` | 帧有效 bit 数,可能大于 layout 实际消耗的 bit 数(多余忽略) | | `base` | 基础帧 hex,所有字段在此之上打位 | | `off` | 关机帧 hex,存在时 `power=1` 直接返回(319 个型号有) | | `rep` | body 重复次数(1 / 2 / 3) | | `f[]` | 字段定义数组,见下 | | `cs[]` | 校验规则数组,见下 | **`f[]` 字段定义**(全库 1204 个,每个都含 4 个 key): ```json { "d": "mode", "p": [0,1,2,67,68,69], "n": 6, "v": ["24","09","00","36","12"] } ``` | key | 说明 | |---|---| | `d` | 依赖维度,字符串或数组 | | `p` | bit 位置数组,长度恒等于 `n` | | `n` | 字段位宽 | | `v` | 查表数组,长度 = 各维度基数之积 | **`cs[]` 校验规则**(122 个型号有,共 443 条;当前数据只用到了 `not`): | op | 行为 | |---|---| | `not` | `b[dst] = ~b[src] & 0xff` | | `sum` | `b[dst] = (plus + Σ b[a..b)) & 0xff` | | `xor` | `b[dst] = (plus ^ ...) & 0xff` | | 其他 | BCD 求和:`v += (b[j]>>4) + (b[j]&0x0f)` | `inv` 为 true 时结果再取反。规则按顺序执行,后面的规则可以读到前面的结果。 --- ## 五、完整实例:格力 id 10727 ```jsonc { "id": 10727, "timing": "9000,4500,610,580,610,1680", "gap": "20000,40000,20000", "layout": "L35G32GL35G32", "repeat": 1, "mode": [1,1,1,1,1], "speed": [1,1,1,1], "swing": [1,1], "tempMin": 16, "tempMax": 30, "enc": { "kind": "recipe", "nbits": 136, "rep": 1, "base": "1200040a5100800002400081c800000030", "off": "0200040a5100800160400081c800000030", "f": [ { "d": "mode", "p": [0,1,2,67,68,69], "n": 6, "v": ["24","09","00","36","12"] }, { "d": ["mode","speed"], "p": [...], "n": 7, "v": [...] } ], "cs": [] } } ``` **layout 解析**:`L` `35` `G` `32` `G` `L` `35` `G` `32` → 共 9 个 token,消耗 35+32+35+32 = **134 bit**(`nbits` 是 136,最后 2 bit 未使用)。 **脉宽数**:`2 + 35×2 + 2 + 32×2 + 2 + 2 + 35×2 + 2 + 32×2 = 278`,偶数补 `0H` → 279,再补 `TAIL_IDLE` → **280 段**。 **默认状态输出**(制冷 26℃ 自动风 摆风开): ``` hex 9250040a51008000124a0081c800000030 (34 字节) pulses 280 段,总时长约 455ms 首段 9000,4500,610,1680,610,580,610,580,610,1680... 尾段 ...,610,1680,610,580,610,580,610,160000 ``` --- ## 六、页面功能 `pages/index/index.vue` 单页,自上而下: 1. **检测区** — 「检测红外」按钮,输出权限声明/授予状态、`hasIrEmitter()` 结果;「发射」按钮手动重发当前帧 2. **选择区** — 品牌 picker(103 项)+ 型号 picker(显示型号 ID) 3. **控制区** — 开关、5 种模式、温度 ±(受 `tempMin/tempMax` 约束)、4 档风速、2 种摆风。**任何操作都会立即发射** 4. **误差偏移区** — 见下节 5. **红外码值区** — 显示 `data`(hex)+ 完整脉宽数组(可滚动、可复制),底部标注段数 / 总时长 / 字符数 6. **日志区** — 每次发射记一行完整红外码值,上限 20 条(超出丢弃最旧),可清空 #### 随机±误差 对**实际发射的脉宽**施加逐值独立、带随机符号的百分比缩放,用于复现"实测脉宽偏离码库标称值"这一客观现象,测试空调接收端的容错边界。拆成两步: **步骤 1 — 设定误差幅度**:一条滑条在 **0 ~ 100%**(step 1)设定统一幅度 `R`。 **步骤 2 — 随机符号**:对 `timing` 的 6 个值和 `gap` 的每个值,各掷硬币决定该值是放大 `×(1+R)` 还是缩小 `×(1-R)`(即符号 `+1`/`-1`)。**每个值独立**,一半放大一半缩小。 - 作用对象:**仅 `timing` 的 6 个值和 `gap` 的全部值**,`TAIL_IDLE`(160ms 帧尾静默)与 `hex` 不参与缩放 - 随机时机:**拖动滑条中**仅实时预览(沿用当前符号集、幅度跟随,不换符号);**松手(change)**时持久化幅度并**重新 roll 一组符号**锁定。幅度不变时多次重发、多次预览码值一致(可复现);点「重新随机」可手动换一批符号 - 幅度为 **0** 时清空符号、完全等价标称,行为与旧固定偏移、默认发射一致 - 换型号 `selectModel()` 会**重新 roll**(新模型 timing/gap 值数不同,必须重建符号组) - 设置项持久化在 `uni.setStorageSync('irErrRange')`(存幅度整数 0~100,不再存符号),启动时在 `selectBrand()` **之前**读取,避免先按 0% 渲染 - 每行用「T0…/G0… 标称 → 实际 + ↗放大 / ↘缩小」展示逐值结果,帧总时长标称 → 实际一并显示 参考取值:对照 462 个型号的实测偏差分布,中位 1.3%、P90 7.8%、最大 14.3%,故幅度上限放宽到 100% 仅供压力测试,常规容错实验一般用个位数到二三十即可。 **精度坑(务必保留现写法)**:缩放公式必须写 `Math.round(v * (100 + pct) / 100)`,**不能**写成 `Math.round(v * (1 + pct / 100))`。后者因浮点误差会少进一位: ``` 330 * 1.15 = 379.49999999999994 Math.round(330 * (1 + 0.15)) = 379 ← 错 Math.round(330 * 115 / 100) = 380 ← 对 ``` 两种写法在整帧各值逐段 `Math.round` 下,因浮点误差会在进位边界产生系统性偏差(旧数据实测 462 型号 × -20~20 共 41 档有 **834 处结果不同**)。随机±误差每值独立取 `sign`,错公式同样会在进位边界丢一位,故 `scalePct(330, 15) === 380` 与 `armcor 5837 timing 3430 @ +15% → 3945` 两条精度断言锁死公式。 几个实现细节: - 品牌数据按 file 名缓存进 `brandCache`,切换品牌不重复读文件 - 模式/风速/摆风按钮的禁用状态直接读 `model.mode[i]` 等 flag - 启动时默认选中格力(`file === 'gree'`) - `loadIrJson()` 优先用 `plus.io` 读 `_www/static/ir/`,失败回落到 `uni.request('/static/ir/')` - `FILE_ALIAS` 处理文件名别名:`aux` → `aux-ac.json` - 日志改为 `{ id, text }` 对象数组 + keyed `v-for`。单条日志约 1180 字符,若用单文本节点累积 50 条就是 59KB 且每次追加都要全量重排;改数组后 Vue 只做增量 patch,配合 20 条上限(约 24KB)与固定高度 `scroll-view`,开销恒定 - 误差状态拆成 `errRange`(ref,幅度 0~100)+ `errSigns`(reactive `{tSign, gSign}`,锁定的符号组);滑条驱动 `errRange`,`onErrChanging`(拖动,实时预览不换符号)与 `onErrChange`(松手,roll+持久化)分工 --- ## 七、自验方法 改完 `utils/ir.js` 后,直接用 Node 跑,不用装 App。核心回归脚本已固化在 `.tmp/err-test.mjs`(18 项断言:缩放精度、向后兼容、rollSigns 结构、逐值符号、脉冲偶数): ```bash node .tmp/err-test.mjs ``` 核心链路探针(格力 10727 标称态回归)仍可用: ```js // .tmp/probe.mjs import { encodePulses, defaultState } from '../utils/ir.js' import fs from 'fs' const m = JSON.parse(fs.readFileSync('<项目路径>/static/ir/gree.json', 'utf-8')).list.find(x => x.id === 10727) const r = encodePulses(m, defaultState(m)) console.log(r.hex) // 9250040a51008000124a0081c800000030 console.log(r.pulses.length) // 280 console.log(r.pulses.length % 2) // 0(偶数) ``` > Node ESM 下 Windows 绝对路径 import 会报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`,`.tmp/` 内测脚本用相对路径 `../utils/ir.js` 导入即可。 **改完必跑反证**:临时把 `scaleSigned` 里 `sign` 忽略(恒用 `+pct`),`err-test.mjs` 的「全 -1 符号 === 旧 -pct」「逐值 round 公式」「出现↗↘两种值」断言必须变红;否则等于没测。验证后立即改回。 **UI 编排改动**(`index.vue` 的误差状态时序)不建执行环境,靠代码审查 + 真机验证即可——改动量小,逻辑经 grep 核对符号、逐一读代码确认即可覆盖,不值得为纯编排逻辑搭组件测试 harness。 **回归基准**: | 检查项 | 期望值 | |---|---| | 格力 10727 默认态 hex | `9250040a51008000124a0081c800000030` | | 脉宽段数 | 280(偶数) | | 码值串长度 | 1156 字符 | | 关机帧 | 走 `enc.off`,hex 前缀 `0200040a...` | | 制热 30℃ 高风 | hex 前缀 `3e70040a...` | **缩放精度回归**(随机±误差无确定性总时长基准,但缩放公式必须锁死): | 断言 | 期望 | |---|---| | `scalePct(330, 15)` | `380` | | `scalePct(3430, 15)`(armcor 首值) | `3945` | | 符号组全 `+1` 的 `encodePulses(m, st, R, s)` | === 旧统一 `+R%` 输出(向后兼容) | | 符号组全 `-1` 的 `encodePulses(m, st, R, s)` | === 旧统一 `-R%` 输出 | | `range=0` 带任意符号 | === 标称(`encodePulses(m, st)`) | | `rollSigns(model, R)` | `tSign` 长 6,`gSign` 长 = gap 值数,仅含 ±1;`R=0` 时全 `+1` | | 逐值带符号输出 | 每个值 = `Math.round(v*(100+R*sign)/100)`,且同帧出现放大↗与缩小↘ | Node 实测脚本 `.tmp/err-test.mjs`(18 断言)覆盖以上全部。改 `ir.js` 后应全绿;已做反证(临时忽略符号 → 4 断言变红)确认测试真实有效。 **逐段舍入漂移**:逐段 `Math.round` 会让"整帧幅度 ×K"与"逐段 round 之和"产生累计差(如全 +1 时总时长 ≠ 455300×1.05)。随机符号下每个值独立舍入,漂移不可用单一公式预测,故只做上述精度断言、不做总时长数值断言。 三条最容易写错的点: 1. **缩放公式必须写 `Math.round(v*(100+pct*sign)/100)`**,用 `v*(1+pct*sign/100)` 会因浮点少进一位(见下方精度坑)。 2. **符号只作用于 timing/gap 每个值,`TAIL_IDLE` 与 `hex` 永不缩放**;倒数第 2 段的补位 `zH` 随符号缩放、末段恒为 `160000`。 3. **换型号后 `errSigns` 必须重建**(新模型 timing/gap 值数不同),`selectModel()` 里调用 `applyError()` 保证;组件级测试 [F] 已覆盖。 **码库完整性校验**(新增/修改数据后建议跑): | 校验项 | 结果 | |---|---| | 型号总数 | 462 | | `enc.kind` 取值 | 仅 `recipe`(456) / `const`(6) | | `mode/speed/swing` 数组长度 | 恒为 5 / 4 / 2 | | `f.p.length === f.n` | 全部 1204 个字段通过 | | `f.v.length ≥ 各维度基数之积` | 全部通过 | | `gap` 元素数 ≥ `G` 数 × `repeat` | 全部通过 | | `tempMin/tempMax` | 全部 16 / 30,与 temp 基数 15 吻合 | --- ## 八、已知问题与 TODO | 优先级 | 问题 | 说明 | |---|---|---| | 高 | **无 git 仓库** | `.git` 不存在,改代码零版本保护。建议立即 `git init`(`.gitignore` 已配好,会忽略 `unpackage/` 和 `.hbuilderx/`) | | 中 | `selectBrand()` 硬编码 `id === 10727` | 该 ID 只存在于格力码库,其他品牌都回落 index 0。功能不出错,但意图不明,建议改为按品牌自己的首个型号或直接取 0 | | 低 | `lutBits(f.v[ci] \|\| f.v[0], n)` | 查表值若恰好是字符串 `"0"` 会被判为 falsy 而错误回落。当前数据用 `"00"` 这类值,**未触发**,属潜在隐患 | | 低 | `windDir` 字段未被使用 | 462 个型号都有该字段,代码里没读 | | 低 | 型号 picker 显示数字 ID | 对用户不友好,但原始数据中没有型号名称字段 | --- ## 附录 A:品牌清单(103 个 / 462 型号) | 品牌(型号数) | 品牌(型号数) | 品牌(型号数) | 品牌(型号数) | |---|---|---|---| | 三菱电机(32) | 海尔(29) | 格力(26) | 三洋(19) | | 万士益(15) | 内田(14) | 歌林(13) | 美的(13) | | 格兰仕(12) | 三菱重工(11) | 蓝波(11) | 大金(10) | | 日立(10) | 东元(9) | 华凌(9) | 春兰(9) | | 松下(9) | 志高(8) | 珍宝(8) | LG 乐金(7) | | 乐华(7) | 海信(7) | 澳柯玛(7) | 声宝(6) | | 月兔(6) | 长虹(6) | 伊莱克斯(5) | 吉普生(5) | | 长岭(5) | 东宝(4) | 奥克斯(4) | 小天鹅(4) | | 开利(4) | 日电(4) | 禾联(4) | 科龙(4) | | 约克(4) | Armcor(3) | Fujiaire(3) | 三星(3) | | 上菱(3) | 东芝(3) | 华菱(3) | 汇丰(3) | | 迎燕(3) | Blue Star(2) | Doctor(2) | FUNIKI(2) | | Godrej(2) | Jiangnan(2) | Oak(2) | 东新宝(2) | | 双鹿(2) | 古桥(2) | 威力(2) | 富士通(2) | | 惠而浦(2) | 新禾(2) | 新科(2) | 格威尔(2) | | 爱家乐(2) | 索伊(2) | 美菱(2) | 西冷(2) | | 金松(2) | Bosko(1) | IGC(1) | KelVinator(1) | | Kris(1) | Lvt(1) | Nikko(1) | TCL(1) | | Voltas(1) | 万家乐(1) | 三钻(1) | 佳乐(1) | | 冰点(1) | 创维(1) | 华宝(1) | 华美(1) | | 国菱(1) | 国际(1) | 天津(1) | 宜科(1) | | 康佳(1) | 惠威(1) | 扬子(1) | 新飞(1) | | 熊猫(1) | 爱德龙(1) | 特灵(1) | 现代(1) | | 申花(1) | 福奈(1) | 维友康(1) | 艾普顿(1) | | 莎普罗(1) | 金星(1) | 金达(1) | 飞利浦(1) | | 高士达(1) | 高路华(1) | 黄河(1) | | ## 附录 B:`utils/ir.js` 导出 API | 函数 | 签名 | 说明 | |---|---|---| | `encodeAc` | `(enc, state) → hex` | 状态编码为 hex 字符串 | | `expandHex` | `(model, hex, pct?, signs?) → number[]` | hex 展开为脉宽数组。`pct` 统一偏移百分比;`signs`(可选)为 `{tSign,gSign}` 符号组时,改走逐值随机±(`Math.round(v*(100+pct*sign)/100)`) | | `encodePulses` | `(model, state, pct?, signs?) → { hex, pulses }` | 上面两步的组合,页面实际调用这个 | | `scalePct` | `(v, pct) → number` | 单值统一偏移缩放。`pct` 为 0 或 undefined 时原值返回(旧 API,保留) | | `scaledTiming` | `(model, pct?, signs?) → { timing[], gap[] }` | 供 UI 显示「标称 → 实际」对比;传 `signs` 时逐值随机± | | `rollSigns` | `(model, range) → { tSign, gSign }` | 掷硬币生成 timing/gap 每值的 ±1 符号组;`range=0` 时全 `+1` | | `defaultState` | `(model) → state` | 生成默认状态(开机、首个可用模式、26℃、首个可用风速/摆风) | | `firstEnabled` | `(flags) → number` | 返回第一个 flag 为 1 的下标 | | `loadIrJson` | `(file) → Promise` | 读取码库 JSON,带别名映射与双通道回落 | | `TAIL_IDLE` | `160000` | 帧尾静默时长(微秒),不参与误差缩放 | > `pct`、`signs` 均为可选。不传 `signs` 时 `pct` 走旧的统一偏移(`scalePct`),行为与旧版完全一致;传 `signs` 且 `pct>0` 时对 timing/gap 每个值独立 `×(100+pct*sign)/100`,`hex` 与 `TAIL_IDLE` 永不缩放。