@ziztechnology/dial-library 0.0.15 → 0.0.17

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,37 +1,57 @@
1
1
  # Toooony 表盘 SDK
2
2
 
3
- `@ziztechnology/dial-library` Toooony 表盘提供统一的设备信息和行车状态 API。你可以用它读取传感器,也可以直接订阅经过平滑、确认和回退处理后的行车状态。
3
+ `@ziztechnology/dial-library` Toooony 表盘的前端 SDK,主要做几件事:
4
+
5
+ 1. 读取设备的基础传感器和电池、网络信息;
6
+ 2. 把传感器数据分析成“静止、加速、转弯”等行车状态;
7
+ 3. 根据行车状态显示 Emoji、图片、视频、Live Photo 或 TGS 动画。
8
+ 4. 把网页播放器接入 Runtime 的车载媒体按键和状态同步能力。
9
+
10
+ SDK 是 ESM 包,自带 TypeScript 类型。
4
11
 
5
12
  > [!NOTE]
6
- > SDK 最低支持 Toooony Runtime `v1.5.22`。行车状态传感器 Bridge 仅向审核通过的 `PACKAGED_H5` 开放。第三方表盘应使用本 SDK,不要直接依赖 Runtime 注入到 `window` 上的内部方法。
13
+ > SDK 最低支持 Toooony Runtime `v1.5.22`。行车状态传感器 Bridge 只向审核通过的 `PACKAGED_H5` 开放。表盘应通过本 SDK 使用这些能力,不要直接调用 Runtime 注入到 `window` 上的内部方法。
7
14
 
8
15
  ## 安装
9
16
 
10
- 使用 npm、pnpm 或 yarn 安装:
11
-
12
17
  ```bash
13
18
  npm install @ziztechnology/dial-library
14
19
  ```
15
20
 
16
- SDK ESM 包,并自带 TypeScript 类型。
21
+ 也可以使用 pnpm yarn。
22
+
23
+ ## 功能模块
24
+
25
+ 文档按照 `src` 的公开导出顺序划分:
26
+
27
+ | 顺序 | 模块 | 解决什么问题 |
28
+ | ---- | -------------- | ---------------------------------------------------- |
29
+ | 1 | 基础传感器 | 一次读取陀螺仪、加速度、电池、温度、Wi-Fi 等设备信息 |
30
+ | 2 | 行车状态 | 持续分析传感器数据,得到八种稳定的行车状态 |
31
+ | 3 | 单次分析 | 查看某一帧传感器数据的车辆坐标和瞬时候选状态 |
32
+ | 4 | 行车表情配置 | 读取、解析和校验 Runtime 注入的素材配置 |
33
+ | 5 | 媒体 URL | 判断素材地址能否作为长期有效的公网 HTTPS URL 使用 |
34
+ | 6 | 行车表情播放器 | 根据状态显示 Emoji、图片、视频、Live Photo 或 TGS |
35
+ | 7 | 页面生命周期 | 页面隐藏或 Runtime 暂停时,自动暂停传感器和播放器 |
36
+ | 8 | 车载多媒体 | 接收车载播放按键,并把网页播放器状态同步给 Runtime |
17
37
 
18
38
  ## 快速开始
19
39
 
20
- 下面的例子会监听行车状态,并把当前状态显示在页面上:
40
+ 下面的例子只监听行车状态,并把中文状态显示在页面中:
21
41
 
22
42
  ```html
23
43
  <p id="driving-status">正在读取行车状态…</p>
24
44
  ```
25
45
 
26
46
  ```ts
27
- import { CAR_RUNNING_LABELS, createDrivingStatusController } from '@ziztechnology/dial-library';
47
+ import { CAR_RUNNING_LABELS, DrivingStatusController } from '@ziztechnology/dial-library';
28
48
 
29
- const statusElement = document.querySelector('#driving-status');
49
+ const statusElement = document.querySelector<HTMLElement>('#driving-status');
30
50
  if (!statusElement) throw new Error('找不到 #driving-status');
31
51
 
32
- const controller = createDrivingStatusController();
52
+ const controller = new DrivingStatusController();
33
53
 
34
- // 控制器默认从 STOPPED 开始。subscribe 只在稳定状态改变后触发。
54
+ // subscribe 只通知后续变化,所以先显示一次当前状态。
35
55
  statusElement.textContent = CAR_RUNNING_LABELS[controller.getSnapshot().status];
36
56
 
37
57
  const unsubscribe = controller.subscribe((event) => {
@@ -40,62 +60,20 @@ const unsubscribe = controller.subscribe((event) => {
40
60
 
41
61
  controller.start();
42
62
 
43
- // 页面不再使用控制器时释放资源。
63
+ // 页面销毁时释放资源。
44
64
  const destroy = () => {
45
65
  unsubscribe();
46
66
  controller.destroy();
47
67
  };
48
68
  ```
49
69
 
50
- `start()` 启动传感器轮询;`destroy()` 停止轮询、取消未完成的读取并移除监听器。`start()`、`stop()` `destroy()` 都可以重复调用。
51
-
52
- 控制器默认会跟随 Runtime 和页面生命周期自动暂停、恢复,因此大多数表盘不需要额外监听 `visibilitychange`。
70
+ `start()` 开始轮询传感器,`stop()` 临时停止,`destroy()` 永久释放资源。三者都可以安全地重复调用,但 `destroy()` 之后不能重新启动。
53
71
 
54
- ## 常量
72
+ ## 1. 基础传感器
55
73
 
56
- ### 行车状态
74
+ ### 读取设备快照
57
75
 
