@ziztechnology/dial-library 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,115 +1,196 @@
1
- # Toooony 表盘用户 SDK
1
+ # Toooony 表盘 SDK
2
+
3
+ `@ziztechnology/dial-library` 为 Toooony 表盘提供统一的设备信息和行车状态 API。你可以用它读取传感器,也可以直接订阅经过平滑、确认和回退处理后的行车状态。
2
4
 
3
5
  > [!NOTE]
4
- > 最低支持 Runtime 版本 `v1.5.20`。行车状态传感器 Bridge 仅供经过审核的 `PACKAGED_H5` 使用;第三方应依赖本 SDK API,不要把 Runtime `window` 内部方法当作稳定接口。
6
+ > SDK 最低支持 Toooony Runtime `v1.5.20`。行车状态传感器 Bridge 仅向审核通过的 `PACKAGED_H5` 开放。第三方表盘应使用本 SDK,不要直接依赖 Runtime 注入到 `window` 上的内部方法。
5
7
 
6
8
  ## 安装
7
9
 
10
+ 使用 npm、pnpm 或 yarn 安装:
11
+
12
+ ```bash
13
+ npm install @ziztechnology/dial-library
14
+ ```
15
+
8
16
  ```bash
9
- npm i @ziztechnology/dial-library
17
+ pnpm add @ziztechnology/dial-library
10
18
  ```
11
19
 
12
- ## 行车状态母版快速接入
20
+ ```bash
21
+ yarn add @ziztechnology/dial-library
22
+ ```
23
+
24
+ SDK 是 ESM 包,并自带 TypeScript 类型。
25
+
26
+ ## 快速开始
27
+
28
+ 下面的例子会监听行车状态,并把当前状态显示在页面上:
29
+
30
+ ```html
31
+ <p id="driving-status">正在读取行车状态…</p>
32
+ ```
13
33
 
14
34
  ```ts
15
- import {
16
- OFFICIAL_DRIVING_EXPRESSION_FALLBACKS,
17
- createDrivingExpressionPlayer,
18
- createDrivingStatusController,
19
- readDrivingExpressionsConfig,
20
- } from '@ziztechnology/dial-library';
35
+ import { CAR_RUNNING_LABELS, createDrivingStatusController } from '@ziztechnology/dial-library';
21
36
 
22
- const config = readDrivingExpressionsConfig();
23
- if (!config) throw new Error('Unsupported or invalid drivingExpressions config');
37
+ const statusElement = document.querySelector('#driving-status');
38
+ if (!statusElement) throw new Error('找不到 #driving-status');
24
39
 
25
- const player = createDrivingExpressionPlayer(document.querySelector('#expression')!, {
26
- config,
27
- // 官方母版必须显式传入;第三方不传时不会自动获得官方 Emoji。
28
- fallbacks: OFFICIAL_DRIVING_EXPRESSION_FALLBACKS,
29
- });
30
40
  const controller = createDrivingStatusController();
31
41
 
32
- await player.show('STOPPED');
42
+ // 控制器默认从 STOPPED 开始。subscribe 只在稳定状态改变后触发。
43
+ statusElement.textContent = CAR_RUNNING_LABELS[controller.getSnapshot().status];
44
+
33
45
  const unsubscribe = controller.subscribe((event) => {
34
- if (event.type === 'status_changed') void player.show(event.status);
46
+ statusElement.textContent = CAR_RUNNING_LABELS[event.status];
35
47
  });
36
- controller.start();
37
48
 
38
- // 也可以由母版显式控制;默认会自动接入 Runtime pause/resume 和页面生命周期。
39
- const pause = () => {
40
- controller.stop();
41
- player.pause();
42
- };
43
- const resume = () => {
44
- player.resume();
45
- controller.start();
46
- };
49
+ controller.start();
47
50
 
48
- // 页面销毁时:
51
+ // 页面不再使用控制器时释放资源。
49
52
  const destroy = () => {
50
53
  unsubscribe();
51
54
  controller.destroy();
52
- player.destroy();
53
55
  };
54
56
  ```
55
57
 
56
- 重复调用 `start()`、`stop()`、`destroy()`、`pause()` `resume()`是安全的。启动时 UI 应先显示 `STOPPED`,只有已提交状态才触发媒体切换,候选状态不会触发 UI。
58
+ `start()` 启动传感器轮询;`destroy()` 停止轮询、取消未完成的读取并移除监听器。`start()`、`stop()` `destroy()` 都可以重复调用。
57
59
 
58
- ## 稳定的八状态协议
60
+ 控制器默认会跟随 Runtime 和页面生命周期自动暂停、恢复,因此大多数表盘不需要额外监听 `visibilitychange`。
59
61
 
60
- `CarRunningStatus` 的 wire 值和 `CAR_RUNNING_STATUSES` 固定为:
62
+ ## 常量
61
63
 
