@yunlefun-home/air-conditioner-core 0.2.0 → 0.4.0
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/dist/index.cjs +1334 -68
- package/dist/index.d.cts +710 -40
- package/dist/index.d.ts +710 -40
- package/dist/index.js +1281 -72
- package/package.json +6 -4
package/dist/index.cjs
CHANGED
|
@@ -22,17 +22,129 @@ const acAudioFileNames = {
|
|
|
22
22
|
};
|
|
23
23
|
const minTemperature = 16;
|
|
24
24
|
const maxTemperature = 31;
|
|
25
|
+
const defaultAcLimits = {
|
|
26
|
+
min: 16,
|
|
27
|
+
max: 31,
|
|
28
|
+
step: 1
|
|
29
|
+
};
|
|
25
30
|
const recommendedColdTemperature = 26;
|
|
26
31
|
const recommendedHotTemperature = 20;
|
|
27
32
|
const defaultAcState = {
|
|
28
33
|
mode: "cold",
|
|
29
34
|
status: false,
|
|
30
|
-
temperature: 26
|
|
35
|
+
temperature: 26,
|
|
36
|
+
temperatures: {
|
|
37
|
+
cold: 26,
|
|
38
|
+
hot: 26
|
|
39
|
+
},
|
|
40
|
+
sound: "normal",
|
|
41
|
+
swing: false,
|
|
42
|
+
fan: "auto"
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* 遥控器「声音」键依次切换的顺序。
|
|
46
|
+
*/
|
|
47
|
+
const acSoundModes = [
|
|
48
|
+
"normal",
|
|
49
|
+
"sleep",
|
|
50
|
+
"mute"
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* 各模式的中文名称(遥控器小屏与按键提示共用)。
|
|
54
|
+
*/
|
|
55
|
+
const acModeLabels = {
|
|
56
|
+
cold: "制冷",
|
|
57
|
+
hot: "制热"
|
|
58
|
+
};
|
|
59
|
+
const acSoundModeLabels = {
|
|
60
|
+
normal: "标准",
|
|
61
|
+
sleep: "睡眠",
|
|
62
|
+
mute: "静音"
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* 各声音模式的总音量(0–1)。
|
|
66
|
+
*/
|
|
67
|
+
const acSoundVolumes = {
|
|
68
|
+
normal: 1,
|
|
69
|
+
sleep: .3,
|
|
70
|
+
mute: 0
|
|
31
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* 遥控器「风速」键依次切换的顺序。
|
|
74
|
+
*/
|
|
75
|
+
const acFanSpeeds = [
|
|
76
|
+
"auto",
|
|
77
|
+
"low",
|
|
78
|
+
"medium",
|
|
79
|
+
"high"
|
|
80
|
+
];
|
|
81
|
+
const acFanSpeedLabels = {
|
|
82
|
+
auto: "自动",
|
|
83
|
+
low: "低风",
|
|
84
|
+
medium: "中风",
|
|
85
|
+
high: "高风"
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* 小屏上点亮的风速格数(1–3)。自动风速由空调自己决定,显示为 0 并另加「自动」标记。
|
|
89
|
+
*/
|
|
90
|
+
const acFanBars = {
|
|
91
|
+
auto: 0,
|
|
92
|
+
low: 1,
|
|
93
|
+
medium: 2,
|
|
94
|
+
high: 3
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* 固定风速下风声的相对音量(0–1);自动风速沿用开机后的「达标 / 发力」循环。
|
|
98
|
+
*/
|
|
99
|
+
const acFanSoundLevels = {
|
|
100
|
+
low: .45,
|
|
101
|
+
medium: .7,
|
|
102
|
+
high: 1
|
|
103
|
+
};
|
|
104
|
+
function isAcFanSpeed(value) {
|
|
105
|
+
return acFanSpeeds.includes(value);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 风速键的下一档:自动 → 低 → 中 → 高 → 自动。
|
|
109
|
+
*/
|
|
110
|
+
function nextAcFanSpeed(fan) {
|
|
111
|
+
const index = Math.max(acFanSpeeds.indexOf(fan), 0);
|
|
112
|
+
return acFanSpeeds[(index + 1) % acFanSpeeds.length];
|
|
113
|
+
}
|
|
114
|
+
function isAcSoundMode(value) {
|
|
115
|
+
return acSoundModes.includes(value);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 声音键的下一个模式:标准 → 睡眠 → 静音 → 标准。
|
|
119
|
+
*/
|
|
120
|
+
function nextAcSoundMode(mode) {
|
|
121
|
+
const index = Math.max(acSoundModes.indexOf(mode), 0);
|
|
122
|
+
return acSoundModes[(index + 1) % acSoundModes.length];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 音效时序。时长单位为毫秒,`*Level` 是风声相对音量(0–1)。
|
|
126
|
+
*/
|
|
32
127
|
const soundTimings = {
|
|
128
|
+
/** 开机后多久接上持续的风声(启动声约 9 秒) */
|
|
33
129
|
startDelayMs: 8e3,
|
|
34
|
-
|
|
35
|
-
|
|
130
|
+
/** 风声循环首尾的交叉淡化时长 */
|
|
131
|
+
loopCrossfadeMs: 1500,
|
|
132
|
+
/** 关机时声音淡出时长 */
|
|
133
|
+
stopFadeMs: 600,
|
|
134
|
+
/** 开机运行多久后室温达标、风声降下来 */
|
|
135
|
+
settleAfterMs: 9e4,
|
|
136
|
+
/** 风声降下来的渐变时长 */
|
|
137
|
+
settleRampMs: 2e4,
|
|
138
|
+
/** 达标后的安静风声 */
|
|
139
|
+
settleLevel: .5,
|
|
140
|
+
/** 室温回升、压缩机重新发力时的风声 */
|
|
141
|
+
surgeLevel: .85,
|
|
142
|
+
/** 两次发力之间安静运行的时长范围 */
|
|
143
|
+
quietDurationMs: [18e4, 36e4],
|
|
144
|
+
/** 每次发力持续的时长范围 */
|
|
145
|
+
surgeDurationMs: [6e4, 12e4],
|
|
146
|
+
/** 发力 / 回落的渐变时长 */
|
|
147
|
+
surgeRampMs: 1e4
|
|
36
148
|
};
|
|
37
149
|
/**
|
|
38
150
|
* 根据基础路径解析出三个音频文件的完整 URL。
|
|
@@ -52,79 +164,789 @@ function resolveAcAudioAssets(baseUrl = "/assets/audio", ext = "m4a") {
|
|
|
52
164
|
}
|
|
53
165
|
//#endregion
|
|
54
166
|
//#region src/audio.ts
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
167
|
+
/** Web Audio 资源迟迟没加载好时,本次开机先用 `<audio>` 播放 */
|
|
168
|
+
const readyTimeoutMs = 1500;
|
|
169
|
+
const fallbackTweenStepMs = 100;
|
|
170
|
+
const curveLength = 64;
|
|
171
|
+
function sourceUrl(element) {
|
|
172
|
+
return element?.currentSrc || element?.src || "";
|
|
173
|
+
}
|
|
174
|
+
async function fetchAudioData(url) {
|
|
175
|
+
const response = await fetch(url);
|
|
176
|
+
if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`);
|
|
177
|
+
return response.arrayBuffer();
|
|
178
|
+
}
|
|
179
|
+
function createDefaultAudioContext() {
|
|
180
|
+
const scope = globalThis;
|
|
181
|
+
const AudioContextConstructor = scope.AudioContext ?? scope.webkitAudioContext;
|
|
182
|
+
return AudioContextConstructor ? new AudioContextConstructor() : null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 合成一声很轻的「嗒」:音高快速下滑的正弦波,短促起音、指数衰减,像水珠落在窗台上。
|
|
186
|
+
*/
|
|
187
|
+
function playDrip(context, output) {
|
|
188
|
+
const start = context.currentTime;
|
|
189
|
+
const oscillator = context.createOscillator();
|
|
190
|
+
const gain = context.createGain();
|
|
191
|
+
oscillator.type = "sine";
|
|
192
|
+
oscillator.frequency.setValueAtTime(1500, start);
|
|
193
|
+
oscillator.frequency.exponentialRampToValueAtTime(520, start + .07);
|
|
194
|
+
gain.gain.setValueAtTime(1e-4, start);
|
|
195
|
+
gain.gain.exponentialRampToValueAtTime(.12, start + .004);
|
|
196
|
+
gain.gain.exponentialRampToValueAtTime(1e-4, start + .16);
|
|
197
|
+
oscillator.connect(gain);
|
|
198
|
+
gain.connect(output);
|
|
199
|
+
oscillator.start(start);
|
|
200
|
+
oscillator.stop(start + .18);
|
|
201
|
+
oscillator.onended = () => gain.disconnect();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 等功率淡入 / 淡出曲线,噪声类声音交叉淡化时中段不会变小。
|
|
205
|
+
*/
|
|
206
|
+
function fadeCurve(direction) {
|
|
207
|
+
const curve = new Float32Array(curveLength);
|
|
208
|
+
for (let i = 0; i < curveLength; i++) {
|
|
209
|
+
const phase = i / 63 * Math.PI / 2;
|
|
210
|
+
curve[i] = direction === "in" ? Math.sin(phase) : Math.cos(phase);
|
|
211
|
+
}
|
|
212
|
+
return curve;
|
|
213
|
+
}
|
|
214
|
+
function randomBetween(range, random) {
|
|
215
|
+
return range[0] + (range[1] - range[0]) * random();
|
|
216
|
+
}
|
|
217
|
+
function playElement(element) {
|
|
218
|
+
if (!element) return;
|
|
219
|
+
element.load();
|
|
220
|
+
Promise.resolve(element.play()).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
function stopElement(element) {
|
|
223
|
+
if (!element) return;
|
|
224
|
+
if (element.pause) element.pause();
|
|
225
|
+
else element.load();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 空调音效:按键「嘀」声、开机启动声与持续的风声。
|
|
229
|
+
*
|
|
230
|
+
* 默认通过 Web Audio API 播放:风声无缝循环,开关机淡入淡出,运行一段时间后
|
|
231
|
+
* 风声变小、并不定时随压缩机发力起伏。环境不支持或音频无法获取时退回
|
|
232
|
+
* `<audio>` 元素。
|
|
233
|
+
*
|
|
234
|
+
* 需在用户交互(点击遥控器)里首次调用,浏览器才允许发声。
|
|
235
|
+
*
|
|
236
|
+
* @param elements 三个音频元素;Web Audio 会从它们的 `src` 获取音频
|
|
237
|
+
* @param options 声音模式与可替换的环境依赖
|
|
238
|
+
*/
|
|
239
|
+
function createAcAudioController(elements, options = {}) {
|
|
240
|
+
const random = options.random ?? Math.random;
|
|
241
|
+
let soundMode = options.soundMode ?? "normal";
|
|
242
|
+
let fanSpeed = options.fanSpeed ?? "auto";
|
|
243
|
+
/** 开机后风声循环已经接上(启动声结束) */
|
|
244
|
+
let fanRunning = false;
|
|
245
|
+
/** 每次重启「达标 / 发力」循环自增,丢弃旧循环的回调 */
|
|
246
|
+
let fanCycle = 0;
|
|
247
|
+
/** undefined:尚未尝试;null:不可用 */
|
|
248
|
+
let engine;
|
|
249
|
+
let run;
|
|
250
|
+
/** 每次开关机自增,丢弃过期的异步回调 */
|
|
251
|
+
let session = 0;
|
|
252
|
+
const timers = /* @__PURE__ */ new Set();
|
|
253
|
+
/** `<audio>` 播放时的风声相对音量 */
|
|
254
|
+
let fallbackFanLevel = 1;
|
|
255
|
+
let fallbackTween;
|
|
256
|
+
function later(callback, delayMs) {
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
timers.delete(timer);
|
|
259
|
+
callback();
|
|
260
|
+
}, delayMs);
|
|
261
|
+
timers.add(timer);
|
|
262
|
+
}
|
|
263
|
+
function clearTimers() {
|
|
264
|
+
timers.forEach((timer) => clearTimeout(timer));
|
|
265
|
+
timers.clear();
|
|
266
|
+
}
|
|
267
|
+
function volume() {
|
|
268
|
+
return acSoundVolumes[soundMode];
|
|
269
|
+
}
|
|
270
|
+
function applyElementVolume(element, level = 1) {
|
|
271
|
+
if (!element) return;
|
|
272
|
+
if (element.volume !== void 0) element.volume = Math.min(1, Math.max(0, volume() * level));
|
|
273
|
+
if (element.muted !== void 0) element.muted = soundMode === "mute";
|
|
274
|
+
}
|
|
275
|
+
function cancelFallbackTween() {
|
|
276
|
+
if (fallbackTween) clearInterval(fallbackTween);
|
|
277
|
+
fallbackTween = void 0;
|
|
278
|
+
}
|
|
279
|
+
function tweenFallbackFan(target, durationMs, onDone) {
|
|
280
|
+
cancelFallbackTween();
|
|
281
|
+
const from = fallbackFanLevel;
|
|
282
|
+
const steps = Math.max(1, Math.round(durationMs / fallbackTweenStepMs));
|
|
283
|
+
let step = 0;
|
|
284
|
+
fallbackTween = setInterval(() => {
|
|
285
|
+
step++;
|
|
286
|
+
fallbackFanLevel = from + (target - from) * (step / steps);
|
|
287
|
+
applyElementVolume(elements.work, fallbackFanLevel);
|
|
288
|
+
if (step >= steps) {
|
|
289
|
+
cancelFallbackTween();
|
|
290
|
+
onDone?.();
|
|
291
|
+
}
|
|
292
|
+
}, fallbackTweenStepMs);
|
|
66
293
|
}
|
|
67
|
-
function
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
294
|
+
function ensureEngine() {
|
|
295
|
+
if (engine !== void 0) return engine;
|
|
296
|
+
engine = createEngine();
|
|
297
|
+
return engine;
|
|
71
298
|
}
|
|
72
|
-
function
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
299
|
+
function createEngine() {
|
|
300
|
+
if (options.webAudio === false) return null;
|
|
301
|
+
let context;
|
|
302
|
+
try {
|
|
303
|
+
context = (options.createAudioContext ?? createDefaultAudioContext)();
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
if (!context) return null;
|
|
308
|
+
const master = context.createGain();
|
|
309
|
+
master.gain.value = volume();
|
|
310
|
+
master.connect(context.destination);
|
|
311
|
+
const buffers = {};
|
|
312
|
+
const load = options.loadAudioData ?? fetchAudioData;
|
|
313
|
+
const decode = async (name) => {
|
|
314
|
+
const url = sourceUrl(elements[name]);
|
|
315
|
+
if (!url) return;
|
|
316
|
+
try {
|
|
317
|
+
buffers[name] = await context.decodeAudioData(await load(url));
|
|
318
|
+
} catch {}
|
|
319
|
+
};
|
|
320
|
+
const ready = Promise.all([
|
|
321
|
+
"beep",
|
|
322
|
+
"start",
|
|
323
|
+
"work"
|
|
324
|
+
].map(decode)).then(() => Boolean(buffers.start && buffers.work));
|
|
325
|
+
return {
|
|
326
|
+
context,
|
|
327
|
+
master,
|
|
328
|
+
buffers,
|
|
329
|
+
ready
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function resume(web) {
|
|
333
|
+
if (web.context.state === "suspended") Promise.resolve(web.context.resume()).catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
function waitReady(web) {
|
|
336
|
+
return Promise.race([web.ready, new Promise((resolve) => setTimeout(resolve, readyTimeoutMs, false))]);
|
|
337
|
+
}
|
|
338
|
+
function playBuffer(web, buffer, destination, when) {
|
|
339
|
+
const source = web.context.createBufferSource();
|
|
340
|
+
source.buffer = buffer;
|
|
341
|
+
source.connect(destination);
|
|
342
|
+
source.start(when);
|
|
343
|
+
return source;
|
|
344
|
+
}
|
|
345
|
+
function track(target, source, cleanup) {
|
|
346
|
+
target.sources.add(source);
|
|
347
|
+
source.onended = () => {
|
|
348
|
+
target.sources.delete(source);
|
|
349
|
+
source.disconnect();
|
|
350
|
+
cleanup?.();
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* 排一段风声,并在它结束前交叉淡化接上下一段。
|
|
355
|
+
*/
|
|
356
|
+
function loopFan(web, target, when) {
|
|
357
|
+
const buffer = web.buffers.work;
|
|
358
|
+
const crossfade = Math.min(soundTimings.loopCrossfadeMs / 1e3, buffer.duration / 2);
|
|
359
|
+
const gain = web.context.createGain();
|
|
360
|
+
gain.gain.value = 0;
|
|
361
|
+
gain.gain.setValueCurveAtTime(fadeCurve("in"), when, crossfade);
|
|
362
|
+
gain.gain.setValueCurveAtTime(fadeCurve("out"), when + buffer.duration - crossfade, crossfade);
|
|
363
|
+
gain.connect(target.fan);
|
|
364
|
+
const source = playBuffer(web, buffer, gain, when);
|
|
365
|
+
source.stop(when + buffer.duration);
|
|
366
|
+
track(target, source, () => gain.disconnect());
|
|
367
|
+
const next = when + buffer.duration - crossfade;
|
|
368
|
+
later(() => {
|
|
369
|
+
if (run === target) loopFan(web, target, next);
|
|
370
|
+
}, Math.max(0, (next - web.context.currentTime - 1) * 1e3));
|
|
371
|
+
}
|
|
372
|
+
function setFanLevel(level, rampMs) {
|
|
373
|
+
if (run && engine) {
|
|
374
|
+
const gain = run.fan.gain;
|
|
375
|
+
const now = engine.context.currentTime;
|
|
376
|
+
gain.cancelScheduledValues(now);
|
|
377
|
+
gain.setValueAtTime(gain.value, now);
|
|
378
|
+
gain.linearRampToValueAtTime(level, now + rampMs / 1e3);
|
|
379
|
+
} else tweenFallbackFan(level, rampMs);
|
|
380
|
+
}
|
|
381
|
+
/** 风声循环刚接上时的音量:自动风速从满音量开始,固定风速保持各自的音量。 */
|
|
382
|
+
function initialFanLevel() {
|
|
383
|
+
return fanSpeed === "auto" ? 1 : acFanSoundLevels[fanSpeed];
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 自动风速:室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
|
|
387
|
+
* 固定风速不走这个循环。
|
|
388
|
+
*/
|
|
389
|
+
function startFanCycle() {
|
|
390
|
+
fanRunning = true;
|
|
391
|
+
const cycle = ++fanCycle;
|
|
392
|
+
if (fanSpeed !== "auto") return;
|
|
393
|
+
const current = () => cycle === fanCycle && fanSpeed === "auto";
|
|
394
|
+
const quiet = () => {
|
|
395
|
+
later(() => {
|
|
396
|
+
if (!current()) return;
|
|
397
|
+
setFanLevel(soundTimings.surgeLevel, soundTimings.surgeRampMs);
|
|
398
|
+
later(() => {
|
|
399
|
+
if (!current()) return;
|
|
400
|
+
setFanLevel(soundTimings.settleLevel, soundTimings.surgeRampMs);
|
|
401
|
+
quiet();
|
|
402
|
+
}, randomBetween(soundTimings.surgeDurationMs, random));
|
|
403
|
+
}, randomBetween(soundTimings.quietDurationMs, random));
|
|
404
|
+
};
|
|
405
|
+
later(() => {
|
|
406
|
+
if (!current()) return;
|
|
407
|
+
setFanLevel(soundTimings.settleLevel, soundTimings.settleRampMs);
|
|
408
|
+
quiet();
|
|
409
|
+
}, soundTimings.settleAfterMs);
|
|
410
|
+
}
|
|
411
|
+
function startWeb(web) {
|
|
412
|
+
const output = web.context.createGain();
|
|
413
|
+
output.connect(web.master);
|
|
414
|
+
const fan = web.context.createGain();
|
|
415
|
+
fan.gain.value = initialFanLevel();
|
|
416
|
+
fan.connect(output);
|
|
417
|
+
const target = {
|
|
418
|
+
output,
|
|
419
|
+
fan,
|
|
420
|
+
sources: /* @__PURE__ */ new Set()
|
|
421
|
+
};
|
|
422
|
+
run = target;
|
|
423
|
+
const now = web.context.currentTime;
|
|
424
|
+
track(target, playBuffer(web, web.buffers.start, output, now));
|
|
425
|
+
loopFan(web, target, now + soundTimings.startDelayMs / 1e3);
|
|
426
|
+
later(startFanCycle, soundTimings.startDelayMs);
|
|
427
|
+
}
|
|
428
|
+
function stopWeb(fadeMs) {
|
|
429
|
+
const target = run;
|
|
430
|
+
run = void 0;
|
|
431
|
+
if (!target || !engine) return;
|
|
432
|
+
const now = engine.context.currentTime;
|
|
433
|
+
const end = now + fadeMs / 1e3;
|
|
434
|
+
const gain = target.output.gain;
|
|
435
|
+
gain.cancelScheduledValues(now);
|
|
436
|
+
gain.setValueAtTime(gain.value, now);
|
|
437
|
+
gain.linearRampToValueAtTime(0, end);
|
|
438
|
+
target.sources.forEach((source) => {
|
|
439
|
+
try {
|
|
440
|
+
source.stop(end);
|
|
441
|
+
} catch {}
|
|
442
|
+
});
|
|
443
|
+
setTimeout(() => target.output.disconnect(), fadeMs + 100);
|
|
80
444
|
}
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
445
|
+
function startFallback() {
|
|
446
|
+
const { start, work } = elements;
|
|
447
|
+
stopElement(work);
|
|
448
|
+
applyElementVolume(start);
|
|
449
|
+
playElement(start);
|
|
450
|
+
later(() => {
|
|
451
|
+
if (!work) return;
|
|
452
|
+
fallbackFanLevel = 0;
|
|
453
|
+
applyElementVolume(work, 0);
|
|
454
|
+
if (work.loop !== void 0) work.loop = true;
|
|
455
|
+
playElement(work);
|
|
456
|
+
tweenFallbackFan(initialFanLevel(), soundTimings.loopCrossfadeMs);
|
|
457
|
+
startFanCycle();
|
|
85
458
|
}, soundTimings.startDelayMs);
|
|
86
459
|
}
|
|
460
|
+
function stopFallback(fadeMs) {
|
|
461
|
+
stopElement(elements.start);
|
|
462
|
+
if (fadeMs > 0) tweenFallbackFan(0, fadeMs, () => stopElement(elements.work));
|
|
463
|
+
else stopElement(elements.work);
|
|
464
|
+
}
|
|
465
|
+
function powerOn() {
|
|
466
|
+
const id = ++session;
|
|
467
|
+
fanRunning = false;
|
|
468
|
+
fanCycle++;
|
|
469
|
+
clearTimers();
|
|
470
|
+
cancelFallbackTween();
|
|
471
|
+
stopWeb(0);
|
|
472
|
+
stopFallback(0);
|
|
473
|
+
const web = ensureEngine();
|
|
474
|
+
if (!web) {
|
|
475
|
+
startFallback();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
resume(web);
|
|
479
|
+
waitReady(web).then((ok) => {
|
|
480
|
+
if (id !== session) return;
|
|
481
|
+
if (ok) startWeb(web);
|
|
482
|
+
else startFallback();
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
function powerOff() {
|
|
486
|
+
session++;
|
|
487
|
+
fanRunning = false;
|
|
488
|
+
fanCycle++;
|
|
489
|
+
clearTimers();
|
|
490
|
+
stopWeb(soundTimings.stopFadeMs);
|
|
491
|
+
stopFallback(soundTimings.stopFadeMs);
|
|
492
|
+
}
|
|
87
493
|
return {
|
|
88
494
|
beep() {
|
|
89
|
-
|
|
495
|
+
if (soundMode === "mute") return;
|
|
496
|
+
const web = ensureEngine();
|
|
497
|
+
if (web) {
|
|
498
|
+
resume(web);
|
|
499
|
+
if (web.buffers.beep) {
|
|
500
|
+
playBuffer(web, web.buffers.beep, web.master, web.context.currentTime);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
applyElementVolume(elements.beep);
|
|
505
|
+
playElement(elements.beep);
|
|
506
|
+
},
|
|
507
|
+
drip() {
|
|
508
|
+
if (soundMode !== "normal") return;
|
|
509
|
+
const web = ensureEngine();
|
|
510
|
+
if (!web || typeof web.context.createOscillator !== "function") return;
|
|
511
|
+
resume(web);
|
|
512
|
+
playDrip(web.context, web.master);
|
|
90
513
|
},
|
|
91
514
|
toggleStatusAudio(previousStatus) {
|
|
92
|
-
if (previousStatus)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
515
|
+
if (previousStatus) powerOff();
|
|
516
|
+
else powerOn();
|
|
517
|
+
},
|
|
518
|
+
setSoundMode(mode) {
|
|
519
|
+
soundMode = mode;
|
|
520
|
+
if (engine) engine.master.gain.setTargetAtTime(volume(), engine.context.currentTime, .08);
|
|
521
|
+
applyElementVolume(elements.beep);
|
|
522
|
+
applyElementVolume(elements.start);
|
|
523
|
+
applyElementVolume(elements.work, fallbackFanLevel);
|
|
524
|
+
},
|
|
525
|
+
setFanSpeed(fan) {
|
|
526
|
+
if (fan === fanSpeed) return;
|
|
527
|
+
fanSpeed = fan;
|
|
528
|
+
if (!fanRunning) {
|
|
529
|
+
if (run) run.fan.gain.value = initialFanLevel();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (fan === "auto") {
|
|
533
|
+
setFanLevel(1, soundTimings.loopCrossfadeMs);
|
|
534
|
+
startFanCycle();
|
|
96
535
|
} else {
|
|
97
|
-
|
|
98
|
-
|
|
536
|
+
fanCycle++;
|
|
537
|
+
setFanLevel(acFanSoundLevels[fan], soundTimings.loopCrossfadeMs);
|
|
99
538
|
}
|
|
100
539
|
},
|
|
101
540
|
dispose() {
|
|
102
|
-
|
|
541
|
+
session++;
|
|
542
|
+
clearTimers();
|
|
543
|
+
cancelFallbackTween();
|
|
544
|
+
stopWeb(0);
|
|
545
|
+
stopFallback(0);
|
|
546
|
+
if (engine && !options.createAudioContext) Promise.resolve(engine.context.close()).catch(() => {});
|
|
547
|
+
engine = null;
|
|
103
548
|
}
|
|
104
549
|
};
|
|
105
550
|
}
|
|
106
551
|
//#endregion
|
|
552
|
+
//#region src/condensation.ts
|
|
553
|
+
/** 风速越大,雾气越浓、冷凝水越快 */
|
|
554
|
+
const fanFactor = {
|
|
555
|
+
auto: .8,
|
|
556
|
+
low: .55,
|
|
557
|
+
medium: .8,
|
|
558
|
+
high: 1
|
|
559
|
+
};
|
|
560
|
+
/** 高于这个设定温度不滴水:冷凝水不该常见,只在开得很冷时偶尔出现 */
|
|
561
|
+
const acDripMaxTemperature = 22;
|
|
562
|
+
/** 最冷(16 °C)与 {@link acDripMaxTemperature} 时,两滴水之间的间隔(ms) */
|
|
563
|
+
const acDripIntervalRange = [9e3, 18e3];
|
|
564
|
+
/**
|
|
565
|
+
* 出风口雾气的浓度(#70),0–1:只在制冷开机时出现,设定温度越低、风越大越浓。
|
|
566
|
+
*
|
|
567
|
+
* 视图把它写进 CSS 变量 `--acw-mist`,3D 等其他渲染器也可以按它控制粒子数量。
|
|
568
|
+
*/
|
|
569
|
+
function getAcMist(state) {
|
|
570
|
+
if (!state.status || state.mode !== "cold") return 0;
|
|
571
|
+
const cold = .2 + .8 * (31 - state.temperature) / 15;
|
|
572
|
+
const mist = Math.min(1, Math.max(.2, cold)) * fanFactor[state.fan ?? "auto"];
|
|
573
|
+
return Math.round(mist * 100) / 100;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* 冷凝水滴落的间隔(#21,ms):只在制冷开机、设定温度不高于 {@link acDripMaxTemperature} 时滴水,
|
|
577
|
+
* 越冷越频繁,高风再快一些;不滴水时返回 `undefined`。
|
|
578
|
+
*
|
|
579
|
+
* 视图把它写进 CSS 变量 `--acw-drip-cycle`,水珠在出风口边缘慢慢聚起再落下,开机后要等一整个间隔才滴第一滴。
|
|
580
|
+
*/
|
|
581
|
+
function getAcDripInterval(state) {
|
|
582
|
+
if (!state.status || state.mode !== "cold" || state.temperature > 22) return void 0;
|
|
583
|
+
const [fastest, slowest] = acDripIntervalRange;
|
|
584
|
+
const progress = (state.temperature - 16) / 6;
|
|
585
|
+
const interval = fastest + (slowest - fastest) * Math.min(1, Math.max(0, progress));
|
|
586
|
+
return Math.round(interval * (state.fan === "high" ? .8 : 1));
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 视图要写到空调根元素上的雾气 / 滴水样式变量与属性。
|
|
590
|
+
*/
|
|
591
|
+
function getAcCondensationStyle(state) {
|
|
592
|
+
const interval = getAcDripInterval(state);
|
|
593
|
+
return {
|
|
594
|
+
mist: getAcMist(state),
|
|
595
|
+
dripCycle: interval === void 0 ? void 0 : `${interval}ms`,
|
|
596
|
+
drip: interval === void 0 ? "off" : "on"
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/motion.ts
|
|
601
|
+
/**
|
|
602
|
+
* 动效 token:各框架视图层共用的时长与缓动曲线。
|
|
603
|
+
*
|
|
604
|
+
* 样式里统一通过 CSS 变量(`--acw-motion-*`)引用,
|
|
605
|
+
* 需要在 JS 里计时的地方(如 React 过渡组件)直接读取 `acMotion`。
|
|
606
|
+
*/
|
|
607
|
+
const acMotion = {
|
|
608
|
+
duration: {
|
|
609
|
+
/** 按下反馈 */
|
|
610
|
+
instant: 90,
|
|
611
|
+
/** 悬停、指示灯、图标 */
|
|
612
|
+
fast: 160,
|
|
613
|
+
/** 显示屏、数字切换、提示 */
|
|
614
|
+
base: 240,
|
|
615
|
+
/** 导风板、出风淡入淡出 */
|
|
616
|
+
slow: 420,
|
|
617
|
+
/** 出风气流单次循环 */
|
|
618
|
+
flow: 1800,
|
|
619
|
+
/** 扫风:左右摆动一个来回 */
|
|
620
|
+
swing: 5200
|
|
621
|
+
},
|
|
622
|
+
easing: {
|
|
623
|
+
/** 常规状态切换 */
|
|
624
|
+
standard: "cubic-bezier(0.2, 0, 0, 1)",
|
|
625
|
+
/** 进入:快起慢停 */
|
|
626
|
+
enter: "cubic-bezier(0, 0, 0, 1)",
|
|
627
|
+
/** 退出:慢起快走 */
|
|
628
|
+
exit: "cubic-bezier(0.3, 0, 1, 1)",
|
|
629
|
+
/** 轻微回弹(按钮松开、图标弹出) */
|
|
630
|
+
spring: "cubic-bezier(0.34, 1.56, 0.64, 1)"
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
/**
|
|
634
|
+
* `prefers-reduced-motion: reduce` 时使用的时长:
|
|
635
|
+
* 保留短促的淡入淡出(不涉及位移),去掉较长的过渡;循环动画由各样式表自行关闭。
|
|
636
|
+
*/
|
|
637
|
+
const acReducedMotionDuration = {
|
|
638
|
+
instant: 0,
|
|
639
|
+
fast: 80,
|
|
640
|
+
base: 120,
|
|
641
|
+
slow: 120,
|
|
642
|
+
flow: 0,
|
|
643
|
+
swing: 0
|
|
644
|
+
};
|
|
645
|
+
const acReducedMotionQuery = "(prefers-reduced-motion: reduce)";
|
|
646
|
+
/**
|
|
647
|
+
* 当前环境是否要求减少动效(SSR / 无 matchMedia 时返回 `false`)。
|
|
648
|
+
*/
|
|
649
|
+
function prefersReducedMotion() {
|
|
650
|
+
return typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
651
|
+
}
|
|
652
|
+
function declarations(durations, withEasing, indent) {
|
|
653
|
+
const lines = Object.keys(durations).map((key) => `${indent}--acw-motion-duration-${key}: ${durations[key]}ms;`);
|
|
654
|
+
if (withEasing) for (const key of Object.keys(acMotion.easing)) lines.push(`${indent}--acw-motion-ease-${key}: ${acMotion.easing[key]};`);
|
|
655
|
+
return lines.join("\n");
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* 生成动效 CSS 变量声明(含 reduced-motion 覆盖),供 Shadow DOM 注入或样式表同步使用。
|
|
659
|
+
*
|
|
660
|
+
* @param selector 变量挂载的选择器,如 `:root`、`:host`
|
|
661
|
+
*/
|
|
662
|
+
function renderAcMotionCssVars(selector = ":root") {
|
|
663
|
+
return [
|
|
664
|
+
`${selector} {`,
|
|
665
|
+
declarations(acMotion.duration, true, " "),
|
|
666
|
+
"}",
|
|
667
|
+
"",
|
|
668
|
+
`@media ${acReducedMotionQuery} {`,
|
|
669
|
+
` ${selector} {`,
|
|
670
|
+
declarations(acReducedMotionDuration, false, " "),
|
|
671
|
+
" }",
|
|
672
|
+
"}"
|
|
673
|
+
].join("\n");
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/layout.ts
|
|
677
|
+
/**
|
|
678
|
+
* 机身的设计尺寸(px),约 3:1,接近真实壁挂机(如 800–900mm × 280–300mm)的比例。
|
|
679
|
+
* 窄容器下按比例整体缩放,保持「长款」外观不被压扁。
|
|
680
|
+
*
|
|
681
|
+
* - `compact`:Vue / React 组件库 / Web Component 使用的 360 × 128 机身
|
|
682
|
+
* - `wide`:React 主站使用的 600 × 200 机身
|
|
683
|
+
*/
|
|
684
|
+
const acShellSize = {
|
|
685
|
+
compact: {
|
|
686
|
+
width: 360,
|
|
687
|
+
height: 128
|
|
688
|
+
},
|
|
689
|
+
wide: {
|
|
690
|
+
width: 600,
|
|
691
|
+
height: 200
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
/**
|
|
695
|
+
* 根据可用宽度计算机身缩放比例:只缩小、不放大。
|
|
696
|
+
*/
|
|
697
|
+
function getAcScale(availableWidth, designWidth) {
|
|
698
|
+
if (!(availableWidth > 0) || !(designWidth > 0)) return 1;
|
|
699
|
+
return Math.min(1, availableWidth / designWidth);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* 监听容器宽度变化并回调缩放比例。
|
|
703
|
+
*
|
|
704
|
+
* 不支持 `ResizeObserver` 的环境(如 SSR)直接返回空的清理函数,保持 1 倍显示。
|
|
705
|
+
*
|
|
706
|
+
* @param target 被测量宽度的容器(不要传被缩放的元素本身)
|
|
707
|
+
* @param designWidth 机身的设计宽度
|
|
708
|
+
* @param onScale 比例变化时的回调
|
|
709
|
+
* @returns 停止监听的清理函数
|
|
710
|
+
*/
|
|
711
|
+
function observeAcScale(target, designWidth, onScale) {
|
|
712
|
+
if (typeof ResizeObserver === "undefined") return () => {};
|
|
713
|
+
let current;
|
|
714
|
+
const update = (width) => {
|
|
715
|
+
const scale = getAcScale(width, designWidth);
|
|
716
|
+
if (scale !== current) {
|
|
717
|
+
current = scale;
|
|
718
|
+
onScale(scale);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
update(target.getBoundingClientRect().width);
|
|
722
|
+
const observer = new ResizeObserver((entries) => {
|
|
723
|
+
for (const entry of entries) update(entry.contentRect.width);
|
|
724
|
+
});
|
|
725
|
+
observer.observe(target);
|
|
726
|
+
return () => observer.disconnect();
|
|
727
|
+
}
|
|
728
|
+
/** 指针跟随倾斜的最大角度(deg) */
|
|
729
|
+
const acMaxTilt = 4;
|
|
730
|
+
const finePointerQuery = "(hover: hover) and (pointer: fine)";
|
|
731
|
+
/**
|
|
732
|
+
* 指针在元素上移动时回调轻微的 3D 倾斜角度(`rotateX` / `rotateY`,单位 deg),离开时回到 0。
|
|
733
|
+
*
|
|
734
|
+
* 仅在有悬停能力的精细指针(鼠标 / 触控板)上启用;`prefers-reduced-motion: reduce`、
|
|
735
|
+
* 触屏或 SSR 下直接返回空的清理函数。
|
|
736
|
+
*
|
|
737
|
+
* @returns 停止监听的清理函数
|
|
738
|
+
*/
|
|
739
|
+
function observeAcTilt(target, onTilt, maxTilt = 4) {
|
|
740
|
+
const matchMedia = globalThis.matchMedia;
|
|
741
|
+
if (typeof matchMedia !== "function" || !matchMedia(finePointerQuery).matches || matchMedia("(prefers-reduced-motion: reduce)").matches) return () => {};
|
|
742
|
+
let frame = 0;
|
|
743
|
+
const schedule = (rotateX, rotateY) => {
|
|
744
|
+
cancelAnimationFrame(frame);
|
|
745
|
+
frame = requestAnimationFrame(() => onTilt(rotateX, rotateY));
|
|
746
|
+
};
|
|
747
|
+
const onMove = (event) => {
|
|
748
|
+
const rect = target.getBoundingClientRect();
|
|
749
|
+
if (!rect.width || !rect.height) return;
|
|
750
|
+
const x = (event.clientX - rect.left) / rect.width - .5;
|
|
751
|
+
const y = (event.clientY - rect.top) / rect.height - .5;
|
|
752
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
753
|
+
schedule(round(-y * 2 * maxTilt), round(x * 2 * maxTilt));
|
|
754
|
+
};
|
|
755
|
+
const onLeave = () => schedule(0, 0);
|
|
756
|
+
target.addEventListener("pointermove", onMove);
|
|
757
|
+
target.addEventListener("pointerleave", onLeave);
|
|
758
|
+
return () => {
|
|
759
|
+
cancelAnimationFrame(frame);
|
|
760
|
+
target.removeEventListener("pointermove", onMove);
|
|
761
|
+
target.removeEventListener("pointerleave", onLeave);
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/model.ts
|
|
766
|
+
const acForms = ["wall", "tower"];
|
|
767
|
+
const acFormLabels = {
|
|
768
|
+
wall: "挂机",
|
|
769
|
+
tower: "柜机"
|
|
770
|
+
};
|
|
771
|
+
/** 网页用的模型文件名(站点的 `public/models/` 下) */
|
|
772
|
+
const acModelFiles = {
|
|
773
|
+
wall: "CuteAC.glb",
|
|
774
|
+
tower: "CuteACTower.glb"
|
|
775
|
+
};
|
|
776
|
+
/** 渲染器要控制的命名节点,两种形态相同 */
|
|
777
|
+
const acModelParts = {
|
|
778
|
+
/** 导风板:挂机是一整块,柜机是一组百叶(子节点) */
|
|
779
|
+
louver: "Louver",
|
|
780
|
+
/** 0.25 × 0.1、UV 为 0–1 的平面,贴温度画面 */
|
|
781
|
+
display: "Display",
|
|
782
|
+
/** 指示灯 */
|
|
783
|
+
led: "Led"
|
|
784
|
+
};
|
|
785
|
+
const acLouverMotion = {
|
|
786
|
+
wall: {
|
|
787
|
+
pivot: "node",
|
|
788
|
+
openAngle: 40
|
|
789
|
+
},
|
|
790
|
+
tower: {
|
|
791
|
+
pivot: "children",
|
|
792
|
+
openAngle: 30
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
const acAirflowAnchors = {
|
|
796
|
+
wall: {
|
|
797
|
+
offset: [
|
|
798
|
+
0,
|
|
799
|
+
-.1,
|
|
800
|
+
.05
|
|
801
|
+
],
|
|
802
|
+
extent: 1.5,
|
|
803
|
+
direction: "down"
|
|
804
|
+
},
|
|
805
|
+
tower: {
|
|
806
|
+
offset: [
|
|
807
|
+
.24,
|
|
808
|
+
0,
|
|
809
|
+
.08
|
|
810
|
+
],
|
|
811
|
+
extent: .5,
|
|
812
|
+
direction: "side"
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
/** 指示灯与显示窗的颜色:开机时是模式色,关机是灰色(与 `ACPalette` 一致) */
|
|
816
|
+
const acModelColors = {
|
|
817
|
+
cold: "#5aa9ff",
|
|
818
|
+
hot: "#ff9f5a",
|
|
819
|
+
off: "#8e97aa"
|
|
820
|
+
};
|
|
821
|
+
function getAcLedColor(state) {
|
|
822
|
+
return state.status ? acModelColors[state.mode] : acModelColors.off;
|
|
823
|
+
}
|
|
824
|
+
function isAcForm(value) {
|
|
825
|
+
return value === "wall" || value === "tower";
|
|
826
|
+
}
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/press.ts
|
|
829
|
+
/**
|
|
830
|
+
* 让按键支持长按连续触发,和真的遥控器一样:按下立即触发一次,按住一会儿后连续触发并逐渐加速,
|
|
831
|
+
* 松开、移出或取消时停止。键盘的 Enter / 空格仍走 `click`,每按一次触发一次。
|
|
832
|
+
*
|
|
833
|
+
* @param element 按键
|
|
834
|
+
* @param fire 每次触发时调用
|
|
835
|
+
* @param options 节奏与停止条件
|
|
836
|
+
* @returns 解除绑定的清理函数
|
|
837
|
+
*/
|
|
838
|
+
function bindAcPressRepeat(element, fire, options = {}) {
|
|
839
|
+
const { delay = 500, interval = 200, minInterval = 100, value } = options;
|
|
840
|
+
let timer;
|
|
841
|
+
let wait = interval;
|
|
842
|
+
let last;
|
|
843
|
+
const stop = () => {
|
|
844
|
+
clearTimeout(timer);
|
|
845
|
+
timer = void 0;
|
|
846
|
+
};
|
|
847
|
+
const tick = () => {
|
|
848
|
+
if (value && value() === last) return stop();
|
|
849
|
+
last = value?.();
|
|
850
|
+
fire();
|
|
851
|
+
wait = Math.max(minInterval, wait * .85);
|
|
852
|
+
timer = setTimeout(tick, wait);
|
|
853
|
+
};
|
|
854
|
+
const onPointerDown = (event) => {
|
|
855
|
+
if (event.button !== 0) return;
|
|
856
|
+
stop();
|
|
857
|
+
last = value?.();
|
|
858
|
+
fire();
|
|
859
|
+
wait = interval;
|
|
860
|
+
timer = setTimeout(tick, delay);
|
|
861
|
+
};
|
|
862
|
+
const onClick = (event) => {
|
|
863
|
+
if (event.detail === 0) fire();
|
|
864
|
+
};
|
|
865
|
+
const onContextMenu = (event) => event.preventDefault();
|
|
866
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
867
|
+
element.addEventListener("pointerup", stop);
|
|
868
|
+
element.addEventListener("pointerleave", stop);
|
|
869
|
+
element.addEventListener("pointercancel", stop);
|
|
870
|
+
element.addEventListener("click", onClick);
|
|
871
|
+
element.addEventListener("contextmenu", onContextMenu);
|
|
872
|
+
return () => {
|
|
873
|
+
stop();
|
|
874
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
875
|
+
element.removeEventListener("pointerup", stop);
|
|
876
|
+
element.removeEventListener("pointerleave", stop);
|
|
877
|
+
element.removeEventListener("pointercancel", stop);
|
|
878
|
+
element.removeEventListener("click", onClick);
|
|
879
|
+
element.removeEventListener("contextmenu", onContextMenu);
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
//#endregion
|
|
107
883
|
//#region src/reducer.ts
|
|
108
|
-
|
|
884
|
+
/**
|
|
885
|
+
* Merge a partial state over the defaults and clamp its temperature.
|
|
886
|
+
*
|
|
887
|
+
* @param state - Partial or stored state
|
|
888
|
+
* @param limits - Device limits; omit to keep the default 16–31 °C range
|
|
889
|
+
*/
|
|
890
|
+
function normalizeAcState(state, limits) {
|
|
891
|
+
const mode = state?.mode === "hot" ? "hot" : "cold";
|
|
892
|
+
const temperature = clampTemperature(state?.temperature ?? defaultAcState.temperature, limits);
|
|
893
|
+
const remembered = (other) => {
|
|
894
|
+
const value = state?.temperatures?.[other];
|
|
895
|
+
return typeof value === "number" && Number.isFinite(value) ? clampTemperature(value, limits) : temperature;
|
|
896
|
+
};
|
|
109
897
|
return {
|
|
110
898
|
...defaultAcState,
|
|
111
899
|
...state,
|
|
112
|
-
|
|
900
|
+
mode,
|
|
901
|
+
temperature,
|
|
902
|
+
temperatures: {
|
|
903
|
+
cold: mode === "cold" ? temperature : remembered("cold"),
|
|
904
|
+
hot: mode === "hot" ? temperature : remembered("hot")
|
|
905
|
+
},
|
|
906
|
+
sound: isAcSoundMode(state?.sound) ? state.sound : defaultAcState.sound,
|
|
907
|
+
swing: typeof state?.swing === "boolean" ? state.swing : defaultAcState.swing,
|
|
908
|
+
fan: isAcFanSpeed(state?.fan) ? state.fan : "auto"
|
|
113
909
|
};
|
|
114
910
|
}
|
|
115
|
-
|
|
116
|
-
|
|
911
|
+
/** Set the current mode's temperature, keeping `temperatures` in step. */
|
|
912
|
+
function withTemperature(state, temperature) {
|
|
913
|
+
const temperatures = {
|
|
914
|
+
cold: temperature,
|
|
915
|
+
hot: temperature,
|
|
916
|
+
...state.temperatures
|
|
917
|
+
};
|
|
918
|
+
temperatures[state.mode] = temperature;
|
|
919
|
+
return {
|
|
920
|
+
...state,
|
|
921
|
+
temperature,
|
|
922
|
+
temperatures
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Clamp a temperature into range. When `limits` are given the value also
|
|
927
|
+
* snaps to the device step, measured from `limits.min`.
|
|
928
|
+
*
|
|
929
|
+
* @param temperature - Requested temperature in Celsius
|
|
930
|
+
* @param limits - Device limits; omit to clamp to 16–31 °C without snapping
|
|
931
|
+
*/
|
|
932
|
+
function clampTemperature(temperature, limits) {
|
|
933
|
+
if (!limits) return Math.min(31, Math.max(16, temperature));
|
|
934
|
+
const { min, max, step } = limits;
|
|
935
|
+
const snapped = step > 0 ? Math.round((temperature - min) / step) * step + min : temperature;
|
|
936
|
+
return Number(Math.min(max, Math.max(min, snapped)).toFixed(4));
|
|
117
937
|
}
|
|
118
|
-
|
|
938
|
+
/**
|
|
939
|
+
* Apply an action to the state.
|
|
940
|
+
*
|
|
941
|
+
* @param state - Current state
|
|
942
|
+
* @param action - Action to apply
|
|
943
|
+
* @param limits - Device limits; increments use `limits.step`
|
|
944
|
+
*/
|
|
945
|
+
function acReducer(state, action, limits) {
|
|
946
|
+
const step = limits?.step ?? defaultAcLimits.step;
|
|
119
947
|
switch (action.type) {
|
|
120
|
-
case "increment": return
|
|
121
|
-
|
|
122
|
-
temperature: clampTemperature(state.temperature + 1)
|
|
123
|
-
};
|
|
124
|
-
case "decrement": return {
|
|
125
|
-
...state,
|
|
126
|
-
temperature: clampTemperature(state.temperature - 1)
|
|
127
|
-
};
|
|
948
|
+
case "increment": return withTemperature(state, clampTemperature(state.temperature + step, limits));
|
|
949
|
+
case "decrement": return withTemperature(state, clampTemperature(state.temperature - step, limits));
|
|
128
950
|
case "toggleStatus": return {
|
|
129
951
|
...state,
|
|
130
952
|
status: !state.status
|
|
@@ -133,36 +955,143 @@ function acReducer(state, action) {
|
|
|
133
955
|
...state,
|
|
134
956
|
status: action.status
|
|
135
957
|
};
|
|
136
|
-
case "mode":
|
|
958
|
+
case "mode": {
|
|
959
|
+
const status = state.status || action.powerOn === true;
|
|
960
|
+
if (action.mode === state.mode) return status === state.status ? state : {
|
|
961
|
+
...state,
|
|
962
|
+
status
|
|
963
|
+
};
|
|
964
|
+
const temperatures = {
|
|
965
|
+
cold: state.temperature,
|
|
966
|
+
hot: state.temperature,
|
|
967
|
+
...state.temperatures,
|
|
968
|
+
[state.mode]: state.temperature
|
|
969
|
+
};
|
|
970
|
+
return {
|
|
971
|
+
...state,
|
|
972
|
+
status,
|
|
973
|
+
mode: action.mode,
|
|
974
|
+
temperature: clampTemperature(temperatures[action.mode] ?? state.temperature, limits),
|
|
975
|
+
temperatures
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
case "sound": return {
|
|
979
|
+
...state,
|
|
980
|
+
sound: isAcSoundMode(action.sound) ? action.sound : state.sound
|
|
981
|
+
};
|
|
982
|
+
case "cycleSound": return {
|
|
983
|
+
...state,
|
|
984
|
+
sound: nextAcSoundMode(state.sound)
|
|
985
|
+
};
|
|
986
|
+
case "toggleSwing": return {
|
|
987
|
+
...state,
|
|
988
|
+
swing: !state.swing
|
|
989
|
+
};
|
|
990
|
+
case "swing": return {
|
|
991
|
+
...state,
|
|
992
|
+
swing: action.swing === true
|
|
993
|
+
};
|
|
994
|
+
case "fan": return {
|
|
995
|
+
...state,
|
|
996
|
+
fan: isAcFanSpeed(action.fan) ? action.fan : state.fan
|
|
997
|
+
};
|
|
998
|
+
case "cycleFan": return {
|
|
137
999
|
...state,
|
|
138
|
-
|
|
1000
|
+
fan: nextAcFanSpeed(state.fan)
|
|
139
1001
|
};
|
|
140
1002
|
case "update": return normalizeAcState({
|
|
141
1003
|
...state,
|
|
142
1004
|
...action.payload
|
|
143
|
-
});
|
|
1005
|
+
}, limits);
|
|
144
1006
|
default: throw new Error("Unexpected Ac Action");
|
|
145
1007
|
}
|
|
146
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* 节能建议的短文案(显示在小纸条上)与补充说明。
|
|
1011
|
+
*/
|
|
1012
|
+
function getEnergyTip(mode, temperature) {
|
|
1013
|
+
return mode === "cold" ? {
|
|
1014
|
+
message: `制冷调到 ${temperature}°C 以上更省电`,
|
|
1015
|
+
detail: "少开一度,为节能减排出一份力"
|
|
1016
|
+
} : {
|
|
1017
|
+
message: `制热调到 ${temperature}°C 以下更省电`,
|
|
1018
|
+
detail: "少开一度,为节能减排出一份力"
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* 创建一个提示「呈现器」:每个视图实例一个,记住提示过的节能建议,避免重复打扰。
|
|
1023
|
+
*/
|
|
1024
|
+
function createAcNotificationPresenter() {
|
|
1025
|
+
let lastTip;
|
|
1026
|
+
return (notification, state) => {
|
|
1027
|
+
if (notification.kind === "limit") return "shake";
|
|
1028
|
+
if (notification.kind === "setting") return "announce";
|
|
1029
|
+
const key = `${state.mode}:${state.temperature}`;
|
|
1030
|
+
if (key === lastTip) return "skip";
|
|
1031
|
+
lastTip = key;
|
|
1032
|
+
return "note";
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
147
1035
|
function getRecommendedTemperatureMessage(mode, temperature) {
|
|
148
1036
|
return `建议将空调的制${mode === "cold" ? "冷" : "热"}温度调至 ${temperature} 度以${mode === "cold" ? "上" : "下"},为节能减排贡献一份力量!`;
|
|
149
1037
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
1038
|
+
function getSoundModeMessage(sound) {
|
|
1039
|
+
return `${acSoundModeLabels[sound]}模式:${{
|
|
1040
|
+
normal: "风声和按键音恢复正常",
|
|
1041
|
+
sleep: "风声和按键音都调小了,晚安",
|
|
1042
|
+
mute: "空调不再发出任何声音"
|
|
1043
|
+
}[sound]}`;
|
|
1044
|
+
}
|
|
1045
|
+
function getFanSpeedMessage(fan) {
|
|
1046
|
+
return fan === "auto" ? "风速:自动,由空调根据室温调节" : `风速:${acFanSpeedLabels[fan]}`;
|
|
1047
|
+
}
|
|
1048
|
+
function getSwingMessage(swing) {
|
|
1049
|
+
return swing ? "扫风已开启:导风板上下摆动,出风左右扫" : "扫风已关闭:导风板停在当前位置";
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Visual tone for a state: `idle` when off, otherwise `cool` or `heat`.
|
|
1053
|
+
*
|
|
1054
|
+
* @param state - Current state
|
|
1055
|
+
*/
|
|
1056
|
+
function acTone(state) {
|
|
1057
|
+
if (!state.status) return "idle";
|
|
1058
|
+
return state.mode === "hot" ? "heat" : "cool";
|
|
1059
|
+
}
|
|
1060
|
+
function getAcActionNotification(previousState, nextState, action, limits = defaultAcLimits) {
|
|
1061
|
+
if (action.type === "increment" && previousState.temperature >= limits.max) return {
|
|
152
1062
|
message: "已经是最大温度啦!",
|
|
153
|
-
severity: "error"
|
|
1063
|
+
severity: "error",
|
|
1064
|
+
kind: "limit"
|
|
154
1065
|
};
|
|
155
|
-
if (action.type === "decrement" && previousState.temperature <=
|
|
1066
|
+
if (action.type === "decrement" && previousState.temperature <= limits.min) return {
|
|
156
1067
|
message: "已经是最小温度啦!",
|
|
157
|
-
severity: "error"
|
|
1068
|
+
severity: "error",
|
|
1069
|
+
kind: "limit"
|
|
158
1070
|
};
|
|
159
|
-
if (action.type === "
|
|
160
|
-
message:
|
|
161
|
-
severity: "
|
|
1071
|
+
if ((action.type === "sound" || action.type === "cycleSound") && nextState.sound !== previousState.sound) return {
|
|
1072
|
+
message: getSoundModeMessage(nextState.sound),
|
|
1073
|
+
severity: "info",
|
|
1074
|
+
kind: "setting"
|
|
162
1075
|
};
|
|
163
|
-
if (action.type === "
|
|
164
|
-
message:
|
|
165
|
-
severity: "
|
|
1076
|
+
if ((action.type === "fan" || action.type === "cycleFan") && nextState.fan && nextState.fan !== previousState.fan) return {
|
|
1077
|
+
message: getFanSpeedMessage(nextState.fan),
|
|
1078
|
+
severity: "info",
|
|
1079
|
+
kind: "setting"
|
|
1080
|
+
};
|
|
1081
|
+
if ((action.type === "swing" || action.type === "toggleSwing") && nextState.swing !== previousState.swing) return {
|
|
1082
|
+
message: getSwingMessage(nextState.swing),
|
|
1083
|
+
severity: "info",
|
|
1084
|
+
kind: "setting"
|
|
1085
|
+
};
|
|
1086
|
+
if (action.type === "mode" && nextState.mode === "cold" && nextState.temperature < 26) return {
|
|
1087
|
+
...getEnergyTip("cold", 26),
|
|
1088
|
+
severity: "success",
|
|
1089
|
+
kind: "tip"
|
|
1090
|
+
};
|
|
1091
|
+
if (action.type === "mode" && nextState.mode === "hot" && nextState.temperature > 20) return {
|
|
1092
|
+
...getEnergyTip("hot", 20),
|
|
1093
|
+
severity: "success",
|
|
1094
|
+
kind: "tip"
|
|
166
1095
|
};
|
|
167
1096
|
}
|
|
168
1097
|
//#endregion
|
|
@@ -211,11 +1140,12 @@ function createAirConditionerStore(options = {}) {
|
|
|
211
1140
|
const storageKey = options.storageKey ?? "ac:state";
|
|
212
1141
|
const storage = options.storage === void 0 ? getBrowserStorage() : options.storage;
|
|
213
1142
|
const persistedState = readAcState(storage, storageKey);
|
|
1143
|
+
const limits = options.limits;
|
|
214
1144
|
let state = normalizeAcState({
|
|
215
1145
|
...defaultAcState,
|
|
216
1146
|
...persistedState,
|
|
217
1147
|
...options.initialState
|
|
218
|
-
});
|
|
1148
|
+
}, limits);
|
|
219
1149
|
const listeners = /* @__PURE__ */ new Set();
|
|
220
1150
|
return {
|
|
221
1151
|
getState() {
|
|
@@ -223,8 +1153,8 @@ function createAirConditionerStore(options = {}) {
|
|
|
223
1153
|
},
|
|
224
1154
|
dispatch(action) {
|
|
225
1155
|
const previousState = state;
|
|
226
|
-
const nextState = acReducer(state, action);
|
|
227
|
-
const notification = getAcActionNotification(previousState, nextState, action);
|
|
1156
|
+
const nextState = acReducer(state, action, limits);
|
|
1157
|
+
const notification = getAcActionNotification(previousState, nextState, action, limits);
|
|
228
1158
|
const event = {
|
|
229
1159
|
action,
|
|
230
1160
|
previousState,
|
|
@@ -246,24 +1176,360 @@ function createAirConditionerStore(options = {}) {
|
|
|
246
1176
|
};
|
|
247
1177
|
}
|
|
248
1178
|
//#endregion
|
|
1179
|
+
//#region src/thermal/levels.ts
|
|
1180
|
+
const heatLevels = [
|
|
1181
|
+
"nominal",
|
|
1182
|
+
"fair",
|
|
1183
|
+
"serious",
|
|
1184
|
+
"critical"
|
|
1185
|
+
];
|
|
1186
|
+
/** Default Celsius thresholds for fair, serious and critical. */
|
|
1187
|
+
const defaultHeatThresholds = [
|
|
1188
|
+
60,
|
|
1189
|
+
75,
|
|
1190
|
+
90
|
|
1191
|
+
];
|
|
1192
|
+
/**
|
|
1193
|
+
* @param level - A heat level
|
|
1194
|
+
* @returns Its rank from 0 (nominal) to 3 (critical)
|
|
1195
|
+
*/
|
|
1196
|
+
function heatLevelRank(level) {
|
|
1197
|
+
return heatLevels.indexOf(level);
|
|
1198
|
+
}
|
|
1199
|
+
function isHeatLevel(value) {
|
|
1200
|
+
return heatLevels.includes(value);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Convert a temperature to a heat level. With a previous level, the value
|
|
1204
|
+
* must fall `hysteresis` degrees below a threshold before dropping a level,
|
|
1205
|
+
* which keeps readings near a threshold from flickering.
|
|
1206
|
+
*
|
|
1207
|
+
* @param celsius - Measured temperature
|
|
1208
|
+
* @param thresholds - Lower bounds for fair, serious and critical
|
|
1209
|
+
* @param previous - Level from the previous reading
|
|
1210
|
+
* @param hysteresis - Degrees required below a threshold to step down
|
|
1211
|
+
*/
|
|
1212
|
+
function celsiusToHeatLevel(celsius, thresholds = defaultHeatThresholds, previous, hysteresis = 5) {
|
|
1213
|
+
const raw = thresholds.filter((threshold) => celsius >= threshold).length;
|
|
1214
|
+
if (previous === void 0) return heatLevels[raw];
|
|
1215
|
+
const held = thresholds.filter((threshold) => celsius >= threshold - hysteresis).length;
|
|
1216
|
+
return heatLevels[Math.max(raw, Math.min(heatLevelRank(previous), held))];
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Create a filter that raises the level immediately but only lowers it after
|
|
1220
|
+
* lower readings persist for `dwellMs`, measured with `reading.at`.
|
|
1221
|
+
*
|
|
1222
|
+
* @param options - Governor options
|
|
1223
|
+
* @param options.dwellMs - Time a lower level must persist; defaults to 30 seconds
|
|
1224
|
+
* @returns A function that maps each reading to the settled level
|
|
1225
|
+
*/
|
|
1226
|
+
function createHeatGovernor(options = {}) {
|
|
1227
|
+
const dwellMs = options.dwellMs ?? 3e4;
|
|
1228
|
+
let current;
|
|
1229
|
+
let lowerSince;
|
|
1230
|
+
return (reading) => {
|
|
1231
|
+
if (current === void 0 || heatLevelRank(reading.level) >= heatLevelRank(current)) {
|
|
1232
|
+
current = reading.level;
|
|
1233
|
+
lowerSince = void 0;
|
|
1234
|
+
return current;
|
|
1235
|
+
}
|
|
1236
|
+
lowerSince ??= reading.at;
|
|
1237
|
+
if (reading.at - lowerSince >= dwellMs) {
|
|
1238
|
+
current = reading.level;
|
|
1239
|
+
lowerSince = void 0;
|
|
1240
|
+
}
|
|
1241
|
+
return current;
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/thermal/plan.ts
|
|
1246
|
+
const coolingByLevel = {
|
|
1247
|
+
fair: {
|
|
1248
|
+
temperature: 26,
|
|
1249
|
+
airflow: .4
|
|
1250
|
+
},
|
|
1251
|
+
serious: {
|
|
1252
|
+
temperature: 24,
|
|
1253
|
+
airflow: .7
|
|
1254
|
+
},
|
|
1255
|
+
critical: {
|
|
1256
|
+
temperature: 22,
|
|
1257
|
+
airflow: 1
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
/**
|
|
1261
|
+
* Decide how the virtual air conditioner should respond to a heat level.
|
|
1262
|
+
*
|
|
1263
|
+
* @param level - Settled heat level
|
|
1264
|
+
* @param limits - Device limits used to clamp the target temperature
|
|
1265
|
+
* @returns A cooling plan, or `null` at `nominal` so the user keeps control
|
|
1266
|
+
*/
|
|
1267
|
+
function planCooling(level, limits) {
|
|
1268
|
+
if (level === "nominal") return null;
|
|
1269
|
+
const { temperature, airflow } = coolingByLevel[level];
|
|
1270
|
+
return {
|
|
1271
|
+
status: true,
|
|
1272
|
+
mode: "cold",
|
|
1273
|
+
temperature: clampTemperature(temperature, limits),
|
|
1274
|
+
airflow
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/thermal/pressure.ts
|
|
1279
|
+
function getPressureObserver() {
|
|
1280
|
+
return globalThis.PressureObserver;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Heat source backed by the browser Compute Pressure API. It reports CPU
|
|
1284
|
+
* load levels, not temperatures; browsers expose no CPU temperature.
|
|
1285
|
+
*
|
|
1286
|
+
* Supported in Chromium-based browsers on secure origins. The API is only
|
|
1287
|
+
* touched when the source is started.
|
|
1288
|
+
*
|
|
1289
|
+
* @param options - Sampling and error handling options
|
|
1290
|
+
*/
|
|
1291
|
+
function createComputePressureSource(options = {}) {
|
|
1292
|
+
return {
|
|
1293
|
+
id: "compute-pressure",
|
|
1294
|
+
isSupported: () => typeof getPressureObserver() === "function",
|
|
1295
|
+
start(listener) {
|
|
1296
|
+
const Observer = getPressureObserver();
|
|
1297
|
+
if (!Observer) return () => {};
|
|
1298
|
+
let stopped = false;
|
|
1299
|
+
const observer = new Observer((records) => {
|
|
1300
|
+
const record = records.at(-1);
|
|
1301
|
+
if (stopped || !record || !isHeatLevel(record.state)) return;
|
|
1302
|
+
listener({
|
|
1303
|
+
level: record.state,
|
|
1304
|
+
source: "compute-pressure",
|
|
1305
|
+
at: Date.now()
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
observer.observe("cpu", { sampleInterval: options.sampleIntervalMs ?? 2e3 }).catch((error) => {
|
|
1309
|
+
if (!stopped) options.onError?.(error);
|
|
1310
|
+
});
|
|
1311
|
+
return () => {
|
|
1312
|
+
stopped = true;
|
|
1313
|
+
observer.disconnect();
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
//#endregion
|
|
1319
|
+
//#region src/icons.ts
|
|
1320
|
+
/**
|
|
1321
|
+
* 遥控器按钮与提示图标(24 × 24 viewBox 的 SVG path),全部来自 Material Design
|
|
1322
|
+
* Icons,和站点通过 UnoCSS presetIcons 使用的 i-mdi-* 是同一套。
|
|
1323
|
+
*/
|
|
1324
|
+
const acIconPaths = {
|
|
1325
|
+
/** mdi:snowflake */
|
|
1326
|
+
cold: "m20.79 13.95l-2.33.62l-2-1.13v-2.88l2-1.13l2.33.62l.52-1.93l-1.77-.47l.46-1.77l-1.93-.52l-.62 2.33l-2 1.13L13 7.38V5.12l1.71-1.71L13.29 2L12 3.29L10.71 2L9.29 3.41L11 5.12v2.26L8.5 8.82l-2-1.13l-.58-2.33L4 5.88l.47 1.77l-1.77.47l.52 1.93l2.33-.62l2 1.13v2.89l-2 1.13l-2.33-.62l-.52 1.93l1.77.47L4 18.12l1.93.52l.62-2.33l2-1.13L11 16.62v2.26l-1.71 1.71L10.71 22L12 20.71L13.29 22l1.41-1.41l-1.7-1.71v-2.26l2.5-1.45l2 1.13l.62 2.33l1.88-.51l-.47-1.77l1.77-.47zM9.5 10.56L12 9.11l2.5 1.45v2.88L12 14.89l-2.5-1.45z",
|
|
1327
|
+
/** mdi:white-balance-sunny */
|
|
1328
|
+
hot: "m3.55 19.09l1.41 1.41l1.8-1.79l-1.42-1.42M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6c0-3.32-2.69-6-6-6m8 7h3v-2h-3m-2.76 7.71l1.8 1.79l1.41-1.41l-1.79-1.8M20.45 5l-1.41-1.4l-1.8 1.79l1.42 1.42M13 1h-2v3h2M6.76 5.39L4.96 3.6L3.55 5l1.79 1.81zM1 13h3v-2H1m12 9h-2v3h2",
|
|
1329
|
+
/** mdi:power */
|
|
1330
|
+
power: "m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2",
|
|
1331
|
+
/** mdi:plus */
|
|
1332
|
+
plus: "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z",
|
|
1333
|
+
/** mdi:minus */
|
|
1334
|
+
minus: "M19 13H5v-2h14z",
|
|
1335
|
+
/** mdi:menu-up */
|
|
1336
|
+
increase: "m7 15l5-5l5 5z",
|
|
1337
|
+
/** mdi:menu-down */
|
|
1338
|
+
decrease: "m7 10l5 5l5-5z",
|
|
1339
|
+
/** mdi:fan */
|
|
1340
|
+
fan: "M12 11a1 1 0 0 0-1 1a1 1 0 0 0 1 1a1 1 0 0 0 1-1a1 1 0 0 0-1-1m.5-9c4.5 0 4.61 3.57 2.25 4.75c-.99.49-1.43 1.54-1.62 2.47c.48.2.9.51 1.22.91c3.7-2 7.68-1.21 7.68 2.37c0 4.5-3.57 4.6-4.75 2.23c-.5-.99-1.56-1.43-2.49-1.62c-.2.48-.51.89-.91 1.23c1.99 3.69 1.2 7.66-2.38 7.66c-4.5 0-4.59-3.58-2.23-4.76c.98-.49 1.42-1.53 1.62-2.45c-.49-.2-.92-.52-1.24-.92C5.96 15.85 2 15.07 2 11.5C2 7 5.56 6.89 6.74 9.26c.5.99 1.55 1.42 2.48 1.61c.19-.48.51-.9.92-1.22C8.15 5.96 8.94 2 12.5 2",
|
|
1341
|
+
/** mdi:arrow-oscillating */
|
|
1342
|
+
swing: "M6 14h3l-4 4l-4-4h3c0-2.7 1.7-7.4 7-7.9v2c-3.4.5-5 3.8-5 5.9m14 0c0-2.7-1.7-7.4-7-7.9v2c3.4.6 5 3.8 5 5.9h-3l4 4l4-4z",
|
|
1343
|
+
/** mdi:volume-high */
|
|
1344
|
+
soundNormal: "M14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.84-5 6.7v2.07c4-.91 7-4.49 7-8.77s-3-7.86-7-8.77M16.5 12c0-1.77-1-3.29-2.5-4.03V16c1.5-.71 2.5-2.24 2.5-4M3 9v6h4l5 5V4L7 9z",
|
|
1345
|
+
/** mdi:weather-night */
|
|
1346
|
+
soundSleep: "m17.75 4.09l-2.53 1.94l.91 3.06l-2.63-1.81l-2.63 1.81l.91-3.06l-2.53-1.94L12.44 4l1.06-3l1.06 3zm3.5 6.91l-1.64 1.25l.59 1.98l-1.7-1.17l-1.7 1.17l.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85c-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14c.4-.4.82-.76 1.27-1.08c.75-.53 1.93.36 1.85 1.19c-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82c-2.81 3.14-2.7 7.96.31 10.98c3.02 3.01 7.84 3.12 10.98.31",
|
|
1347
|
+
/** mdi:volume-off */
|
|
1348
|
+
soundMute: "M12 4L9.91 6.09L12 8.18M4.27 3L3 4.27L7.73 9H3v6h4l5 5v-6.73l4.25 4.26c-.67.51-1.42.93-2.25 1.17v2.07c1.38-.32 2.63-.95 3.68-1.81L19.73 21L21 19.73l-9-9M19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.9 8.9 0 0 0 21 12c0-4.28-3-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71m-2.5 0c0-1.77-1-3.29-2.5-4.03v2.21l2.45 2.45c.05-.2.05-.42.05-.63",
|
|
1349
|
+
/** mdi:leaf */
|
|
1350
|
+
leaf: "M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66l.95-2.3c.48.17.98.3 1.34.3C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8"
|
|
1351
|
+
};
|
|
1352
|
+
//#endregion
|
|
1353
|
+
//#region src/ui.ts
|
|
1354
|
+
/**
|
|
1355
|
+
* 声音键在各声音模式下显示的图标。
|
|
1356
|
+
*/
|
|
1357
|
+
const acSoundIcons = {
|
|
1358
|
+
normal: "soundNormal",
|
|
1359
|
+
sleep: "soundSleep",
|
|
1360
|
+
mute: "soundMute"
|
|
1361
|
+
};
|
|
1362
|
+
/**
|
|
1363
|
+
* 遥控器小屏的读屏文字与各按键的悬停提示,保证各框架文案一致。
|
|
1364
|
+
*/
|
|
1365
|
+
function getAcRemoteText(state) {
|
|
1366
|
+
const swing = `扫风:${state.swing ? "开" : "关"}`;
|
|
1367
|
+
const sound = `声音:${acSoundModeLabels[state.sound]}`;
|
|
1368
|
+
const fan = `风速:${acFanSpeedLabels[state.fan ?? "auto"]}`;
|
|
1369
|
+
return {
|
|
1370
|
+
display: `${state.status ? "运行" : "待机"},${acModeLabels[state.mode]} ${state.temperature}°C,${fan},${swing},${sound}`,
|
|
1371
|
+
power: `电源:${state.status ? "开" : "关"}`,
|
|
1372
|
+
mode: acModeLabels,
|
|
1373
|
+
fan,
|
|
1374
|
+
swing,
|
|
1375
|
+
sound
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* 能效标签的点阵与等级条布局:蓝白背景的「中国能效标识」,带能效信息码
|
|
1380
|
+
* (二维码),样式依据《房间空气调节器能源效率标识实施规则》(CEL 010—2019)。
|
|
1381
|
+
*
|
|
1382
|
+
* 等级按现行 GB 21455-2019 分 5 级,1 级最高。GB 21455 的修订版征求意见稿
|
|
1383
|
+
* (2026 年 5 月)拟改为 3 级、以全年动态能源消耗效率(DAPF)分级;正式发布并
|
|
1384
|
+
* 实施后,再更新 `grades`、`standard` 与 `levels`。
|
|
1385
|
+
*
|
|
1386
|
+
* - `top`:标识名称(白点),右侧是能效信息码
|
|
1387
|
+
* - 等级区:首行是 1 级绿色条和指示箭头,`levels` 是 2–5 级
|
|
1388
|
+
* - `detail`:生产者、型号、APF 等参数(黑点)
|
|
1389
|
+
* - `footer`:依据的国家标准编号(白点)
|
|
1390
|
+
*/
|
|
1391
|
+
const acEnergyLabel = {
|
|
1392
|
+
grade: 1,
|
|
1393
|
+
grades: 5,
|
|
1394
|
+
standard: "GB 21455-2019",
|
|
1395
|
+
top: [{
|
|
1396
|
+
num: 4,
|
|
1397
|
+
size: 4,
|
|
1398
|
+
mx: 1.5,
|
|
1399
|
+
my: 0,
|
|
1400
|
+
color: "white"
|
|
1401
|
+
}],
|
|
1402
|
+
detail: [
|
|
1403
|
+
{
|
|
1404
|
+
num: 11,
|
|
1405
|
+
size: 2,
|
|
1406
|
+
mx: .8,
|
|
1407
|
+
my: 2,
|
|
1408
|
+
color: "black"
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
num: 9,
|
|
1412
|
+
size: 1.5,
|
|
1413
|
+
mx: .8,
|
|
1414
|
+
my: 2,
|
|
1415
|
+
color: "black"
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
num: 10,
|
|
1419
|
+
size: 1.2,
|
|
1420
|
+
mx: .8,
|
|
1421
|
+
my: 0,
|
|
1422
|
+
color: "black"
|
|
1423
|
+
}
|
|
1424
|
+
],
|
|
1425
|
+
footer: {
|
|
1426
|
+
num: 8,
|
|
1427
|
+
size: 2,
|
|
1428
|
+
mx: .8,
|
|
1429
|
+
my: 0,
|
|
1430
|
+
color: "white"
|
|
1431
|
+
},
|
|
1432
|
+
levels: [
|
|
1433
|
+
{
|
|
1434
|
+
background: "lightgreen",
|
|
1435
|
+
width: "50%"
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
background: "#fdd835",
|
|
1439
|
+
width: "60%"
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
background: "orange",
|
|
1443
|
+
width: "70%"
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
background: "red",
|
|
1447
|
+
width: "80%"
|
|
1448
|
+
}
|
|
1449
|
+
]
|
|
1450
|
+
};
|
|
1451
|
+
/**
|
|
1452
|
+
* 能效标签的读屏文字。
|
|
1453
|
+
*/
|
|
1454
|
+
function getAcEnergyLabelText() {
|
|
1455
|
+
return `中国能效标识:能效等级 ${acEnergyLabel.grade} 级(共 ${acEnergyLabel.grades} 级),依据 ${acEnergyLabel.standard}`;
|
|
1456
|
+
}
|
|
1457
|
+
//#endregion
|
|
1458
|
+
exports.acAirflowAnchors = acAirflowAnchors;
|
|
249
1459
|
exports.acAudioFileNames = acAudioFileNames;
|
|
1460
|
+
exports.acDripIntervalRange = acDripIntervalRange;
|
|
1461
|
+
exports.acDripMaxTemperature = acDripMaxTemperature;
|
|
1462
|
+
exports.acEnergyLabel = acEnergyLabel;
|
|
1463
|
+
exports.acFanBars = acFanBars;
|
|
1464
|
+
exports.acFanSoundLevels = acFanSoundLevels;
|
|
1465
|
+
exports.acFanSpeedLabels = acFanSpeedLabels;
|
|
1466
|
+
exports.acFanSpeeds = acFanSpeeds;
|
|
1467
|
+
exports.acFormLabels = acFormLabels;
|
|
1468
|
+
exports.acForms = acForms;
|
|
1469
|
+
exports.acIconPaths = acIconPaths;
|
|
1470
|
+
exports.acLouverMotion = acLouverMotion;
|
|
1471
|
+
exports.acMaxTilt = acMaxTilt;
|
|
250
1472
|
exports.acModeIcons = acModeIcons;
|
|
1473
|
+
exports.acModeLabels = acModeLabels;
|
|
1474
|
+
exports.acModelColors = acModelColors;
|
|
1475
|
+
exports.acModelFiles = acModelFiles;
|
|
1476
|
+
exports.acModelParts = acModelParts;
|
|
1477
|
+
exports.acMotion = acMotion;
|
|
1478
|
+
exports.acReducedMotionDuration = acReducedMotionDuration;
|
|
1479
|
+
exports.acReducedMotionQuery = acReducedMotionQuery;
|
|
251
1480
|
exports.acReducer = acReducer;
|
|
1481
|
+
exports.acShellSize = acShellSize;
|
|
1482
|
+
exports.acSoundIcons = acSoundIcons;
|
|
1483
|
+
exports.acSoundModeLabels = acSoundModeLabels;
|
|
1484
|
+
exports.acSoundModes = acSoundModes;
|
|
1485
|
+
exports.acSoundVolumes = acSoundVolumes;
|
|
252
1486
|
exports.acStorageKey = acStorageKey;
|
|
1487
|
+
exports.acTone = acTone;
|
|
1488
|
+
exports.bindAcPressRepeat = bindAcPressRepeat;
|
|
1489
|
+
exports.celsiusToHeatLevel = celsiusToHeatLevel;
|
|
253
1490
|
exports.clampTemperature = clampTemperature;
|
|
254
1491
|
exports.createAcAudioController = createAcAudioController;
|
|
1492
|
+
exports.createAcNotificationPresenter = createAcNotificationPresenter;
|
|
255
1493
|
exports.createAirConditionerStore = createAirConditionerStore;
|
|
1494
|
+
exports.createComputePressureSource = createComputePressureSource;
|
|
1495
|
+
exports.createHeatGovernor = createHeatGovernor;
|
|
256
1496
|
exports.createMemoryStorage = createMemoryStorage;
|
|
1497
|
+
exports.defaultAcLimits = defaultAcLimits;
|
|
257
1498
|
exports.defaultAcState = defaultAcState;
|
|
1499
|
+
exports.defaultHeatThresholds = defaultHeatThresholds;
|
|
258
1500
|
exports.getAcActionNotification = getAcActionNotification;
|
|
1501
|
+
exports.getAcCondensationStyle = getAcCondensationStyle;
|
|
1502
|
+
exports.getAcDripInterval = getAcDripInterval;
|
|
1503
|
+
exports.getAcEnergyLabelText = getAcEnergyLabelText;
|
|
1504
|
+
exports.getAcLedColor = getAcLedColor;
|
|
1505
|
+
exports.getAcMist = getAcMist;
|
|
1506
|
+
exports.getAcRemoteText = getAcRemoteText;
|
|
1507
|
+
exports.getAcScale = getAcScale;
|
|
259
1508
|
exports.getBrowserStorage = getBrowserStorage;
|
|
1509
|
+
exports.getEnergyTip = getEnergyTip;
|
|
1510
|
+
exports.getFanSpeedMessage = getFanSpeedMessage;
|
|
260
1511
|
exports.getRecommendedTemperatureMessage = getRecommendedTemperatureMessage;
|
|
1512
|
+
exports.getSoundModeMessage = getSoundModeMessage;
|
|
1513
|
+
exports.getSwingMessage = getSwingMessage;
|
|
1514
|
+
exports.heatLevelRank = heatLevelRank;
|
|
1515
|
+
exports.heatLevels = heatLevels;
|
|
1516
|
+
exports.isAcFanSpeed = isAcFanSpeed;
|
|
1517
|
+
exports.isAcForm = isAcForm;
|
|
1518
|
+
exports.isAcSoundMode = isAcSoundMode;
|
|
1519
|
+
exports.isHeatLevel = isHeatLevel;
|
|
261
1520
|
exports.maxTemperature = maxTemperature;
|
|
262
1521
|
exports.minTemperature = minTemperature;
|
|
1522
|
+
exports.nextAcFanSpeed = nextAcFanSpeed;
|
|
1523
|
+
exports.nextAcSoundMode = nextAcSoundMode;
|
|
263
1524
|
exports.normalizeAcState = normalizeAcState;
|
|
1525
|
+
exports.observeAcScale = observeAcScale;
|
|
1526
|
+
exports.observeAcTilt = observeAcTilt;
|
|
1527
|
+
exports.planCooling = planCooling;
|
|
1528
|
+
exports.prefersReducedMotion = prefersReducedMotion;
|
|
264
1529
|
exports.readAcState = readAcState;
|
|
265
1530
|
exports.recommendedColdTemperature = recommendedColdTemperature;
|
|
266
1531
|
exports.recommendedHotTemperature = recommendedHotTemperature;
|
|
1532
|
+
exports.renderAcMotionCssVars = renderAcMotionCssVars;
|
|
267
1533
|
exports.resolveAcAudioAssets = resolveAcAudioAssets;
|
|
268
1534
|
exports.soundTimings = soundTimings;
|
|
269
1535
|
exports.writeAcState = writeAcState;
|