58
- `CarRunningStatus` 一共有八种取值。状态字符串是稳定协议,可以用于判断逻辑或作为素材配置的键:
59
-
60
- | 常量值 | `CAR_RUNNING_LABELS` 中文名称 | 默认优先级 |
61
- | -------------------- | ----------------------------- | ---------: |
62
- | `STOPPED` | 静止 | 0 |
63
- | `STEADY_DRIVING` | 匀速行驶 | 0 |
64
- | `ACCELERATION` | 加速 | 1 |
65
- | `BRAKING` | 刹车 | 2 |
66
- | `LEFT_TURN` | 左转 | 3 |
67
- | `RIGHT_TURN` | 右转 | 3 |
68
- | `RAPID_ACCELERATION` | 急加速 | 4 |
69
- | `SUDDEN_BRAKING` | 急刹车 | 5 |
70
-
71
- 相关导出:
72
-
73
- - `CAR_RUNNING_STATUSES`:按协议顺序保存全部八种状态,适合遍历。
74
- - `CAR_RUNNING_LABELS`:状态到中文名称的映射。
75
- - `CAR_RUNNING_STATUS_PRIORITY`:状态优先级。高优先级动作可以抢占正在展示的低优先级状态。
76
-
77
- ```ts
78
- import { CAR_RUNNING_LABELS, CAR_RUNNING_STATUSES, type CarRunningStatus } from '@ziztechnology/dial-library';
79
-
80
- const renderStatus = (status: CarRunningStatus) => {
81
- console.log(status, CAR_RUNNING_LABELS[status]);
82
- };
83
-
84
- CAR_RUNNING_STATUSES.forEach(renderStatus);
85
- ```
86
-
87
- ### 电池状态
88
-
89
- 统一传感器信息中的电池字段使用以下字符串联合类型:
90
-
91
- - `BatteryStatus`:`CHARGING`、`DISCHARGING`、`FULL`、`NOT_CHARGING`、`UNKNOWN`。
92
- - `BatteryPlugged`:`AC`、`USB`、`WIRELESS`、`DOCK`、`NONE`。
93
- - `BatteryHealth`:`GOOD`、`OVERHEAT`、`DEAD`、`OVER_VOLTAGE`、`UNSPECIFIED_FAILURE`、`COLD`、`UNKNOWN`。
94
- - `BATTERY_HEALTH_LABELS`:`BatteryHealth` 到中文名称的映射。
95
-
96
- ## 统一的传感器
97
-
98
- `unifiedSensorInfo()` 一次返回设备当前的统一快照。快照包含陀螺仪、加速度、电池、温度、Wi-Fi、方向和磁场等信息:
76
+ `unifiedSensorInfo()` 会一次返回当前设备信息:
99
77
 
100
78
  ```ts
101
79
  import { unifiedSensorInfo } from '@ziztechnology/dial-library';
@@ -114,99 +92,83 @@ if (info.linearAcceleration.available) {
114
92
  }
115
93
  ```
116
94
 
117
- 每个指标都使用 `available` 作为区分字段:
95
+ 每项数据都带有 `available`。先判断它,再读取 `value`:
118
96
 
119
97
  ```ts
120
- type SensorMetric<T> =
121
- | {
122
- available: true;
123
- value: T;
124
- unit: string | null;
125
- source: string;
126
- sampledAtMs: number;
127
- unavailableReason: null;
128
- }
129
- | {
130
- available: false;
131
- value: null;
132
- unit: string | null;
133
- source: string;
134
- sampledAtMs: number | null;
135
- unavailableReason: UnifiedSensorUnavailableReason | null;
136
- };
98
+ if (info.wifiSsid.available) {
99
+ console.log(info.wifiSsid.value);
100
+ }
137
101
  ```
138
102
 
139
- 先判断 `available`,TypeScript 才会把 `value` 收窄到真实类型。不要用 `value === null` 猜测设备是否支持某项指标。
103
+ 不要只用 `value === null` 判断设备是否支持某项能力,因为不可用的原因可能不同。
140
104
 
141
- 常见的 `unavailableReason` 有:
105
+ ### 可读取的字段
142
106
 
143
- | 原因 | 含义 |
144
- | ---------------------- | -------------------------- |
145
- | `SENSOR_MISSING` | 设备没有对应传感器 |
146
- | `NO_SAMPLE` | 传感器存在,但暂时没有样本 |
147
- | `PLATFORM_UNAVAILABLE` | 当前平台不提供该能力 |
148
- | `PERMISSION_DENIED` | 没有读取权限 |
149
- | `NOT_CONNECTED` | 对应设备或网络未连接 |
107
+ | 字段 | 内容 | 单位 |
108
+ | -------------------- | ------------------------------------ | -------- |
109
+ | `gyroscope` | 陀螺仪 `{ x, y, z }` | `rad/s` |
110
+ | `accelerometer` | 加速度计 `{ x, y, z }` | `m/s²` |
111
+ | `linearAcceleration` | 去除重力后的线性加速度 `{ x, y, z }` | `m/s²` |
112
+ | `gravity` | 重力向量 `{ x, y, z }` | `m/s²` |
113
+ | `magneticField` | 磁场 `{ x, y, z }` | `µT` |
114
+ | `orientation` | 方向 `{ azimuth, pitch, roll }` | `degree` |
115
+ | `motionIntensity` | 运动强度;旧 Runtime 可能没有此字段 | `m/s²` |
116
+ | `temperature` | 电池温度 | `°C` |
117
+ | `wifiSsid` | 当前 Wi-Fi 名称 | 无 |
118
+ | `batteryCapacity` | 电量百分比和电荷计数 | 无 |
119
+ | `battery` | 是否有电池、充电状态、健康度、电压等 | 无 |
150
120
 
151
- `capturedAtMs` 是快照时间,具体指标的 `sampledAtMs` 是采样时间。行车状态识别默认拒绝未来样本和超过 1 秒的旧样本。
121
+ `capturedAtMs` 是整个快照的生成时间,单项数据中的 `sampledAtMs` 是该传感器的采样时间。
152
122
 
153
- ### 可用字段
123
+ ### 数据为什么不可用
154
124
 
155
- | 字段 | 值 | 单位 |
156
- | -------------------- | -------------------------------------- | -------- |
157
- | `gyroscope` | `{ x, y, z }` | `rad/s` |
158
- | `accelerometer` | `{ x, y, z }` | `m/s²` |
159
- | `linearAcceleration` | `{ x, y, z }` | `m/s²` |
160
- | `gravity` | `{ x, y, z }` | `m/s²` |
161
- | `magneticField` | `{ x, y, z }` | `µT` |
162
- | `orientation` | `{ azimuth, pitch, roll }` | `degree` |
163
- | `motionIntensity` | 数值;旧 Runtime 中可能不存在 | `m/s²` |
164
- | `temperature` | 数值 | `°C` |
165
- | `wifiSsid` | 字符串 | 无 |
166
- | `batteryCapacity` | `{ levelPercent, chargeCounterUah }` | 无 |
167
- | `battery` | 电池是否存在、充电状态、健康度和电压等 | 无 |
125
+ `unavailableReason` 可能是:
168
126
 