62
- ```text
63
- STOPPED
64
- STEADY_DRIVING
65
- ACCELERATION
66
- RAPID_ACCELERATION
67
- BRAKING
68
- SUDDEN_BRAKING
69
- LEFT_TURN
70
- RIGHT_TURN
64
+ ### 行车状态
65
+
66
+ `CarRunningStatus` 一共有八种取值。状态字符串是稳定协议,可以用于判断逻辑或作为素材配置的键:
67
+
68
+ | 常量值 | `CAR_RUNNING_LABELS` 中文名称 | 默认优先级 |
69
+ | -------------------- | ----------------------------- | ---------: |
70
+ | `STOPPED` | 静止 | 0 |
71
+ | `STEADY_DRIVING` | 匀速行驶 | 0 |
72
+ | `ACCELERATION` | 加速 | 1 |
73
+ | `BRAKING` | 刹车 | 2 |
74
+ | `LEFT_TURN` | 左转 | 3 |
75
+ | `RIGHT_TURN` | 右转 | 3 |
76
+ | `RAPID_ACCELERATION` | 急加速 | 4 |
77
+ | `SUDDEN_BRAKING` | 急刹车 | 5 |
78
+
79
+ 相关导出:
80
+
81
+ - `CAR_RUNNING_STATUSES`:按协议顺序保存全部八种状态,适合遍历。
82
+ - `CAR_RUNNING_LABELS`:状态到中文名称的映射。
83
+ - `CAR_RUNNING_STATUS_PRIORITY`:状态优先级。高优先级动作可以抢占正在展示的低优先级状态。
84
+
85
+ ```ts
86
+ import { CAR_RUNNING_LABELS, CAR_RUNNING_STATUSES, type CarRunningStatus } from '@ziztechnology/dial-library';
87
+
88
+ const renderStatus = (status: CarRunningStatus) => {
89
+ console.log(status, CAR_RUNNING_LABELS[status]);
90
+ };
91
+
92
+ CAR_RUNNING_STATUSES.forEach(renderStatus);
71
93
  ```
72
94
 
73
- `OFFICIAL_DRIVING_EXPRESSION_FALLBACKS` 提供官方母版的固定默认值:`😐 😊 😄 😲 😣 😱 🤨 🤨`。SDK 不会自动使用这张表;第三方未传 `fallbacks` 时,素材缺失或加载失败只会上报错误。
95
+ ### 电池状态
96
+
97
+ 统一传感器信息中的电池字段使用以下字符串联合类型:
74
98
 
75
- ## 配置解析
99
+ - `BatteryStatus`:`CHARGING`、`DISCHARGING`、`FULL`、`NOT_CHARGING`、`UNKNOWN`。
100
+ - `BatteryPlugged`:`AC`、`USB`、`WIRELESS`、`DOCK`、`NONE`。
101
+ - `BatteryHealth`:`GOOD`、`OVERHEAT`、`DEAD`、`OVER_VOLTAGE`、`UNSPECIFIED_FAILURE`、`COLD`、`UNKNOWN`。
102
+ - `BATTERY_HEALTH_LABELS`:`BatteryHealth` 到中文名称的映射。
76
103
 
77
- `drivingExpressions` 使用 schema v1,八个状态必须完整且不能包含未知状态。`parseDrivingExpressionsConfig()` 同时接受 JSON 对象和 JSON 字符串,并返回完整、结构化的配置:
104
+ ## 统一的传感器
105
+
106
+ `unifiedSensorInfo()` 一次返回设备当前的统一快照。快照包含陀螺仪、加速度、电池、温度、Wi-Fi、方向和磁场等信息:
78
107
 
79
108
  ```ts
80
- import { parseDrivingExpressionsConfig } from '@ziztechnology/dial-library';
109
+ import { unifiedSensorInfo } from '@ziztechnology/dial-library';
110
+
111
+ const info = await unifiedSensorInfo();
81
112
 
82
- const config = parseDrivingExpressionsConfig(customField.value);
113
+ if (info.temperature.available) {
114
+ console.log(`电池温度:${info.temperature.value}${info.temperature.unit}`);
115
+ } else {
116
+ console.log(`温度不可用:${info.temperature.unavailableReason}`);
117
+ }
118
+
119
+ if (info.linearAcceleration.available) {
120
+ const { x, y, z } = info.linearAcceleration.value;
121
+ console.log('线性加速度:', { x, y, z }, info.linearAcceleration.unit);
122
+ }
83
123
  ```
84
124
 
85
- 支持的素材为:
125
+ 每个指标都使用 `available` 作为区分字段:
86
126
 
87
127
  ```ts
88
- type StructuredDrivingExpressionMedia =
89
- | { kind: 'emoji'; text: string }
90
- | { kind: 'image'; mediaAssetId?: number; url: string; mimeType?: string }
91
- | { kind: 'video'; mediaAssetId?: number; url: string; coverUrl?: string; mimeType?: string }
92
- | { kind: 'live_photo'; mediaAssetId?: number; coverUrl: string; motionUrl: string }
93
- | { kind: 'tgs'; mediaAssetId?: number; url: string; coverUrl?: string };
128
+ type SensorMetric<T> =
129
+ | {
130
+ available: true;
131
+ value: T;
132
+ unit: string | null;
133
+ source: string;
134
+ sampledAtMs: number;
135
+ unavailableReason: null;
136
+ }
137
+ | {
138
+ available: false;
139
+ value: null;
140
+ unit: string | null;
141
+ source: string;
142
+ sampledAtMs: number | null;
143
+ unavailableReason: UnifiedSensorUnavailableReason | null;
144
+ };
94
145
  ```
95
146
 
96
- 解析器拒绝缺项、轮播字段、非法 kind/字段组合、非单个可见 grapheme 的 Emoji,以及 `http:`、`data:`、`blob:`、localhost、内网/保留 IP、内部单标签 host、带 COS/GCS/OSS 等常见临时签名参数的非长期公网 HTTPS URL。普通公网 HTTPS 与无签名业务 query 仍可使用。后端仍必须按 `mediaAssetId + userID` 重建可信 URL;SDK 静态校验不能替代服务端所有权、DNS 解析和 host allowlist 校验。
147
+ 先判断 `available`,TypeScript 才会把 `value` 收窄到真实类型。不要用 `value === null` 猜测设备是否支持某项指标。
148
+
149
+ 常见的 `unavailableReason` 有:
150
+
151
+ | 原因 | 含义 |
152
+ | ---------------------- | -------------------------- |
153
+ | `SENSOR_MISSING` | 设备没有对应传感器 |
154
+ | `NO_SAMPLE` | 传感器存在,但暂时没有样本 |
155
+ | `PLATFORM_UNAVAILABLE` | 当前平台不提供该能力 |
156
+ | `PERMISSION_DENIED` | 没有读取权限 |
157
+ | `NOT_CONNECTED` | 对应设备或网络未连接 |
97
158
 
98
- `readDrivingExpressionsConfig()` 优先读取 Runtime 注入的 `window.__TOOOONY_DRIVING_EXPRESSIONS__`,再兼容旧 `__TOOOONY_FACE_CONFIG__` 路径;非法或不支持的配置返回 `null`,不会隐式填充官方默认值。
159
+ `capturedAtMs` 是快照时间,具体指标的 `sampledAtMs` 是采样时间。行车状态识别默认拒绝未来样本和超过 1 秒的旧样本。
99
160
 
100
- 首版 SDK 接受过裸图片 URL。为兼容旧表盘,`resolveDrivingExpression()` 及 `DrivingExpressionsConfig` 仍保留旧形状;只有显式设置 `{ allowLegacyImageUrls: true }` 时,schema 解析器才会把裸 HTTPS URL 归一化为 `kind: 'image'`。
161
+ ### 可用字段
101
162
 
102
- ## 纯函数分类
163
+ | 字段 | 值 | 单位 |
164
+ | -------------------- | -------------------------------------- | -------- |
165
+ | `gyroscope` | `{ x, y, z }` | `rad/s` |
166
+ | `accelerometer` | `{ x, y, z }` | `m/s²` |
167
+ | `linearAcceleration` | `{ x, y, z }` | `m/s²` |
168
+ | `gravity` | `{ x, y, z }` | `m/s²` |
169
+ | `magneticField` | `{ x, y, z }` | `µT` |
170
+ | `orientation` | `{ azimuth, pitch, roll }` | `degree` |
171
+ | `motionIntensity` | 数值;旧 Runtime 中可能不存在 | `m/s²` |
172
+ | `temperature` | 数值 | `°C` |
173
+ | `wifiSsid` | 字符串 | 无 |
174
+ | `batteryCapacity` | `{ levelPercent, chargeCounterUah }` | 无 |
175
+ | `battery` | 电池是否存在、充电状态、健康度和电压等 | 无 |
176
+
177
+ 如果只需要根据单次快照做无状态判断,可以使用纯函数 `classifyDrivingSnapshot()`:
103
178
 
104
179
  ```ts