169
- 如果需要检查单次快照的车辆坐标投影和瞬时候选,可以使用 `classifyDrivingSnapshot()`:
127
+ | 值 | 含义 |
128
+ | ---------------------- | ------------------------ |
129
+ | `SENSOR_MISSING` | 设备没有对应传感器 |
130
+ | `NO_SAMPLE` | 有传感器,但暂时没有样本 |
131
+ | `PLATFORM_UNAVAILABLE` | 当前平台不提供这项能力 |
132
+ | `PERMISSION_DENIED` | 没有读取权限 |
133
+ | `NOT_CONNECTED` | 设备或网络没有连接 |
170
134
 
171
- ```ts
172
- import { classifyDrivingSnapshot, unifiedSensorInfo } from '@ziztechnology/dial-library';
135
+ 电池相关类型包括 `BatteryStatus`、`BatteryPlugged` 和 `BatteryHealth`。`BATTERY_HEALTH_LABELS` 可以把电池健康状态转换成中文。
173
136
 
174
- const analysis = classifyDrivingSnapshot(await unifiedSensorInfo());
137
+ ## 2. 行车状态
175
138
 
176
- if (analysis === null) {
177
- console.log('传感器数据不可用、非法或已经过期');
178
- } else {
179
- console.log('纵向加速度:', analysis.metrics.longitudinal);
180
- console.log('偏航角速度:', analysis.metrics.yaw);
181
- console.log('瞬时候选:', analysis.candidateStatus);
182
- console.log('拒绝原因:', analysis.rejectionReason);
183
- }
184
- ```
185
-
186
- 该函数不返回最终的 `STOPPED` 或 `STEADY_DRIVING`,也不保存时间窗口。`candidateStatus` 只能用于诊断,面向 UI 必须使用下一节的状态控制器。
139
+ ### 八种状态
187
140
 
188
- 设备的正反面和车头朝向需要固定,但允许安装倾角变化。SDK 对重力向量做低通滤波,并动态建立车辆坐标系:
141
+ | 状态 | 中文 | 默认优先级 |
142
+ | -------------------- | -------- | ---------: |
143
+ | `STOPPED` | 静止 | 0 |
144
+ | `STEADY_DRIVING` | 匀速行驶 | 0 |
145
+ | `ACCELERATION` | 加速 | 1 |
146
+ | `BRAKING` | 刹车 | 2 |
147
+ | `LEFT_TURN` | 左转 | 3 |
148
+ | `RIGHT_TURN` | 右转 | 3 |
149
+ | `RAPID_ACCELERATION` | 急加速 | 4 |
150
+ | `SUDDEN_BRAKING` | 急刹车 | 5 |
189
151
 
190
- ```text
191
- right = 将设备 +X 投影到水平面
192
- up = 归一化后的 TYPE_GRAVITY
193
- forward = right × up
194
- ```
152
+ 相关导出:
195
153
 
196
- 线性加速度和陀螺仪随后投影到该坐标系。标准化后的正偏航表示左转,负偏航表示右转,因此同一安装朝向下的俯仰角偏差不会改变前后、左右和转向符号。
154
+ - `CAR_RUNNING_STATUSES`:全部状态,适合遍历;
155
+ - `CAR_RUNNING_LABELS`:状态对应的中文名称;
156
+ - `CAR_RUNNING_STATUS_PRIORITY`:状态优先级;
157
+ - `CarRunningStatus`:八种状态的 TypeScript 联合类型。
197
158
 
198
- ## 行车状态基本使用
159
+ 高优先级动作可以抢占正在显示的低优先级状态。
199
160
 
200
- ### 订阅稳定状态
161
+ ### 持续监听稳定状态
201
162
 
202
- `createDrivingStatusController()` 负责持续读取传感器,并处理瞬时噪声。推荐让页面只响应它发出的 `status_changed`:
163
+ 页面展示行车状态时,推荐使用 `new DrivingStatusController()`。它会持续读取传感器,并通过时间窗口过滤瞬时噪声:
203
164
 
204
165
  ```ts
205
- import { CAR_RUNNING_LABELS, createDrivingStatusController } from '@ziztechnology/dial-library';
166
+ import { CAR_RUNNING_LABELS, DrivingStatusController } from '@ziztechnology/dial-library';
206
167
 
207
- const controller = createDrivingStatusController({
168
+ const controller = new DrivingStatusController({
208
169
  initialStatus: 'STOPPED',
209
170
  pollIntervalMs: 200,
171
+ unavailableFallbackMs: 3_000,
210
172
  });
211
173
 
212
174
  const unsubscribe = controller.subscribe((event) => {
@@ -215,73 +177,206 @@ const unsubscribe = controller.subscribe((event) => {
215
177
 
216
178
  controller.start();
217
179
 
218
- // 临时停止;之后可以再次 start()。
180
+ // 临时暂停
219
181
  controller.stop();
220
182
 
221
- // 永久释放;destroy() 后不能重新启动。
183
+ // 需要时可以再次启动
184
+ controller.start();
185
+
186
+ // 永久释放
222
187
  unsubscribe();
223
188
  controller.destroy();
224
189
  ```
225
190
 
226
- 控制器默认以 200ms 为目标节拍读取,并保证同一时间只有一次 Bridge 读取。下一轮会扣除上一轮 Bridge 耗时;Bridge 本身超过 200ms 时则在返回后立即开始下一轮,避免再叠加固定等待。所有确认都使用传感器时间而不是轮询次数;相同 `sampledAtMs` 的重复数据不会推进窗口。正常 5Hz 采样仍需满足配置的 5/4/3 个样本,较慢的 Bridge 可以用至少 3 个独立观测覆盖同一确认时长;相邻有效观测间隔达到 1 秒时会重新建立动作窗口,不能跨越长数据空洞确认。
227
-
228
- 静止和匀速使用 accelerometer 的 1 秒三轴 MAD 稳健波动窗口;正常 5Hz 采样至少需要 5 个唯一样本,慢 Bridge 使用相同的时间覆盖规则。运动量不高于 `0.12 m/s²` 且陀螺仪足够稳定,连续 1.5 秒才进入 `STOPPED`;运动量达到 `0.18 m/s²` 或陀螺仪出现持续运动后,从当前运动样本开始连续 0.6 秒才进入 `STEADY_DRIVING`。中间区间保持原基础状态,停车时一次短促晃动不会立即切回行驶。
229
-
230
- 普通加速和刹车要求 0.8 秒内至少 4/5 的样本方向一致;急加速和急刹要求峰值、0.6 秒持续均值及方向一致性同时成立,并拒绝垂直能量占主导或短时间反号的颠簸。车辆刚出现方向稳定的物理偏航后,强转向路径要求 0.4 秒内至少 3 个唯一样本,窗口均值达到 `0.08 rad/s`;在默认轮询频率下通常会在 0.4–0.6 秒内触发。转向也可以由累计转角触发,并锁存到回正结束。算法不会预测车辆尚未产生转动时的驾驶意图。最终优先级为:急加减速、已锁存转向、普通加减速、基础状态。
191
+ 控制器默认每 `200ms` 读取一次,并保证同一时间只有一个读取任务。它不会因为一次抖动就改变状态,而是等多个独立样本在一段时间内保持一致后再通知页面。
231
192
 
232
- 缓慢转向还要求偏航旋转占陀螺仪能量的主要部分,或存在足够的横向加速度;垂直颠簸占主导且缺少偏航佐证时不会创建转向事件。重力样本短时缺失时,控制器会在 `maxSampleAgeMs` 内复用最后一个滤波后的车辆坐标系;缓存到期后按传感器不可用处理。单独调用 `classifyDrivingSnapshot()` 不会使用控制器缓存。
193
+ 如果传感器短暂失败,控制器会先保留最后状态。连续不可用达到 `unavailableFallbackMs` 后,才回退到 `STOPPED`。
233
194
 
234
- ### 读取当前状态
235
-
236
- 订阅只通知后续变化。初次渲染或调试时,可以读取快照:
195
+ ### 读取控制器当前情况
237
196
 
238
197
  ```ts
239
198
  const snapshot = controller.getSnapshot();
240
199
 
241
- console.log(snapshot.status); // 已提交状态
200
+ console.log(snapshot.status); // 当前已经确认的状态
242
201
  console.log(snapshot.candidate); // 正在确认的候选状态,可能为 null
243
- console.log(snapshot.candidateSinceMs); // 候选首次出现的传感器时间
244
- console.log(snapshot.activeTurn); // 已锁存的转向事件
245
202
  console.log(snapshot.baseStatus); // STOPPED 或 STEADY_DRIVING
203
+ console.log(snapshot.activeTurn); // 当前锁定的转向,可能为 null
246
204
  console.log(snapshot.lifecycle); // stopped、running 或 destroyed
247
- console.log(snapshot.tuningVersion);
248
205
  ```
249
206
 
250
207
  ### 调整识别参数
251
208
 
252
- 默认参数集中在 `DEFAULT_DRIVING_STATUS_TUNING`。只覆盖需要调整的字段即可:
209
+ 默认参数保存在 `DEFAULT_DRIVING_STATUS_TUNING`。通常不需要修改;真车测试发现识别过慢或过于敏感时,只覆盖需要调整的字段:
253
210
 