105
180
  import { classifyDrivingSnapshot, unifiedSensorInfo } from '@ziztechnology/dial-library';
106
181
 
107
182
  const status = classifyDrivingSnapshot(await unifiedSensorInfo());
183
+
184
+ if (status === null) {
185
+ console.log('传感器数据不可用、非法或已经过期');
186
+ } else {
187
+ console.log('当前瞬时状态:', status);
188
+ }
108
189
  ```
109
190
 
110
- `classifyDrivingSnapshot(info, tuning?)` 是无状态纯函数。原 API `checkRunningStatus()` 保留为同一函数的兼容别名。数据不可用、非法或超过 1 秒未更新时返回 `null`。
191
+ `checkRunningStatus()` 是同一函数的兼容别名。单次分类不会去抖,也不会保存上一次状态;它使用 Runtime `motionIntensity >= 0.4 m/s²` 判断瞬时行驶。面向 UI 时,通常应该使用下一节带 accelerometer 滑窗的状态控制器。
111
192
 
112
- 固定安装方向:
193
+ 设备的固定安装方向为:
113
194
 
114
195
  ```text
115
196
  +X = 车身右侧
@@ -117,68 +198,235 @@ const status = classifyDrivingSnapshot(await unifiedSensorInfo());
117
198
  -Z = 车辆前进方向
118
199
  ```
119
200
 
120
- `longitudinal = -linearAcceleration.z`、`lateral = linearAcceleration.x`、`yaw = gyroscope.y`。左转为正 yaw 或负 lateral,右转符号相反。
201
+ 因此纵向加速度取 `-linearAcceleration.z`,横向加速度取 `linearAcceleration.x`,偏航角速度取 `gyroscope.y`。
202
+
203
+ ## 行车状态基本使用
121
204
 
122
- 默认阈值集中在 `DEFAULT_DRIVING_STATUS_TUNING`,版本为 `driving-status-v1`。仅靠惯性传感器无法严格区分静止和理想匀速直线运动;v1 使用 `motionIntensity` 和陀螺仪扰动近似判断,必须通过固定安装真机路测继续调参。
205
+ ### 订阅稳定状态
123
206
 
124
- ## 状态控制器
207
+ `createDrivingStatusController()` 负责持续读取传感器,并处理瞬时噪声。推荐让页面只响应它发出的 `status_changed`:
125
208
 
126
209
  ```ts