254
211
  ```ts
255
- const controller = createDrivingStatusController({
212
+ const controller = new DrivingStatusController({
256
213
  tuning: {
257
214
  turnYawThreshold: 0.08,
258
215
  turnConfirmationMs: 400,
259
- turnMinimumSamples: 3,
260
- motionWindowMs: 1_200,
261
- motionMinimumSamples: 6,
262
- drivingMotionThreshold: 0.2,
263
- stoppedMotionThreshold: 0.11,
264
- stoppedConfirmationMs: 1_500,
265
216
  longitudinalThreshold: 0.75,
266
- turnYawDominanceThreshold: 0.65,
267
- turnLateralEvidenceThreshold: 0.3,
217
+ stoppedConfirmationMs: 1_500,
218
+ },
219
+ });
220
+ ```
221
+
222
+ 可以先调用 `resolveDrivingStatusTuning(overrides)` 检查参数并得到完整配置。阈值和时间窗口必须是有效的正数,样本数必须是正整数。调整后应使用完整路测数据验证,不要只用一两帧数据判断效果。
223
+
224
+ ### 诊断识别过程
225
+
226
+ `onDiagnostic` 适合调试和真车调参:
227
+
228
+ ```ts
229
+ const controller = new DrivingStatusController({
230
+ onDiagnostic(event) {
231
+ if (event.type === 'detector_transition') {
232
+ console.log(event.detector, event.phase, event.status, event.statistics);
233
+ }
234
+
235
+ if (event.type === 'fallback_to_stopped') {
236
+ console.warn(`传感器已连续不可用 ${event.unavailableForMs}ms`);
237
+ }
238
+ },
239
+ });
240
+ ```
241
+
242
+ 诊断事件会告诉你样本是否有效、各检测器看到了什么证据、候选状态为什么进入或退出,以及何时因为传感器不可用而回退。
243
+
244
+ ## 3. 单次分析
245
+
246
+ `classifyDrivingSnapshot()` 分析一份传感器快照,适合开发和调试:
247
+
248
+ ```ts
249
+ import { classifyDrivingSnapshot, unifiedSensorInfo } from '@ziztechnology/dial-library';
250
+
251
+ const analysis = classifyDrivingSnapshot(await unifiedSensorInfo());
252
+
253
+ if (!analysis) {
254
+ console.log('必要的传感器数据缺失、非法或已经过期');
255
+ } else {
256
+ console.log('纵向加速度:', analysis.metrics.longitudinal);
257
+ console.log('横向加速度:', analysis.metrics.lateral);
258
+ console.log('偏航角速度:', analysis.metrics.yaw);
259
+ console.log('瞬时候选状态:', analysis.candidateStatus);
260
+ console.log('是否因颠簸被拒绝:', analysis.rejectionReason);
261
+ }
262
+ ```
263
+
264
+ 这个函数只看当前快照,不保存时间窗口,因此不会给出最终的 `STOPPED` 或 `STEADY_DRIVING`。页面 UI 应使用状态控制器,不能把 `candidateStatus` 当成最终结果。
265
+
266
+ 更底层的分析接口还有:
267
+
268
+ - `extractDrivingSnapshotMetrics()`:提取车辆坐标系下的纵向、横向、垂直和偏航数据;
269
+ - `buildVehicleFrame()`:根据重力方向建立车辆坐标系。
270
+
271
+ 这两个接口主要用于算法调试。设备安装时应固定正反面和车头方向;安装倾角可以变化,SDK 会根据重力方向进行校正。
272
+
273
+ ## 4. 行车表情配置
274
+
275
+ Runtime 会把配置注入到 `__TOOOONY_DRIVING_EXPRESSIONS__`。正常情况下直接读取:
276
+
277
+ ```ts
278
+ import { readDrivingExpressionsConfig } from '@ziztechnology/dial-library';
279
+
280
+ const config = readDrivingExpressionsConfig();
281
+
282
+ if (!config) {
283
+ console.log('Runtime 没有提供完整、有效的行车表情配置');
284
+ }
285
+ ```
286
+
287
+ 配置不存在或无效时返回 `null`,SDK 不会偷偷补上默认素材。
288
+
289
+ ### 配置格式
290
+
291
+ `schemaVersion: 1` 要求 `states` 恰好包含八种状态,每种状态都可以使用以下一种素材:
292
+
293
+ ```ts
294
+ type StructuredDrivingExpressionMedia =
295
+ | { kind: 'emoji'; text: string }
296
+ | { kind: 'image'; mediaAssetId?: number; url: string; mimeType?: string }
297
+ | { kind: 'video'; mediaAssetId?: number; url: string; coverUrl?: string; mimeType?: string }
298
+ | { kind: 'live_photo'; mediaAssetId?: number; coverUrl: string; motionUrl: string }
299
+ | { kind: 'tgs'; mediaAssetId?: number; url: string; coverUrl?: string };
300
+ ```
301
+
302
+ 最小示例:
303
+
304
+ ```ts
305
+ const rawConfig = {
306
+ schemaVersion: 1,
307
+ states: {
308
+ STOPPED: { kind: 'emoji', text: '😐' },
309
+ STEADY_DRIVING: { kind: 'emoji', text: '😊' },
310
+ ACCELERATION: { kind: 'emoji', text: '😄' },
311
+ RAPID_ACCELERATION: { kind: 'emoji', text: '😲' },
312
+ BRAKING: { kind: 'emoji', text: '😣' },
313
+ SUDDEN_BRAKING: { kind: 'emoji', text: '😱' },
314
+ LEFT_TURN: { kind: 'emoji', text: '🤨' },
315
+ RIGHT_TURN: { kind: 'emoji', text: '🤨' },
268
316
  },
317
+ };
318
+ ```
319
+
320
+ ### 解析配置
321
+
322
+ | API | 结果 |
323
+ | --------------------------------------- | ---------------------------------------------- |
324
+ | `parseDrivingExpressionsConfig(raw)` | 配置无效时抛出 `DrivingExpressionsConfigError` |
325
+ | `tryParseDrivingExpressionsConfig(raw)` | 配置无效时返回 `null` |
326
+ | `readDrivingExpressionsConfig()` | 读取 Runtime 注入值;不存在或无效时返回 `null` |
327
+ | `waitForDrivingExpressionsConfig()` | 等待晚到的注入;超时返回 `null` |
328
+
329
+ Runtime 通常会在页面脚本运行前完成注入。只有确实需要兼容晚注入时,才使用异步等待:
330
+
331
+ ```ts
332
+ import { waitForDrivingExpressionsConfig } from '@ziztechnology/dial-library';
333
+
334
+ const abortController = new AbortController();
335
+ window.addEventListener('pagehide', () => abortController.abort(), { once: true });
336
+
337
+ const config = await waitForDrivingExpressionsConfig({
338
+ timeoutMs: 3_000,
339
+ pollIntervalMs: 100,
340
+ signal: abortController.signal,
341
+ });
342
+ ```
343
+
344
+ 取消时 Promise 会以 `AbortError` 结束。
345
+
346
+ 旧版配置允许直接写图片 URL。只在迁移旧配置时开启兼容:
347
+
348
+ ```ts
349
+ const config = parseDrivingExpressionsConfig(rawConfig, {
350
+ allowLegacyImageUrls: true,
269
351
  });
270
352
  ```
271
353
 
272
- `drivingMotionThreshold` 必须大于 `stoppedMotionThreshold`,进入阈值也必须大于相应退出阈值。样本数必须是正整数,方向一致率必须大于 `0.5` 且不大于 `1`,其余窗口和阈值必须是正有限数值。
354
+ `resolveDrivingExpression()` 会在某个旧版状态缺失时使用 `STEADY_DRIVING` 素材;`resolveDrivingExpressionStrict()` 不会自动这样做,缺失且没有显式回退时会抛出错误。播放器使用严格规则。
355
+
356
+ ## 5. 媒体 URL
357
+
358
+ 图片、视频、Live Photo 和 TGS 使用的地址必须是长期有效的公网 HTTPS URL。
273
359
 
274
- ### 识别诊断
360
+ ```ts
361
+ import { isPublicDrivingMediaUrl } from '@ziztechnology/dial-library';
362
+
363
+ console.log(isPublicDrivingMediaUrl('https://cdn.example.com/expression.png')); // true
364
+ console.log(isPublicDrivingMediaUrl('http://cdn.example.com/expression.png')); // false
365
+ console.log(isPublicDrivingMediaUrl('https://localhost/expression.png')); // false
366
+ ```
275
367
 
276
- `onDiagnostic` 的 `sample_available` 事件包含 `frameSource`(`live` 或 `cached`)和 `frameAgeMs`。`detector_evidence` 会报告基础状态 MAD、普通/急加减速的均值、峰值、方向一致率和 RMS,以及转向的累计角度、偏航主导率和横向/垂直 RMS。
368
+ SDK 会拒绝:
277
369
 
278
- 检测器状态变化时还会发送 `detector_transition`,其 `phase` `candidate_entered`、`candidate_rejected`、`candidate_confirmed` 或 `candidate_exited`。该事件携带当时的完整 `statistics`,可直接用于真车回放调参;同一状态不会逐轮询重复发送。
370
+ - `http:`、`data:`、`blob:` 等非 HTTPS 地址;
371
+ - 带用户名或密码的地址;
372
+ - localhost、内网 IP 和保留地址;
373
+ - 带常见临时签名参数的地址。
279
374
 
280
- 惯性传感器无法严格区分“完全静止”和“没有任何振动的理想匀速直线运动”。默认算法使用稳健振动统计、陀螺仪扰动和 1.5 秒确认近似判断,修改阈值后应使用完整路测过程回放验证。
375
+ 前端 URL 校验只能防止明显错误。服务端仍应根据 `mediaAssetId` 和用户身份检查素材所有权,并重新生成可信地址。
281
376
 
282
- ### 驱动行车表情
377
+ ## 6. 行车表情播放器
283
378
 
284
- Runtime 可以为八种状态注入图片、视频、Live Photo、TGS 或 Emoji 配置。`readDrivingExpressionsConfig()` 会读取并校验这份配置,播放器负责切换素材:
379
+ ### 把状态和素材连起来
285
380
 
286
381
  ```html
287
382
  <div id="expression"></div>
@@ -290,7 +385,7 @@ Runtime 可以为八种状态注入图片、视频、Live Photo、TGS 或 Emoji
290
385
  ```ts
291
386
  import {
292
387
  createDrivingExpressionPlayer,
293
- createDrivingStatusController,
388
+ DrivingStatusController,
294
389
  readDrivingExpressionsConfig,
295
390
  } from '@ziztechnology/dial-library';
296
391
 
@@ -301,7 +396,7 @@ const config = readDrivingExpressionsConfig();
301
396
  if (!config) throw new Error('Runtime 未提供有效的行车表情配置');
302
397
 
303
398
  const player = createDrivingExpressionPlayer(container, { config });
304
- const controller = createDrivingStatusController();
399
+ const controller = new DrivingStatusController();
305
400
 
306
401
  await player.show(controller.getSnapshot().status);
307
402
 
@@ -318,53 +413,13 @@ const destroy = () => {
318
413
  };
319
414
  ```
320
415
 
321
- 播放器同一时间只保留一个活动动态素材。它会复用视频元素,并在状态离开时停止播放;页面暂停或隐藏时,播放器与控制器都会自动暂停。
322
-
323
- TGS 使用 SDK 内置的 SVG light 播放器,并且只在首次显示 TGS 素材时动态加载。表盘项目不需要安装、导入或配置 `lottie-web`,也不需要为构建工具添加 Lottie alias。
324
-
325
- ## 行车状态的回退
326
-
327
- 行车状态有两类彼此独立的回退:传感器不可用时的**状态回退**,以及表情素材失败时的**媒体回退**。
328
-
329
- ### 传感器不可用时回退到静止
330
-
331
- 控制器遇到一次读取失败、非法快照或过期样本时,会暂时保持最后一个已提交状态和仍在有效时间范围内的识别窗口,避免页面闪烁或丢失转向、刹车证据。连续不可用达到 3 秒后,控制器才清空窗口并提交 `STOPPED`:
332
-
333
- ```ts
334
- const controller = createDrivingStatusController({
335
- unavailableFallbackMs: 3_000,
336
- onDiagnostic(event) {
337
- if (event.type === 'fallback_to_stopped') {
338
- console.warn(`传感器已连续不可用 ${event.unavailableForMs}ms`);
339
- }
340
- },
341
- });
342
-
343
- controller.subscribe((event) => {
344
- if (event.reason === 'unavailable_fallback') {
345
- console.log('已回退到 STOPPED');
346
- }
347
- });
348
- ```
349
-
350
- 传感器恢复后,控制器会清除不可用状态;短缺样前后的时间窗仍需满足独立观测数、持续时间和小于 1 秒的相邻间隔约束,不会把跨越长空洞的数据或单个样本提交给 UI。`unavailableFallbackMs` 必须是大于 0 的有限数值。
416
+ 播放器会复用视频元素,并保证同一时间只有一个动态素材在播放。TGS 播放能力已包含在 SDK 中,表盘项目不需要额外安装或配置 `lottie-web`。
351
417
 
352
- ### 素材失败时使用显式回退
418
+ 播放器提供 `show()`、`pause()`、`resume()`、`destroy()` 和 `getSnapshot()`。快照中的 `phase` 可能是 `idle`、`loading`、`ready`、`fallback` 或 `error`。
353
419
 
354
- SDK 提供 `OFFICIAL_DRIVING_EXPRESSION_FALLBACKS`,对应的默认 Emoji 为:
420
+ ### 素材失败时使用回退
355
421
 
356
- | 状态 | 回退内容 |
357
- | -------------------- | -------- |
358
- | `STOPPED` | 😐 |
359
- | `STEADY_DRIVING` | 😊 |
360
- | `ACCELERATION` | 😄 |
361
- | `RAPID_ACCELERATION` | 😲 |
362
- | `BRAKING` | 😣 |
363
- | `SUDDEN_BRAKING` | 😱 |
364
- | `LEFT_TURN` | 🤨 |
365
- | `RIGHT_TURN` | 🤨 |
366
-
367
- 官方母版必须显式把它传给播放器:
422
+ 官方 Emoji 回退不会自动启用,需要显式传入:
368
423
 
369
424
  ```ts
370
425
  import { OFFICIAL_DRIVING_EXPRESSION_FALLBACKS, createDrivingExpressionPlayer } from '@ziztechnology/dial-library';
@@ -380,79 +435,156 @@ const player = createDrivingExpressionPlayer(container, {
380
435
  });