210
+ import { CAR_RUNNING_LABELS, createDrivingStatusController } from '@ziztechnology/dial-library';
211
+
127
212
  const controller = createDrivingStatusController({
213
+ initialStatus: 'STOPPED',
128
214
  pollIntervalMs: 200,
215
+ });
216
+
217
+ const unsubscribe = controller.subscribe((event) => {
218
+ console.log(`${CAR_RUNNING_LABELS[event.previousStatus]} -> ${CAR_RUNNING_LABELS[event.status]}`, event.reason);
219
+ });
220
+
221
+ controller.start();
222
+
223
+ // 临时停止;之后可以再次 start()。
224
+ controller.stop();
225
+
226
+ // 永久释放;destroy() 后不能重新启动。
227
+ unsubscribe();
228
+ controller.destroy();
229
+ ```
230
+
231
+ 控制器默认每 200ms 读取一次,并保证上一次读取结束后才开始下一次。它会对普通动作平滑、要求候选状态连续出现、设置状态最短展示时间,并允许急刹车等高优先级状态抢占。候选状态不会触发 UI,只有提交后的稳定状态才会通知订阅者。
232
+
233
+ 静止和匀速使用 accelerometer 的 1 秒三轴波动窗口判断。窗口按 `sampledAtMs` 去重,至少积累 5 个有效样本;总体标准差大于 `0.4 m/s²` 时进入 `STEADY_DRIVING`,小于 `0.25 m/s²` 时进入 `STOPPED`,中间区间保持原基础状态。滑窗预热不影响加速、刹车或转弯识别。accelerometer 不可用或过期时,整次行车快照进入不可用流程。
234
+
235
+ ### 读取当前状态
236
+
237
+ 订阅只通知后续变化。初次渲染或调试时,可以读取快照:
238
+
239
+ ```ts
240
+ const snapshot = controller.getSnapshot();
241
+
242
+ console.log(snapshot.status); // 已提交状态
243
+ console.log(snapshot.candidate); // 正在确认的候选状态,可能为 null
244
+ console.log(snapshot.candidateConfirmationCount);
245
+ console.log(snapshot.lifecycle); // stopped、running 或 destroyed
246
+ console.log(snapshot.tuningVersion); // driving-status-v1
247
+ ```
248
+
249
+ ### 调整识别参数
250
+
251
+ 默认参数集中在 `DEFAULT_DRIVING_STATUS_TUNING`。只覆盖需要调整的字段即可:
252
+
253
+ ```ts
254
+ const controller = createDrivingStatusController({
255
+ tuning: {
256
+ turnYawThreshold: 0.6,
257
+ motionWindowMs: 1_200,
258
+ motionMinimumSamples: 6,
259
+ drivingMotionThreshold: 0.45,
260
+ stoppedMotionThreshold: 0.28,
261
+ confirmationSamples: {
262
+ LEFT_TURN: 3,
263
+ RIGHT_TURN: 3,
264
+ },
265
+ },
266
+ });
267
+ ```
268
+
269
+ `drivingMotionThreshold` 必须大于 `stoppedMotionThreshold`,形成进入行驶和退出行驶的迟滞区间。`motionMinimumSamples` 必须是正整数,其余窗口和阈值必须是正有限数值。
270
+
271
+ 固定安装的惯性传感器无法严格区分“完全静止”和“理想匀速直线运动”。默认算法使用三轴加速度波动和陀螺仪扰动近似判断,修改阈值后应在真机上路测。
272
+
273
+ ### 驱动行车表情
274
+
275
+ Runtime 可以为八种状态注入图片、视频、Live Photo、TGS 或 Emoji 配置。`readDrivingExpressionsConfig()` 会读取并校验这份配置,播放器负责切换素材:
276
+
277
+ ```html
278
+ <div id="expression"></div>
279
+ ```
280
+
281
+ ```ts
282
+ import {
283
+ createDrivingExpressionPlayer,
284
+ createDrivingStatusController,
285
+ readDrivingExpressionsConfig,
286
+ } from '@ziztechnology/dial-library';
287
+
288
+ const container = document.querySelector<HTMLElement>('#expression');
289
+ if (!container) throw new Error('找不到 #expression');
290
+
291
+ const config = readDrivingExpressionsConfig();
292
+ if (!config) throw new Error('Runtime 未提供有效的行车表情配置');
293
+
294
+ const player = createDrivingExpressionPlayer(container, { config });
295
+ const controller = createDrivingStatusController();
296
+
297
+ await player.show(controller.getSnapshot().status);
298
+
299
+ const unsubscribe = controller.subscribe((event) => {
300
+ void player.show(event.status);
301
+ });
302
+
303
+ controller.start();
304
+
305
+ const destroy = () => {
306
+ unsubscribe();
307
+ controller.destroy();
308
+ player.destroy();
309
+ };
310
+ ```
311
+
312
+ 播放器同一时间只保留一个活动动态素材。它会复用视频元素,并在状态离开时停止播放;页面暂停或隐藏时,播放器与控制器都会自动暂停。
313
+
314
+ ## 行车状态的回退
315
+
316
+ 行车状态有两类彼此独立的回退:传感器不可用时的**状态回退**,以及表情素材失败时的**媒体回退**。
317
+
318
+ ### 传感器不可用时回退到静止
319
+
320
+ 控制器遇到一次读取失败、非法快照或过期样本时,会暂时保持最后一个已提交状态,避免页面闪烁。连续不可用达到 3 秒后,控制器才提交 `STOPPED`:
321
+
322
+ ```ts
323
+ const controller = createDrivingStatusController({
129
324
  unavailableFallbackMs: 3_000,
130
- initialStatus: 'STOPPED',
131
- tuning: { turnYawThreshold: 0.32 },
132
325
  onDiagnostic(event) {
133
- // 诊断事件不包含完整媒体 URL或逐帧传感器数据。
326
+ if (event.type === 'fallback_to_stopped') {
327
+ console.warn(`传感器已连续不可用 ${event.unavailableForMs}ms`);
328
+ }
134
329
  },
135
330
  });
331
+
332
+ controller.subscribe((event) => {
333
+ if (event.reason === 'unavailable_fallback') {
334
+ console.log('已回退到 STOPPED');
335
+ }
336
+ });
136
337
  ```
137
338
 
138
- 控制器统一实现:
339
+ 传感器恢复后,控制器会清除不可用状态并重新确认候选,不会立即把单个样本提交给 UI。`unavailableFallbackMs` 必须是大于 0 的有限数值。
340
+
341
+ ### 素材失败时使用显式回退
139
342
 
140
- - 默认 200ms 轮询,读取期间不会发起重叠请求;`stop()` 会通过 `AbortSignal` 取消/忽略未完成读取。
141
- - 普通动作短窗口平滑,急动作检查未平滑峰值。
142
- - 急刹车 > 急加速 > 转弯 > 刹车 > 加速 > 静止/匀速的抢占优先级。
143
- - 急动作 1 样本、转弯/普通动作 2 样本、匀速 3 样本、静止 5 样本的连续确认。
144
- - 动作退出阈值为入口阈值的 `0.75`,并对静止/匀速使用反向滞回区间。
145
- - 每个已提交状态的最短展示时间;更高优先级候选可抢占,锁期间继续采样。
146
- - 单次/短暂不可用保持最后状态;连续不可用达到 3 秒强制提交 `STOPPED`;恢复后重新确认候选。
343
+ SDK 提供 `OFFICIAL_DRIVING_EXPRESSION_FALLBACKS`,对应的默认 Emoji 为:
147
344
 
148
- `getSnapshot()` 返回生命周期、已提交状态、候选计数、不可用起点和 tuning 版本。`subscribe()` 只发送稳定的 `status_changed` 事件;更细的 `sample_available`、`candidate_changed`、`status_held_by_min_duration`、`fallback_to_stopped` 等通过可选 `onDiagnostic` 获取。
345
+ | 状态 | 回退内容 |
346
+ | -------------------- | -------- |
347
+ | `STOPPED` | 😐 |
348
+ | `STEADY_DRIVING` | 😊 |
349
+ | `ACCELERATION` | 😄 |
350
+ | `RAPID_ACCELERATION` | 😲 |
351
+ | `BRAKING` | 😣 |
352
+ | `SUDDEN_BRAKING` | 😱 |
353
+ | `LEFT_TURN` | 🤨 |
354
+ | `RIGHT_TURN` | 🤨 |
149
355
 
150
- 测试和离线回放可注入 `sensorProvider` 与 `clock`。provider 可接收 `AbortSignal`:
356
+ 官方母版必须显式把它传给播放器:
151
357
 
152
358
  ```ts