381
436
  ```
382
437
 
383
- 回退是**选择加入**的:第三方不传 `fallbacks`,SDK 就不会自动使用官方 Emoji。素材缺失、URL 非法、网络失败、超时或解码失败时,播放器会进入错误状态并发送 `media_load_failed` 诊断事件。如果你对你开发的表盘的样式一致性比较高,你可以编写自己的回退。
384
-
385
- 也可以只为部分状态提供自己的回退:
438
+ 也可以只为部分状态提供自己的 Emoji 或图片回退:
386
439
 
387
440
  ```ts
388
441
  const player = createDrivingExpressionPlayer(container, {
389
442
  config,
390
443
  fallbacks: {
391
444
  STOPPED: { kind: 'emoji', text: '😐' },
392
- STEADY_DRIVING: { kind: 'emoji', text: '😊' },
445
+ STEADY_DRIVING: { kind: 'image', url: 'https://cdn.example.com/steady.png' },
393
446
  },
394
447
  });
395
448
  ```
396
449
 
397
- 播放器默认等待素材 5 秒,失败后按 15 秒退避重试。可以通过 `loadTimeoutMs` `retryBackoffMs` 调整。Live Photo 的 motion 加载失败时也会应用该状态的显式回退,而不会一直停留在 cover。
450
+ 只有 Emoji 和图片可以作为回退素材。网络失败、加载超时、解码失败或配置无效时,`onDiagnostic` 会收到 `media_load_failed`;如果有可用回退,还会收到 `media_fallback_applied`。
398
451
 
399
- ### 配置本身无效时
452
+ 默认加载超时是 5 秒,失败后 15 秒再重试。可以通过 `loadTimeoutMs` 和 `retryBackoffMs` 修改。图片和视频的显示方式可以通过 `fit: 'contain' | 'cover'` 设置。
400
453
 
401
- `readDrivingExpressionsConfig()` 只读取 Runtime 注入的 `__TOOOONY_DRIVING_EXPRESSIONS__`。配置不存在或无效时返回 `null`,不会替你补齐默认值:
454
+ ## 7. 页面生命周期
402
455
 
403
- ```ts
404
- const config = readDrivingExpressionsConfig();
456
+ 状态控制器和播放器默认都会跟随以下情况自动暂停和恢复:
405
457
 
406
- if (!config) {
407
- // 由表盘决定:显示静态占位、隐藏组件或终止初始化。
408
- }
409
- ```
458
+ - 页面隐藏或重新显示;
459
+ - `pagehide`、`pageshow`、`freeze`、`resume`;
460
+ - Runtime 的传感器暂停和恢复通知。
410
461
 
411
- Runtime v1.5.22 及以上的 PACKAGED_H5 会在 document-start 阶段完成配置注入,因此优先使用上述同步读取。若需要容忍异常的晚注入时序,可以进行一次有上限的异步等待:
462
+ 因此大多数表盘不需要自己监听 `visibilitychange`。如果页面已经有统一的生命周期管理,可以关闭自动管理,再手动调用相应方法:
412
463
 
413
464
  ```ts
414
- import { waitForDrivingExpressionsConfig } from '@ziztechnology/dial-library';
465
+ const controller = new DrivingStatusController({
466
+ managePageLifecycle: false,
467
+ });
415
468
 