153
- const controller = createDrivingStatusController({
154
- sensorProvider: (signal) => replay.next(signal),
155
- clock: fakeClock,
359
+ import { OFFICIAL_DRIVING_EXPRESSION_FALLBACKS, createDrivingExpressionPlayer } from '@ziztechnology/dial-library';
360
+
361
+ const player = createDrivingExpressionPlayer(container, {
362
+ config,
363
+ fallbacks: OFFICIAL_DRIVING_EXPRESSION_FALLBACKS,
364
+ onDiagnostic(event) {
365
+ if (event.type === 'media_fallback_applied') {
366
+ console.warn(`${event.status} 已使用 ${event.kind} 回退素材`);
367
+ }
368
+ },
156
369
  });
157
370
  ```
158
371
 
159
- ## 媒体播放器
372
+ 回退是**选择加入**的:第三方不传 `fallbacks`,SDK 就不会自动使用官方 Emoji。素材缺失、URL 非法、网络失败、超时或解码失败时,播放器会进入错误状态并发送 `media_load_failed` 诊断事件。如果你对你开发的表盘的样式一致性比较高,你可以编写自己的回退。
160
373
 
161
- `createDrivingExpressionPlayer(container, options)` 同时只保留一个活动动态播放器:
374
+ 也可以只为部分状态提供自己的回退:
162
375
 
163
- - Emoji 直接渲染本地文本。
164
- - 图片/GIF 使用原生 `<img>`,保留 GIF 动画。
165
- - 视频复用一个 `<video muted loop playsinline>`;离开状态时停止并清空,重新进入从 `0` 开始。
166
- - Live Photo 先显示 cover,motion 就绪后使用静音循环视频;motion 失败时应用该状态 fallback,不会永久停在 cover。
167
- - TGS 以无凭据 CORS fetch 读取,限制压缩/解压大小、图层数、帧率和帧数;使用 `fflate` 解压并通过 `lottie-web` 循环渲染。
168
- - TGS 仅接受约定的 TGS、gzip JSON Content-Type;HTML 和无关 MIME 会立即回退。
169
- - 默认加载超时 5 秒。失败后应用显式 fallback,并按退避时间重试;状态离开、pause 或 destroy 会取消网络、预加载和动画。
170
- - 轻量预加载顺序为当前状态、`STOPPED`、其余状态;只缓存图片/cover 提示,不同时解码八个视频。
376
+ ```ts
377
+ const player = createDrivingExpressionPlayer(container, {
378
+ config,
379
+ fallbacks: {
380
+ STOPPED: { kind: 'emoji', text: '😐' },
381
+ STEADY_DRIVING: { kind: 'emoji', text: '😊' },
382
+ },
383
+ });
384
+ ```
171
385
 
172
- 控制器和播放器默认共享 `window.__watchFaceRuntimePause` / `window.__watchFaceRuntimeResume`、visibility/page 事件及设备传感器 release/resume 事件。SDK 会链接并在最后一个订阅者销毁时恢复页面原有 hook,避免两者相互覆盖。播放器可通过 `managePageLifecycle: false` 显式关闭自动生命周期管理。
386
+ 播放器默认等待素材 5 秒,失败后按 15 秒退避重试。可以通过 `loadTimeoutMs` `retryBackoffMs` 调整。Live Photo motion 加载失败时也会应用该状态的显式回退,而不会一直停留在 cover。
173
387
 
174
- 可使用 `onDiagnostic` 监听 `media_ready`、`media_load_failed` 和 `media_fallback_applied`。诊断会截断错误消息并移除 URL query,避免记录临时 token。
388
+ ### 配置本身无效时
175
389
 
176
- ## 开发验证
390
+ `readDrivingExpressionsConfig()` 优先读取 Runtime 注入的 `__TOOOONY_DRIVING_EXPRESSIONS__`,并兼容旧的 `__TOOOONY_FACE_CONFIG__` 路径。配置不存在或无效时返回 `null`,不会替你补齐默认值:
177
391
 
178
- ```bash
179
- pnpm test
180
- pnpm exec tsc --noEmit
181
- pnpm run fmt:check
392
+ ```ts
393
+ const config = readDrivingExpressionsConfig();
394
+
395
+ if (!config) {
396
+ // 由表盘决定:显示静态占位、隐藏组件或终止初始化。
397
+ }
398
+ ```
399
+
400
+ schema v1 要求 `states` 恰好包含全部八种状态。支持的单个状态素材为:
401
+
402
+ ```ts
403
+ type StructuredDrivingExpressionMedia =
404
+ | { kind: 'emoji'; text: string }
405
+ | { kind: 'image'; mediaAssetId?: number; url: string; mimeType?: string }
406
+ | {
407
+ kind: 'video';
408
+ mediaAssetId?: number;
409
+ url: string;
410
+ coverUrl?: string;
411
+ mimeType?: string;
412
+ }
413
+ | {
414
+ kind: 'live_photo';
415
+ mediaAssetId?: number;
416
+ coverUrl: string;
417
+ motionUrl: string;
418
+ }
419
+ | { kind: 'tgs'; mediaAssetId?: number; url: string; coverUrl?: string };
182
420
  ```
183
421
 
184
- 测试使用 fake clock、fake sensor provider 和轻量 DOM,覆盖八状态边界、优先级、候选确认、滞回、最短展示、2.9/3 秒不可用边界、生命周期幂等、所有配置 kind、官方/第三方 fallback 差异,以及图片/视频/Live Photo/TGS 播放行为。
422
+ 媒体 URL 必须是长期有效的公网 HTTPS 地址。解析器会拒绝 `http:`、`data:`、`blob:`、localhost、内网或保留地址,以及带常见临时签名参数的 URL。服务端仍应根据 `mediaAssetId` 和用户身份重建可信 URL;前端校验不能替代服务端所有权和域名校验。
423
+
424
+ 旧版表盘曾使用裸图片 URL。只有迁移旧配置时,才应显式开启兼容:
425
+
426
+ ```ts
427
+ import { parseDrivingExpressionsConfig } from '@ziztechnology/dial-library';
428
+
429
+ const config = parseDrivingExpressionsConfig(rawConfig, {
430
+ allowLegacyImageUrls: true,
431
+ });
432
+ ```
package/dist/index.d.mts CHANGED
@@ -73,6 +73,9 @@ interface DrivingStatusTuning {
73
73
  longitudinalThreshold: number;
74
74
  turnYawThreshold: number;
75
75
  turnLateralThreshold: number;
76
+ motionWindowMs: number;
77
+ motionMinimumSamples: number;
78
+ drivingMotionThreshold: number;
76
79
  stoppedMotionThreshold: number;
77
80
  stoppedGyroThreshold: number;
78
81
  hysteresisRatio: number;
package/dist/index.mjs CHANGED
@@ -5,10 +5,13 @@ const DEFAULT_DRIVING_STATUS_TUNING = Object.freeze({
5
5
  maxSampleAgeMs: 1e3,
6
6
  rapidLongitudinalThreshold: 3,
7
7
  longitudinalThreshold: 1,
8
- turnYawThreshold: .3,
9
- turnLateralThreshold: 1.2,
10
- stoppedMotionThreshold: .15,
11
- stoppedGyroThreshold: .05,
8
+ turnYawThreshold: .5,
9
+ turnLateralThreshold: 2.5,
10
+ motionWindowMs: 1e3,
11
+ motionMinimumSamples: 5,
12
+ drivingMotionThreshold: .4,
13
+ stoppedMotionThreshold: .25,
14
+ stoppedGyroThreshold: .1,
12
15
  hysteresisRatio: .75,
13
16
  smoothingWindowSize: 3,
14
17
  confirmationSamples: Object.freeze({
@@ -56,8 +59,12 @@ const resolveDrivingStatusTuning = (overrides = {}) => {
56
59
  assertPositiveFinite("longitudinalThreshold", tuning.longitudinalThreshold);
57
60
  assertPositiveFinite("turnYawThreshold", tuning.turnYawThreshold);
58
61
  assertPositiveFinite("turnLateralThreshold", tuning.turnLateralThreshold);
62
+ assertPositiveFinite("motionWindowMs", tuning.motionWindowMs);
63
+ if (!Number.isInteger(tuning.motionMinimumSamples) || tuning.motionMinimumSamples < 1) throw new RangeError("motionMinimumSamples must be a positive integer");
64
+ assertPositiveFinite("drivingMotionThreshold", tuning.drivingMotionThreshold);
59
65
  assertPositiveFinite("stoppedMotionThreshold", tuning.stoppedMotionThreshold);
60
66
  assertPositiveFinite("stoppedGyroThreshold", tuning.stoppedGyroThreshold);
67
+ if (tuning.drivingMotionThreshold <= tuning.stoppedMotionThreshold) throw new RangeError("drivingMotionThreshold must be greater than stoppedMotionThreshold");
61
68
  if (!Number.isInteger(tuning.smoothingWindowSize) || tuning.smoothingWindowSize < 1) throw new RangeError("smoothingWindowSize must be a positive integer");
62
69
  if (tuning.rapidLongitudinalThreshold < tuning.longitudinalThreshold) throw new RangeError("rapidLongitudinalThreshold must be greater than or equal to longitudinalThreshold");
63
70
  if (!Number.isFinite(tuning.hysteresisRatio) || tuning.hysteresisRatio <= 0 || tuning.hysteresisRatio >= 1) throw new RangeError("hysteresisRatio must be greater than 0 and less than 1");
@@ -92,7 +99,7 @@ const classifyDrivingMetrics = (metrics, tuning = DEFAULT_DRIVING_STATUS_TUNING)
92
99
  if (Math.abs(lateral) >= tuning.turnLateralThreshold) return lateral < 0 ? "LEFT_TURN" : "RIGHT_TURN";
93
100
  if (longitudinal <= -tuning.longitudinalThreshold) return "BRAKING";
94
101
  if (longitudinal >= tuning.longitudinalThreshold) return "ACCELERATION";
95
- if (motionIntensity <= tuning.stoppedMotionThreshold && gyroMagnitude <= tuning.stoppedGyroThreshold) return "STOPPED";
102
+ if (motionIntensity < tuning.drivingMotionThreshold && gyroMagnitude <= tuning.stoppedGyroThreshold) return "STOPPED";
96
103
  return "STEADY_DRIVING";
97
104
  };
98
105
  const availableFreshVector = (metric, capturedAtMs, maxSampleAgeMs) => {
@@ -628,6 +635,7 @@ var DefaultDrivingStatusController = class {
628
635
  constructor(options) {
629
636
  this.listeners = /* @__PURE__ */ new Set();
630
637
  this.metricWindow = [];
638
+ this.motionWindow = [];
631
639
  this.candidate = null;
632
640
  this.candidateConfirmationCount = 0;
633
641
  this.lastAvailableAtMs = null;
@@ -662,6 +670,7 @@ var DefaultDrivingStatusController = class {
662
670
  const initialStatus = options.initialStatus ?? "STOPPED";
663
671
  if (!CAR_RUNNING_STATUSES.includes(initialStatus)) throw new TypeError(`Unsupported initialStatus: ${initialStatus}`);
664
672
  this.status = initialStatus;
673
+ this.baseStatus = isBaseStatus(initialStatus) ? initialStatus : "STOPPED";
665
674
  this.statusCommittedAtMs = this.clock.now();
666
675
  }
667
676
  start() {
@@ -688,6 +697,7 @@ var DefaultDrivingStatusController = class {
688
697
  this.destroyed = true;
689
698
  this.listeners.clear();
690
699
  this.metricWindow.length = 0;
700
+ this.motionWindow.length = 0;
691
701
  }
692
702
  getSnapshot() {
693
703
  return {
@@ -743,8 +753,9 @@ var DefaultDrivingStatusController = class {
743
753
  const payload = await this.sensorProvider(pendingRead.signal);
744
754
  if (!this.polling || generation !== this.generation || pendingRead.signal.aborted) return;
745
755
  const metrics = extractDrivingSnapshotMetrics(payload, this.tuning);
746
- if (metrics === null) this.processUnavailable("invalid_or_stale_snapshot");
747
- else this.processAvailable(metrics);
756
+ const accelerometerSample = extractFreshAccelerometerSample(payload, this.tuning.maxSampleAgeMs);
757
+ if (metrics === null || accelerometerSample === null) this.processUnavailable("invalid_or_stale_snapshot");
758
+ else this.processAvailable(metrics, accelerometerSample);
748
759
  } catch (error) {
749
760
  if (!this.polling || generation !== this.generation || pendingRead.signal.aborted || isAbortError$1(error)) return;
750
761
  this.processUnavailable("provider_error");
@@ -753,7 +764,7 @@ var DefaultDrivingStatusController = class {
753
764
  if (this.polling && generation === this.generation) this.schedulePoll(this.pollIntervalMs, generation);
754
765
  }
755
766
  }
756
- processAvailable(rawMetrics) {
767
+ processAvailable(rawMetrics, accelerometerSample) {
757
768
  const now = this.clock.now();
758
769
  this.lastAvailableAtMs = now;
759
770
  this.unavailableSinceMs = null;
@@ -767,14 +778,26 @@ var DefaultDrivingStatusController = class {
767
778
  this.metricWindow.push(rawMetrics);
768
779
  while (this.metricWindow.length > Math.floor(this.tuning.smoothingWindowSize)) this.metricWindow.shift();
769
780
  const smoothedMetrics = averageMetrics(this.metricWindow);
770
- const nextCandidate = classifyControllerCandidate(rawMetrics, smoothedMetrics, this.status, this.tuning);
781
+ const motionIntensity = this.recordMotionSample(accelerometerSample, rawMetrics.capturedAtMs);
782
+ const nextCandidate = classifyControllerCandidate(rawMetrics, smoothedMetrics, motionIntensity, this.status, this.baseStatus, this.tuning);
771
783
  this.processCandidate(nextCandidate, now);
772
784
  }
785
+ recordMotionSample(sample, capturedAtMs) {
786
+ if (!this.motionWindow.some((existing) => existing.sampledAtMs === sample.sampledAtMs)) {
787
+ this.motionWindow.push(sample);
788
+ this.motionWindow.sort((left, right) => left.sampledAtMs - right.sampledAtMs);
789
+ }
790
+ const cutoffMs = capturedAtMs - this.tuning.motionWindowMs;
791
+ while (this.motionWindow[0] && this.motionWindow[0].sampledAtMs <= cutoffMs) this.motionWindow.shift();
792
+ if (this.motionWindow.length < this.tuning.motionMinimumSamples) return null;
793
+ return combinedAxisStandardDeviation(this.motionWindow);
794
+ }
773
795
  processUnavailable(category) {
774
796
  const now = this.clock.now();
775
797
  if (this.unavailableSinceMs === null) this.unavailableSinceMs = now;
776
798
  const unavailableForMs = Math.max(0, now - this.unavailableSinceMs);
777
799
  this.metricWindow.length = 0;
800
+ this.motionWindow.length = 0;
778
801
  this.setCandidate(null, now);
779
802
  this.emitDiagnostic({
780
803
  type: "sample_unavailable",
@@ -828,6 +851,7 @@ var DefaultDrivingStatusController = class {
828
851
  if (nextStatus === this.status) return;
829
852
  const previousStatus = this.status;
830
853
  this.status = nextStatus;
854
+ if (isBaseStatus(nextStatus)) this.baseStatus = nextStatus;
831
855
  this.statusCommittedAtMs = now;
832
856
  this.setCandidate(null, now);
833
857
  this.heldCandidate = null;
@@ -867,6 +891,7 @@ var DefaultDrivingStatusController = class {
867
891
  }
868
892
  resetTransientRecognition() {
869
893
  this.metricWindow.length = 0;
894
+ this.motionWindow.length = 0;
870
895
  this.candidate = null;
871
896
  this.candidateConfirmationCount = 0;
872
897
  this.unavailableSinceMs = null;
@@ -885,26 +910,28 @@ var DefaultDrivingStatusController = class {
885
910
  this.unsubscribePageLifecycle = null;
886
911
  }
887
912
  };
888
- const classifyControllerCandidate = (raw, smoothed, current, tuning) => {
889
- const entry = classifyEntryCandidate(raw, smoothed, current, tuning);
890
- if (!retainsCurrentStatus(raw, smoothed, current, tuning)) return entry;
913
+ const classifyControllerCandidate = (raw, smoothed, motionIntensity, current, baseStatus, tuning) => {
914
+ const entry = classifyEntryCandidate(raw, smoothed, motionIntensity, baseStatus, tuning);
915
+ if (!retainsCurrentStatus(raw, smoothed, motionIntensity, current, tuning)) return entry;
891
916
  if (entry === current) return current;
892
917
  if (CAR_RUNNING_STATUS_PRIORITY[entry] > CAR_RUNNING_STATUS_PRIORITY[current]) return entry;
893
918
  if (isOppositeTurn(entry, current)) return entry;
894
919
  if ((entry === "STOPPED" || entry === "STEADY_DRIVING") && (current === "STOPPED" || current === "STEADY_DRIVING")) return entry;
895
920
  return current;
896
921
  };
897
- const classifyEntryCandidate = (raw, smoothed, current, tuning) => {
922
+ const classifyEntryCandidate = (raw, smoothed, motionIntensity, baseStatus, tuning) => {
898
923
  if (raw.longitudinal <= -tuning.rapidLongitudinalThreshold) return "SUDDEN_BRAKING";
899
924
  if (raw.longitudinal >= tuning.rapidLongitudinalThreshold) return "RAPID_ACCELERATION";
900
925
  if (Math.abs(smoothed.yaw) >= tuning.turnYawThreshold) return smoothed.yaw > 0 ? "LEFT_TURN" : "RIGHT_TURN";
901
926
  if (Math.abs(smoothed.lateral) >= tuning.turnLateralThreshold) return smoothed.lateral < 0 ? "LEFT_TURN" : "RIGHT_TURN";
902
927
  if (smoothed.longitudinal <= -tuning.longitudinalThreshold) return "BRAKING";
903
928
  if (smoothed.longitudinal >= tuning.longitudinalThreshold) return "ACCELERATION";
904
- const stoppedEntryRatio = current === "STOPPED" ? 1 : tuning.hysteresisRatio;
905
- return smoothed.motionIntensity <= tuning.stoppedMotionThreshold * stoppedEntryRatio && smoothed.gyroMagnitude <= tuning.stoppedGyroThreshold * stoppedEntryRatio ? "STOPPED" : "STEADY_DRIVING";
929
+ if (motionIntensity === null) return baseStatus;
930
+ if (motionIntensity > tuning.drivingMotionThreshold || smoothed.gyroMagnitude > tuning.stoppedGyroThreshold) return "STEADY_DRIVING";
931
+ if (motionIntensity < tuning.stoppedMotionThreshold) return "STOPPED";
932
+ return baseStatus;
906
933
  };
907
- const retainsCurrentStatus = (raw, smoothed, current, tuning) => {
934
+ const retainsCurrentStatus = (raw, smoothed, motionIntensity, current, tuning) => {
908
935
  const ratio = tuning.hysteresisRatio;
909
936
  switch (current) {
910
937
  case "SUDDEN_BRAKING": return raw.longitudinal <= -tuning.rapidLongitudinalThreshold * ratio;
@@ -913,8 +940,8 @@ const retainsCurrentStatus = (raw, smoothed, current, tuning) => {
913
940
  case "RIGHT_TURN": return smoothed.yaw <= -tuning.turnYawThreshold * ratio || smoothed.lateral >= tuning.turnLateralThreshold * ratio;
914
941
  case "BRAKING": return smoothed.longitudinal <= -tuning.longitudinalThreshold * ratio;
915
942
  case "ACCELERATION": return smoothed.longitudinal >= tuning.longitudinalThreshold * ratio;
916
- case "STOPPED": return smoothed.motionIntensity <= tuning.stoppedMotionThreshold && smoothed.gyroMagnitude <= tuning.stoppedGyroThreshold;
917
- case "STEADY_DRIVING": return false;
943
+ case "STOPPED": return (motionIntensity === null || motionIntensity <= tuning.drivingMotionThreshold) && smoothed.gyroMagnitude <= tuning.stoppedGyroThreshold;
944
+ case "STEADY_DRIVING": return motionIntensity === null || motionIntensity >= tuning.stoppedMotionThreshold || smoothed.gyroMagnitude > tuning.stoppedGyroThreshold;
918
945
  }
919
946
  };
920
947
  const averageMetrics = (metrics) => {
@@ -941,6 +968,45 @@ const averageMetrics = (metrics) => {
941
968
  result.gyroMagnitude /= count;
942
969
  return result;
943
970
  };
971
+ const extractFreshAccelerometerSample = (info, maxSampleAgeMs) => {
972
+ const metric = info?.accelerometer;
973
+ if (!metric?.available || !Number.isFinite(info.capturedAtMs) || !Number.isFinite(metric.sampledAtMs)) return null;
974
+ const ageMs = info.capturedAtMs - metric.sampledAtMs;
975
+ if (ageMs < 0 || ageMs > maxSampleAgeMs) return null;
976
+ const { x, y, z } = metric.value;
977
+ if (![
978
+ x,
979
+ y,
980
+ z
981
+ ].every(Number.isFinite)) return null;
982
+ return {
983
+ sampledAtMs: metric.sampledAtMs,
984
+ value: metric.value
985
+ };
986
+ };
987
+ const combinedAxisStandardDeviation = (samples) => {
988
+ const count = samples.length;
989
+ let meanX = 0;
990
+ let meanY = 0;
991
+ let meanZ = 0;
992
+ for (const sample of samples) {
993
+ meanX += sample.value.x;
994
+ meanY += sample.value.y;
995
+ meanZ += sample.value.z;
996
+ }
997
+ meanX /= count;
998
+ meanY /= count;
999
+ meanZ /= count;
1000
+ let varianceX = 0;
1001
+ let varianceY = 0;
1002
+ let varianceZ = 0;
1003
+ for (const sample of samples) {
1004
+ varianceX += (sample.value.x - meanX) ** 2;
1005
+ varianceY += (sample.value.y - meanY) ** 2;
1006
+ varianceZ += (sample.value.z - meanZ) ** 2;
1007
+ }
1008
+ return Math.sqrt((varianceX + varianceY + varianceZ) / count);
1009
+ };
944
1010
  const defaultSensorProvider = () => {
945
1011
  const runtimeGlobal = globalThis;
946
1012
  if (typeof runtimeGlobal.unifiedSensorInfo !== "function") return Promise.reject(/* @__PURE__ */ new Error("window.unifiedSensorInfo is unavailable"));
@@ -952,6 +1018,7 @@ const systemClock = {
952
1018
  clearTimeout: (handle) => globalThis.clearTimeout(handle)
953
1019
  };
954
1020
  const isOppositeTurn = (candidate, current) => candidate === "LEFT_TURN" && current === "RIGHT_TURN" || candidate === "RIGHT_TURN" && current === "LEFT_TURN";
1021
+ const isBaseStatus = (status) => status === "STOPPED" || status === "STEADY_DRIVING";
955
1022
  const isAbortError$1 = (error) => error instanceof Error && error.name === "AbortError";
956
1023
  const requirePositiveFinite = (name, value) => {
957
1024
  if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziztechnology/dial-library",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "description": "Tooony 表盘工具函数、方法",
6
6
  "files": [