416
- const abortController = new AbortController();
417
- const config = await waitForDrivingExpressionsConfig({
418
- timeoutMs: 3_000,
419
- pollIntervalMs: 100,
420
- signal: abortController.signal,
469
+ const player = createDrivingExpressionPlayer(container, {
470
+ config,
471
+ managePageLifecycle: false,
421
472
  });
422
473
  ```
423
474
 
424
- 该函数会立即读取一次,之后使用单个定时器重试。首次读到完整合法的配置时返回;超时返回 `null`;取消时以 `AbortError` 结束。它不会隐式使用官方 Emoji,超时后的界面和回退仍由表盘决定。页面卸载时应调用 `abortController.abort()`。
475
+ 关闭后,页面需要自行调用 `controller.start()` / `controller.stop()` `player.resume()` / `player.pause()`。页面最终销毁时仍应调用两者的 `destroy()`。
476
+
477
+ ## 8. 车载多媒体
478
+
479
+ 多媒体控制器把网页播放器接入 Runtime 的车载媒体控制能力。页面只需要提供当前状态和播放回调,不要直接创建或调用 `window.AndroidCarBridge`、`window.__carBridge`,也不要自行监听 Runtime 的内部媒体事件。
425
480
 
426
- schema v1 要求 `states` 恰好包含全部八种状态。支持的单个状态素材为:
481
+ `AndroidCarBridge` 不依赖 `customFields`,Runtime 会为加载的本地 H5 和直接打开的外部网址注入该能力。页面可以直接创建控制器:
427
482
 
428
483
  ```ts
429
- type StructuredDrivingExpressionMedia =
430
- | { kind: 'emoji'; text: string }
431
- | { kind: 'image'; mediaAssetId?: number; url: string; mimeType?: string }
432
- | {
433
- kind: 'video';
434
- mediaAssetId?: number;
435
- url: string;
436
- coverUrl?: string;
437
- mimeType?: string;
438
- }
439
- | {
440
- kind: 'live_photo';
441
- mediaAssetId?: number;
442
- coverUrl: string;
443
- motionUrl: string;
444
- }
445
- | { kind: 'tgs'; mediaAssetId?: number; url: string; coverUrl?: string };
446
- ```
484
+ import { MultimediaController } from '@ziztechnology/dial-library';
447
485
 
448
- 媒体 URL 必须是长期有效的公网 HTTPS 地址。解析器会拒绝 `http:`、`data:`、`blob:`、localhost、内网或保留地址,以及带常见临时签名参数的 URL。服务端仍应根据 `mediaAssetId` 和用户身份重建可信 URL;前端校验不能替代服务端所有权和域名校验。
486
+ const tracks = [
487
+ { title: 'First Song', artist: 'Toooony', url: 'https://cdn.example.com/first.mp3' },
488
+ { title: 'Second Song', artist: 'Toooony', url: 'https://cdn.example.com/second.mp3' },
489
+ ];
449
490
 
450
- 旧版表盘曾使用裸图片 URL。只有迁移旧配置时,才应显式开启兼容:
491
+ const audio = new Audio();
492
+ let currentIndex = 0;
451
493
 
452
- ```ts
453
- import { parseDrivingExpressionsConfig } from '@ziztechnology/dial-library';
494
+ const loadCurrentTrack = () => {
495
+ audio.src = tracks[currentIndex].url;
496
+ audio.load();
497
+ };
454
498
 
455
- const config = parseDrivingExpressionsConfig(rawConfig, {
456
- allowLegacyImageUrls: true,
499
+ let multimedia: MultimediaController;
500
+
501
+ const changeTrack = async (offset: number) => {
502
+ currentIndex = (currentIndex + offset + tracks.length) % tracks.length;
503
+ loadCurrentTrack();
504
+ await audio.play();
505
+ };
506
+
507
+ loadCurrentTrack();
508
+
509
+ multimedia = new MultimediaController({
510
+ getState() {
511
+ const track = tracks[currentIndex];
512
+ return {
513
+ isPlaying: !audio.paused && !audio.ended,
514
+ title: track.title,
515
+ artist: track.artist,
516
+ canNext: tracks.length > 1,
517
+ canPrevious: tracks.length > 1,
518
+ };
519
+ },
520
+ controls: {
521
+ play: () => audio.play(),
522
+ pause: () => audio.pause(),
523
+ next: () => changeTrack(1),
524
+ previous: () => changeTrack(-1),
525
+ seekToMs: (positionMs) => {
526
+ audio.currentTime = positionMs / 1_000;
527
+ },
528
+ },
457
529
  });
530
+
531
+ // 播放状态变化时立即同步;播放期间 SDK 还会每秒补报一次。
532
+ const reportState = () => multimedia.reportState();
533
+ audio.addEventListener('playing', reportState);
534
+ audio.addEventListener('pause', reportState);
535
+ audio.addEventListener('ended', reportState);
536
+
537
+ window.addEventListener(
538
+ 'pagehide',
539
+ () => {
540
+ audio.removeEventListener('playing', reportState);
541
+ audio.removeEventListener('pause', reportState);
542
+ audio.removeEventListener('ended', reportState);
543
+ multimedia.destroy();
544
+ },
545
+ { once: true },
546
+ );
458
547
  ```
548
+
549
+ ### 播放器状态
550
+
551
+ `getState()` 必须同步返回以下五个字段。SDK 只会把这些字段交给 Runtime:
552
+
553
+ | 字段 | 含义 |
554
+ | ------------- | ------------------------ |
555
+ | `isPlaying` | 当前是否正在播放 |
556
+ | `title` | 当前内容标题 |
557
+ | `artist` | 当前作者或歌手 |
558
+ | `canNext` | 当前是否允许切换到下一项 |
559
+ | `canPrevious` | 当前是否允许切换到上一项 |
560
+
561
+ 当前 Runtime 不读取 `positionMs`、`durationMs`、`album` 或 `artworkUrl` 等扩展字段,因此它们不属于 SDK 的状态契约。曲目或导航能力发生变化时,即使播放状态没有变化,也应调用 `reportState()`。
562
+
563
+ ### 控制回调和状态上报
564
+
565
+ `play` 和 `pause` 是必需回调;`toggle`、`next`、`previous`、`seekToMs` 可以省略。没有提供 `toggle` 时,SDK 会根据 `getState().isPlaying` 自动选择 `play` 或 `pause`。Runtime 只会给 `seekToMs` 传入有限且非负的毫秒值,SDK 也会拒绝非法值。
566
+
567
+ 控制回调可以返回 Promise。回调成功后 SDK 会再上报一次状态;异步失败会写入 SDK 和 Android 日志,不会形成未处理的 Promise rejection。业务仍应在真实的播放器事件中调用 `reportState()`,因为播放开始、缓冲或曲目加载可能晚于控制回调。
568
+
569
+ 首次创建时,SDK 会先读取并校验初始状态,再通知 Runtime 控制器已经 ready,随后立即上报该状态。初始状态无效或读取失败时不会向 Runtime 留下可接收按键的 ready 控制器。只要最新状态中的 `isPlaying` 为 `true`,SDK 就会默认每 `1_000ms` 补报一次;可以用 `reportIntervalMs` 设置其他正数间隔。一次临时的状态读取失败不会停止心跳,后续周期会继续重试。暂停或销毁后心跳会停止。
570
+
571
+ 同一个页面同一时间只能存在一个多媒体控制器。SDK 不会覆盖已有的内部 `__carBridge`;不再使用时必须调用 `destroy()`,该方法可以安全地重复调用。支持媒体生命周期协议的 Runtime 会在销毁时立即清除 ready、最后播放状态和按键路由状态,因此旧控制器不会继续吞掉车载按键。旧版 Runtime 不支持显式销毁通知时,SDK 会尽力补报一次暂停状态,页面切换或 Runtime 停用 WebView 时仍会完成最终清理。初始化异常可以通过 `MultimediaBridgeError.code` 区分:
572
+
573
+ | code | 含义 |
574
+ | ----------------------------------------- | -------------------------------------- |
575
+ | `MULTIMEDIA_BRIDGE_CONFLICT` | 页面已经存在媒体命令入口或另一个控制器 |
576
+ | `MULTIMEDIA_BRIDGE_INITIALIZATION_FAILED` | Ready 通知或初始状态上报失败 |
577
+
578
+ ## 常用导出速查
579
+
580
+ | 需求 | 推荐 API |
581
+ | --------------------- | ----------------------------------- |
582
+ | 读取一次设备信息 | `unifiedSensorInfo()` |
583
+ | 持续获取稳定行车状态 | `new DrivingStatusController()` |
584
+ | 分析一帧传感器数据 | `classifyDrivingSnapshot()` |
585
+ | 读取 Runtime 素材配置 | `readDrivingExpressionsConfig()` |
586
+ | 等待晚注入的配置 | `waitForDrivingExpressionsConfig()` |
587
+ | 校验一份原始配置 | `parseDrivingExpressionsConfig()` |
588
+ | 检查媒体 URL | `isPublicDrivingMediaUrl()` |
589
+ | 根据状态播放素材 | `createDrivingExpressionPlayer()` |
590
+ | 接入车载多媒体控制 | `new MultimediaController()` |