@yunlefun-home/air-conditioner-core 0.3.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.js CHANGED
@@ -32,7 +32,13 @@ const defaultAcState = {
32
32
  mode: "cold",
33
33
  status: false,
34
34
  temperature: 26,
35
- sound: "normal"
35
+ temperatures: {
36
+ cold: 26,
37
+ hot: 26
38
+ },
39
+ sound: "normal",
40
+ swing: false,
41
+ fan: "auto"
36
42
  };
37
43
  /**
38
44
  * 遥控器「声音」键依次切换的顺序。
@@ -42,6 +48,13 @@ const acSoundModes = [
42
48
  "sleep",
43
49
  "mute"
44
50
  ];
51
+ /**
52
+ * 各模式的中文名称(遥控器小屏与按键提示共用)。
53
+ */
54
+ const acModeLabels = {
55
+ cold: "制冷",
56
+ hot: "制热"
57
+ };
45
58
  const acSoundModeLabels = {
46
59
  normal: "标准",
47
60
  sleep: "睡眠",
@@ -55,6 +68,48 @@ const acSoundVolumes = {
55
68
  sleep: .3,
56
69
  mute: 0
57
70
  };
71
+ /**
72
+ * 遥控器「风速」键依次切换的顺序。
73
+ */
74
+ const acFanSpeeds = [
75
+ "auto",
76
+ "low",
77
+ "medium",
78
+ "high"
79
+ ];
80
+ const acFanSpeedLabels = {
81
+ auto: "自动",
82
+ low: "低风",
83
+ medium: "中风",
84
+ high: "高风"
85
+ };
86
+ /**
87
+ * 小屏上点亮的风速格数(1–3)。自动风速由空调自己决定,显示为 0 并另加「自动」标记。
88
+ */
89
+ const acFanBars = {
90
+ auto: 0,
91
+ low: 1,
92
+ medium: 2,
93
+ high: 3
94
+ };
95
+ /**
96
+ * 固定风速下风声的相对音量(0–1);自动风速沿用开机后的「达标 / 发力」循环。
97
+ */
98
+ const acFanSoundLevels = {
99
+ low: .45,
100
+ medium: .7,
101
+ high: 1
102
+ };
103
+ function isAcFanSpeed(value) {
104
+ return acFanSpeeds.includes(value);
105
+ }
106
+ /**
107
+ * 风速键的下一档:自动 → 低 → 中 → 高 → 自动。
108
+ */
109
+ function nextAcFanSpeed(fan) {
110
+ const index = Math.max(acFanSpeeds.indexOf(fan), 0);
111
+ return acFanSpeeds[(index + 1) % acFanSpeeds.length];
112
+ }
58
113
  function isAcSoundMode(value) {
59
114
  return acSoundModes.includes(value);
60
115
  }
@@ -126,6 +181,25 @@ function createDefaultAudioContext() {
126
181
  return AudioContextConstructor ? new AudioContextConstructor() : null;
127
182
  }
128
183
  /**
184
+ * 合成一声很轻的「嗒」:音高快速下滑的正弦波,短促起音、指数衰减,像水珠落在窗台上。
185
+ */
186
+ function playDrip(context, output) {
187
+ const start = context.currentTime;
188
+ const oscillator = context.createOscillator();
189
+ const gain = context.createGain();
190
+ oscillator.type = "sine";
191
+ oscillator.frequency.setValueAtTime(1500, start);
192
+ oscillator.frequency.exponentialRampToValueAtTime(520, start + .07);
193
+ gain.gain.setValueAtTime(1e-4, start);
194
+ gain.gain.exponentialRampToValueAtTime(.12, start + .004);
195
+ gain.gain.exponentialRampToValueAtTime(1e-4, start + .16);
196
+ oscillator.connect(gain);
197
+ gain.connect(output);
198
+ oscillator.start(start);
199
+ oscillator.stop(start + .18);
200
+ oscillator.onended = () => gain.disconnect();
201
+ }
202
+ /**
129
203
  * 等功率淡入 / 淡出曲线,噪声类声音交叉淡化时中段不会变小。
130
204
  */
131
205
  function fadeCurve(direction) {
@@ -164,6 +238,11 @@ function stopElement(element) {
164
238
  function createAcAudioController(elements, options = {}) {
165
239
  const random = options.random ?? Math.random;
166
240
  let soundMode = options.soundMode ?? "normal";
241
+ let fanSpeed = options.fanSpeed ?? "auto";
242
+ /** 开机后风声循环已经接上(启动声结束) */
243
+ let fanRunning = false;
244
+ /** 每次重启「达标 / 发力」循环自增,丢弃旧循环的回调 */
245
+ let fanCycle = 0;
167
246
  /** undefined:尚未尝试;null:不可用 */
168
247
  let engine;
169
248
  let run;
@@ -298,20 +377,32 @@ function createAcAudioController(elements, options = {}) {
298
377
  gain.linearRampToValueAtTime(level, now + rampMs / 1e3);
299
378
  } else tweenFallbackFan(level, rampMs);
300
379
  }
380
+ /** 风声循环刚接上时的音量:自动风速从满音量开始,固定风速保持各自的音量。 */
381
+ function initialFanLevel() {
382
+ return fanSpeed === "auto" ? 1 : acFanSoundLevels[fanSpeed];
383
+ }
301
384
  /**
302
- * 室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
385
+ * 自动风速:室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
386
+ * 固定风速不走这个循环。
303
387
  */
304
388
  function startFanCycle() {
389
+ fanRunning = true;
390
+ const cycle = ++fanCycle;
391
+ if (fanSpeed !== "auto") return;
392
+ const current = () => cycle === fanCycle && fanSpeed === "auto";
305
393
  const quiet = () => {
306
394
  later(() => {
395
+ if (!current()) return;
307
396
  setFanLevel(soundTimings.surgeLevel, soundTimings.surgeRampMs);
308
397
  later(() => {
398
+ if (!current()) return;
309
399
  setFanLevel(soundTimings.settleLevel, soundTimings.surgeRampMs);
310
400
  quiet();
311
401
  }, randomBetween(soundTimings.surgeDurationMs, random));
312
402
  }, randomBetween(soundTimings.quietDurationMs, random));
313
403
  };
314
404
  later(() => {
405
+ if (!current()) return;
315
406
  setFanLevel(soundTimings.settleLevel, soundTimings.settleRampMs);
316
407
  quiet();
317
408
  }, soundTimings.settleAfterMs);
@@ -320,6 +411,7 @@ function createAcAudioController(elements, options = {}) {
320
411
  const output = web.context.createGain();
321
412
  output.connect(web.master);
322
413
  const fan = web.context.createGain();
414
+ fan.gain.value = initialFanLevel();
323
415
  fan.connect(output);
324
416
  const target = {
325
417
  output,
@@ -360,7 +452,7 @@ function createAcAudioController(elements, options = {}) {
360
452
  applyElementVolume(work, 0);
361
453
  if (work.loop !== void 0) work.loop = true;
362
454
  playElement(work);
363
- tweenFallbackFan(1, soundTimings.loopCrossfadeMs);
455
+ tweenFallbackFan(initialFanLevel(), soundTimings.loopCrossfadeMs);
364
456
  startFanCycle();
365
457
  }, soundTimings.startDelayMs);
366
458
  }
@@ -371,6 +463,8 @@ function createAcAudioController(elements, options = {}) {
371
463
  }
372
464
  function powerOn() {
373
465
  const id = ++session;
466
+ fanRunning = false;
467
+ fanCycle++;
374
468
  clearTimers();
375
469
  cancelFallbackTween();
376
470
  stopWeb(0);
@@ -389,6 +483,8 @@ function createAcAudioController(elements, options = {}) {
389
483
  }
390
484
  function powerOff() {
391
485
  session++;
486
+ fanRunning = false;
487
+ fanCycle++;
392
488
  clearTimers();
393
489
  stopWeb(soundTimings.stopFadeMs);
394
490
  stopFallback(soundTimings.stopFadeMs);
@@ -407,6 +503,13 @@ function createAcAudioController(elements, options = {}) {
407
503
  applyElementVolume(elements.beep);
408
504
  playElement(elements.beep);
409
505
  },
506
+ drip() {
507
+ if (soundMode !== "normal") return;
508
+ const web = ensureEngine();
509
+ if (!web || typeof web.context.createOscillator !== "function") return;
510
+ resume(web);
511
+ playDrip(web.context, web.master);
512
+ },
410
513
  toggleStatusAudio(previousStatus) {
411
514
  if (previousStatus) powerOff();
412
515
  else powerOn();
@@ -418,6 +521,21 @@ function createAcAudioController(elements, options = {}) {
418
521
  applyElementVolume(elements.start);
419
522
  applyElementVolume(elements.work, fallbackFanLevel);
420
523
  },
524
+ setFanSpeed(fan) {
525
+ if (fan === fanSpeed) return;
526
+ fanSpeed = fan;
527
+ if (!fanRunning) {
528
+ if (run) run.fan.gain.value = initialFanLevel();
529
+ return;
530
+ }
531
+ if (fan === "auto") {
532
+ setFanLevel(1, soundTimings.loopCrossfadeMs);
533
+ startFanCycle();
534
+ } else {
535
+ fanCycle++;
536
+ setFanLevel(acFanSoundLevels[fan], soundTimings.loopCrossfadeMs);
537
+ }
538
+ },
421
539
  dispose() {
422
540
  session++;
423
541
  clearTimers();
@@ -430,6 +548,54 @@ function createAcAudioController(elements, options = {}) {
430
548
  };
431
549
  }
432
550
  //#endregion
551
+ //#region src/condensation.ts
552
+ /** 风速越大,雾气越浓、冷凝水越快 */
553
+ const fanFactor = {
554
+ auto: .8,
555
+ low: .55,
556
+ medium: .8,
557
+ high: 1
558
+ };
559
+ /** 高于这个设定温度不滴水:冷凝水不该常见,只在开得很冷时偶尔出现 */
560
+ const acDripMaxTemperature = 22;
561
+ /** 最冷(16 °C)与 {@link acDripMaxTemperature} 时,两滴水之间的间隔(ms) */
562
+ const acDripIntervalRange = [9e3, 18e3];
563
+ /**
564
+ * 出风口雾气的浓度(#70),0–1:只在制冷开机时出现,设定温度越低、风越大越浓。
565
+ *
566
+ * 视图把它写进 CSS 变量 `--acw-mist`,3D 等其他渲染器也可以按它控制粒子数量。
567
+ */
568
+ function getAcMist(state) {
569
+ if (!state.status || state.mode !== "cold") return 0;
570
+ const cold = .2 + .8 * (31 - state.temperature) / 15;
571
+ const mist = Math.min(1, Math.max(.2, cold)) * fanFactor[state.fan ?? "auto"];
572
+ return Math.round(mist * 100) / 100;
573
+ }
574
+ /**
575
+ * 冷凝水滴落的间隔(#21,ms):只在制冷开机、设定温度不高于 {@link acDripMaxTemperature} 时滴水,
576
+ * 越冷越频繁,高风再快一些;不滴水时返回 `undefined`。
577
+ *
578
+ * 视图把它写进 CSS 变量 `--acw-drip-cycle`,水珠在出风口边缘慢慢聚起再落下,开机后要等一整个间隔才滴第一滴。
579
+ */
580
+ function getAcDripInterval(state) {
581
+ if (!state.status || state.mode !== "cold" || state.temperature > 22) return void 0;
582
+ const [fastest, slowest] = acDripIntervalRange;
583
+ const progress = (state.temperature - 16) / 6;
584
+ const interval = fastest + (slowest - fastest) * Math.min(1, Math.max(0, progress));
585
+ return Math.round(interval * (state.fan === "high" ? .8 : 1));
586
+ }
587
+ /**
588
+ * 视图要写到空调根元素上的雾气 / 滴水样式变量与属性。
589
+ */
590
+ function getAcCondensationStyle(state) {
591
+ const interval = getAcDripInterval(state);
592
+ return {
593
+ mist: getAcMist(state),
594
+ dripCycle: interval === void 0 ? void 0 : `${interval}ms`,
595
+ drip: interval === void 0 ? "off" : "on"
596
+ };
597
+ }
598
+ //#endregion
433
599
  //#region src/motion.ts
434
600
  /**
435
601
  * 动效 token:各框架视图层共用的时长与缓动曲线。
@@ -448,7 +614,9 @@ const acMotion = {
448
614
  /** 导风板、出风淡入淡出 */
449
615
  slow: 420,
450
616
  /** 出风气流单次循环 */
451
- flow: 1800
617
+ flow: 1800,
618
+ /** 扫风:左右摆动一个来回 */
619
+ swing: 5200
452
620
  },
453
621
  easing: {
454
622
  /** 常规状态切换 */
@@ -470,7 +638,8 @@ const acReducedMotionDuration = {
470
638
  fast: 80,
471
639
  base: 120,
472
640
  slow: 120,
473
- flow: 0
641
+ flow: 0,
642
+ swing: 0
474
643
  };
475
644
  const acReducedMotionQuery = "(prefers-reduced-motion: reduce)";
476
645
  /**
@@ -592,6 +761,124 @@ function observeAcTilt(target, onTilt, maxTilt = 4) {
592
761
  };
593
762
  }
594
763
  //#endregion
764
+ //#region src/model.ts
765
+ const acForms = ["wall", "tower"];
766
+ const acFormLabels = {
767
+ wall: "挂机",
768
+ tower: "柜机"
769
+ };
770
+ /** 网页用的模型文件名(站点的 `public/models/` 下) */
771
+ const acModelFiles = {
772
+ wall: "CuteAC.glb",
773
+ tower: "CuteACTower.glb"
774
+ };
775
+ /** 渲染器要控制的命名节点,两种形态相同 */
776
+ const acModelParts = {
777
+ /** 导风板:挂机是一整块,柜机是一组百叶(子节点) */
778
+ louver: "Louver",
779
+ /** 0.25 × 0.1、UV 为 0–1 的平面,贴温度画面 */
780
+ display: "Display",
781
+ /** 指示灯 */
782
+ led: "Led"
783
+ };
784
+ const acLouverMotion = {
785
+ wall: {
786
+ pivot: "node",
787
+ openAngle: 40
788
+ },
789
+ tower: {
790
+ pivot: "children",
791
+ openAngle: 30
792
+ }
793
+ };
794
+ const acAirflowAnchors = {
795
+ wall: {
796
+ offset: [
797
+ 0,
798
+ -.1,
799
+ .05
800
+ ],
801
+ extent: 1.5,
802
+ direction: "down"
803
+ },
804
+ tower: {
805
+ offset: [
806
+ .24,
807
+ 0,
808
+ .08
809
+ ],
810
+ extent: .5,
811
+ direction: "side"
812
+ }
813
+ };
814
+ /** 指示灯与显示窗的颜色:开机时是模式色,关机是灰色(与 `ACPalette` 一致) */
815
+ const acModelColors = {
816
+ cold: "#5aa9ff",
817
+ hot: "#ff9f5a",
818
+ off: "#8e97aa"
819
+ };
820
+ function getAcLedColor(state) {
821
+ return state.status ? acModelColors[state.mode] : acModelColors.off;
822
+ }
823
+ function isAcForm(value) {
824
+ return value === "wall" || value === "tower";
825
+ }
826
+ //#endregion
827
+ //#region src/press.ts
828
+ /**
829
+ * 让按键支持长按连续触发,和真的遥控器一样:按下立即触发一次,按住一会儿后连续触发并逐渐加速,
830
+ * 松开、移出或取消时停止。键盘的 Enter / 空格仍走 `click`,每按一次触发一次。
831
+ *
832
+ * @param element 按键
833
+ * @param fire 每次触发时调用
834
+ * @param options 节奏与停止条件
835
+ * @returns 解除绑定的清理函数
836
+ */
837
+ function bindAcPressRepeat(element, fire, options = {}) {
838
+ const { delay = 500, interval = 200, minInterval = 100, value } = options;
839
+ let timer;
840
+ let wait = interval;
841
+ let last;
842
+ const stop = () => {
843
+ clearTimeout(timer);
844
+ timer = void 0;
845
+ };
846
+ const tick = () => {
847
+ if (value && value() === last) return stop();
848
+ last = value?.();
849
+ fire();
850
+ wait = Math.max(minInterval, wait * .85);
851
+ timer = setTimeout(tick, wait);
852
+ };
853
+ const onPointerDown = (event) => {
854
+ if (event.button !== 0) return;
855
+ stop();
856
+ last = value?.();
857
+ fire();
858
+ wait = interval;
859
+ timer = setTimeout(tick, delay);
860
+ };
861
+ const onClick = (event) => {
862
+ if (event.detail === 0) fire();
863
+ };
864
+ const onContextMenu = (event) => event.preventDefault();
865
+ element.addEventListener("pointerdown", onPointerDown);
866
+ element.addEventListener("pointerup", stop);
867
+ element.addEventListener("pointerleave", stop);
868
+ element.addEventListener("pointercancel", stop);
869
+ element.addEventListener("click", onClick);
870
+ element.addEventListener("contextmenu", onContextMenu);
871
+ return () => {
872
+ stop();
873
+ element.removeEventListener("pointerdown", onPointerDown);
874
+ element.removeEventListener("pointerup", stop);
875
+ element.removeEventListener("pointerleave", stop);
876
+ element.removeEventListener("pointercancel", stop);
877
+ element.removeEventListener("click", onClick);
878
+ element.removeEventListener("contextmenu", onContextMenu);
879
+ };
880
+ }
881
+ //#endregion
595
882
  //#region src/reducer.ts
596
883
  /**
597
884
  * Merge a partial state over the defaults and clamp its temperature.
@@ -600,11 +887,38 @@ function observeAcTilt(target, onTilt, maxTilt = 4) {
600
887
  * @param limits - Device limits; omit to keep the default 16–31 °C range
601
888
  */
602
889
  function normalizeAcState(state, limits) {
890
+ const mode = state?.mode === "hot" ? "hot" : "cold";
891
+ const temperature = clampTemperature(state?.temperature ?? defaultAcState.temperature, limits);
892
+ const remembered = (other) => {
893
+ const value = state?.temperatures?.[other];
894
+ return typeof value === "number" && Number.isFinite(value) ? clampTemperature(value, limits) : temperature;
895
+ };
603
896
  return {
604
897
  ...defaultAcState,
605
898
  ...state,
606
- temperature: clampTemperature(state?.temperature ?? defaultAcState.temperature, limits),
607
- sound: isAcSoundMode(state?.sound) ? state.sound : defaultAcState.sound
899
+ mode,
900
+ temperature,
901
+ temperatures: {
902
+ cold: mode === "cold" ? temperature : remembered("cold"),
903
+ hot: mode === "hot" ? temperature : remembered("hot")
904
+ },
905
+ sound: isAcSoundMode(state?.sound) ? state.sound : defaultAcState.sound,
906
+ swing: typeof state?.swing === "boolean" ? state.swing : defaultAcState.swing,
907
+ fan: isAcFanSpeed(state?.fan) ? state.fan : "auto"
908
+ };
909
+ }
910
+ /** Set the current mode's temperature, keeping `temperatures` in step. */
911
+ function withTemperature(state, temperature) {
912
+ const temperatures = {
913
+ cold: temperature,
914
+ hot: temperature,
915
+ ...state.temperatures
916
+ };
917
+ temperatures[state.mode] = temperature;
918
+ return {
919
+ ...state,
920
+ temperature,
921
+ temperatures
608
922
  };
609
923
  }
610
924
  /**
@@ -630,14 +944,8 @@ function clampTemperature(temperature, limits) {
630
944
  function acReducer(state, action, limits) {
631
945
  const step = limits?.step ?? defaultAcLimits.step;
632
946
  switch (action.type) {
633
- case "increment": return {
634
- ...state,
635
- temperature: clampTemperature(state.temperature + step, limits)
636
- };
637
- case "decrement": return {
638
- ...state,
639
- temperature: clampTemperature(state.temperature - step, limits)
640
- };
947
+ case "increment": return withTemperature(state, clampTemperature(state.temperature + step, limits));
948
+ case "decrement": return withTemperature(state, clampTemperature(state.temperature - step, limits));
641
949
  case "toggleStatus": return {
642
950
  ...state,
643
951
  status: !state.status
@@ -646,10 +954,26 @@ function acReducer(state, action, limits) {
646
954
  ...state,
647
955
  status: action.status
648
956
  };
649
- case "mode": return {
650
- ...state,
651
- mode: action.mode
652
- };
957
+ case "mode": {
958
+ const status = state.status || action.powerOn === true;
959
+ if (action.mode === state.mode) return status === state.status ? state : {
960
+ ...state,
961
+ status
962
+ };
963
+ const temperatures = {
964
+ cold: state.temperature,
965
+ hot: state.temperature,
966
+ ...state.temperatures,
967
+ [state.mode]: state.temperature
968
+ };
969
+ return {
970
+ ...state,
971
+ status,
972
+ mode: action.mode,
973
+ temperature: clampTemperature(temperatures[action.mode] ?? state.temperature, limits),
974
+ temperatures
975
+ };
976
+ }
653
977
  case "sound": return {
654
978
  ...state,
655
979
  sound: isAcSoundMode(action.sound) ? action.sound : state.sound
@@ -658,6 +982,22 @@ function acReducer(state, action, limits) {
658
982
  ...state,
659
983
  sound: nextAcSoundMode(state.sound)
660
984
  };
985
+ case "toggleSwing": return {
986
+ ...state,
987
+ swing: !state.swing
988
+ };
989
+ case "swing": return {
990
+ ...state,
991
+ swing: action.swing === true
992
+ };
993
+ case "fan": return {
994
+ ...state,
995
+ fan: isAcFanSpeed(action.fan) ? action.fan : state.fan
996
+ };
997
+ case "cycleFan": return {
998
+ ...state,
999
+ fan: nextAcFanSpeed(state.fan)
1000
+ };
661
1001
  case "update": return normalizeAcState({
662
1002
  ...state,
663
1003
  ...action.payload
@@ -665,6 +1005,32 @@ function acReducer(state, action, limits) {
665
1005
  default: throw new Error("Unexpected Ac Action");
666
1006
  }
667
1007
  }
1008
+ /**
1009
+ * 节能建议的短文案(显示在小纸条上)与补充说明。
1010
+ */
1011
+ function getEnergyTip(mode, temperature) {
1012
+ return mode === "cold" ? {
1013
+ message: `制冷调到 ${temperature}°C 以上更省电`,
1014
+ detail: "少开一度,为节能减排出一份力"
1015
+ } : {
1016
+ message: `制热调到 ${temperature}°C 以下更省电`,
1017
+ detail: "少开一度,为节能减排出一份力"
1018
+ };
1019
+ }
1020
+ /**
1021
+ * 创建一个提示「呈现器」:每个视图实例一个,记住提示过的节能建议,避免重复打扰。
1022
+ */
1023
+ function createAcNotificationPresenter() {
1024
+ let lastTip;
1025
+ return (notification, state) => {
1026
+ if (notification.kind === "limit") return "shake";
1027
+ if (notification.kind === "setting") return "announce";
1028
+ const key = `${state.mode}:${state.temperature}`;
1029
+ if (key === lastTip) return "skip";
1030
+ lastTip = key;
1031
+ return "note";
1032
+ };
1033
+ }
668
1034
  function getRecommendedTemperatureMessage(mode, temperature) {
669
1035
  return `建议将空调的制${mode === "cold" ? "冷" : "热"}温度调至 ${temperature} 度以${mode === "cold" ? "上" : "下"},为节能减排贡献一份力量!`;
670
1036
  }
@@ -675,6 +1041,12 @@ function getSoundModeMessage(sound) {
675
1041
  mute: "空调不再发出任何声音"
676
1042
  }[sound]}`;
677
1043
  }
1044
+ function getFanSpeedMessage(fan) {
1045
+ return fan === "auto" ? "风速:自动,由空调根据室温调节" : `风速:${acFanSpeedLabels[fan]}`;
1046
+ }
1047
+ function getSwingMessage(swing) {
1048
+ return swing ? "扫风已开启:导风板上下摆动,出风左右扫" : "扫风已关闭:导风板停在当前位置";
1049
+ }
678
1050
  /**
679
1051
  * Visual tone for a state: `idle` when off, otherwise `cool` or `heat`.
680
1052
  *
@@ -687,23 +1059,38 @@ function acTone(state) {
687
1059
  function getAcActionNotification(previousState, nextState, action, limits = defaultAcLimits) {
688
1060
  if (action.type === "increment" && previousState.temperature >= limits.max) return {
689
1061
  message: "已经是最大温度啦!",
690
- severity: "error"
1062
+ severity: "error",
1063
+ kind: "limit"
691
1064
  };
692
1065
  if (action.type === "decrement" && previousState.temperature <= limits.min) return {
693
1066
  message: "已经是最小温度啦!",
694
- severity: "error"
1067
+ severity: "error",
1068
+ kind: "limit"
695
1069
  };
696
1070
  if ((action.type === "sound" || action.type === "cycleSound") && nextState.sound !== previousState.sound) return {
697
1071
  message: getSoundModeMessage(nextState.sound),
698
- severity: "info"
1072
+ severity: "info",
1073
+ kind: "setting"
699
1074
  };
700
- if (action.type === "mode" && nextState.mode === "cold" && previousState.temperature < 26) return {
701
- message: getRecommendedTemperatureMessage("cold", 26),
702
- severity: "success"
1075
+ if ((action.type === "fan" || action.type === "cycleFan") && nextState.fan && nextState.fan !== previousState.fan) return {
1076
+ message: getFanSpeedMessage(nextState.fan),
1077
+ severity: "info",
1078
+ kind: "setting"
703
1079
  };
704
- if (action.type === "mode" && nextState.mode === "hot" && previousState.temperature > 20) return {
705
- message: getRecommendedTemperatureMessage("hot", 20),
706
- severity: "success"
1080
+ if ((action.type === "swing" || action.type === "toggleSwing") && nextState.swing !== previousState.swing) return {
1081
+ message: getSwingMessage(nextState.swing),
1082
+ severity: "info",
1083
+ kind: "setting"
1084
+ };
1085
+ if (action.type === "mode" && nextState.mode === "cold" && nextState.temperature < 26) return {
1086
+ ...getEnergyTip("cold", 26),
1087
+ severity: "success",
1088
+ kind: "tip"
1089
+ };
1090
+ if (action.type === "mode" && nextState.mode === "hot" && nextState.temperature > 20) return {
1091
+ ...getEnergyTip("hot", 20),
1092
+ severity: "success",
1093
+ kind: "tip"
707
1094
  };
708
1095
  }
709
1096
  //#endregion
@@ -928,24 +1315,41 @@ function createComputePressureSource(options = {}) {
928
1315
  };
929
1316
  }
930
1317
  //#endregion
931
- //#region src/ui.ts
1318
+ //#region src/icons.ts
932
1319
  /**
933
- * 各框架视图层共用的界面数据:遥控器图标与能效标签的点阵布局。
934
- * 保证 Vue / React / Web Component 渲染出完全一致的空调外观。
935
- */
936
- /**
937
- * 遥控器按钮图标(24 × 24 viewBox 的 SVG path)。
1320
+ * 遥控器按钮与提示图标(24 × 24 viewBox 的 SVG path),全部来自 Material Design
1321
+ * Icons,和站点通过 UnoCSS presetIcons 使用的 i-mdi-* 是同一套。
938
1322
  */
939
1323
  const acIconPaths = {
940
- cold: "m22 11h-4.17l3.24-3.24l-1.41-1.42L15 11h-2V9l4.66-4.66l-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93L6.34 4.34L11 9v2H9L4.34 6.34L2.93 7.76L6.17 11H2v2h4.17l-3.24 3.24l1.41 1.42L9 13h2v2l-4.66 4.66l1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24l1.42-1.41L13 15v-2h2l4.66 4.66l1.41-1.42L17.83 13H22z",
941
- power: "M13 3h-2v10h2zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12a7 7 0 0 1-14 0a6.92 6.92 0 0 1 2.58-5.42L6.17 5.17A9 9 0 1 0 21 12a8.94 8.94 0 0 0-3.17-6.83",
942
- hot: "M12 7a5 5 0 1 0 5 5a5 5 0 0 0-5-5m0-5a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0V3a1 1 0 0 0-1-1m0 17a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0v-2a1 1 0 0 0-1-1M5.64 6.05L4.22 4.64a1 1 0 0 0-1.41 1.41l1.41 1.42a1 1 0 0 0 1.42 0a1 1 0 0 0 0-1.42M21 11h-2a1 1 0 0 0 0 2h2a1 1 0 0 0 0-2M5 12a1 1 0 0 0-1-1H2a1 1 0 0 0 0 2h2a1 1 0 0 0 1-1m13.36-5.95a1 1 0 0 0 .7-.29l1.42-1.42a1 1 0 1 0-1.41-1.41l-1.42 1.41a1 1 0 0 0 0 1.42a1 1 0 0 0 .71.29m0 11.9a1 1 0 0 0-1.42 1.41l1.42 1.42a1 1 0 0 0 1.41 0a1 1 0 0 0 0-1.41ZM5.64 17.95l-1.42 1.42a1 1 0 0 0 0 1.41a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0-1.41-1.41",
943
- increase: "m12 8l6 8H6z",
944
- decrease: "m12 16l-6-8h12z",
945
- soundNormal: "M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1m13.5 2A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02M14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85",
946
- soundSleep: "M11.65 3.46c.27-.71-.36-1.45-1.12-1.34c-5.52.8-9.47 6.07-8.34 11.88c.78 4.02 4.09 7.21 8.14 7.87c3.74.61 7.16-.87 9.32-3.44c.48-.57.19-1.48-.55-1.62c-6.02-1.15-9.68-7.54-7.45-13.35",
947
- soundMute: "M3.63 3.63a.996.996 0 0 0 0 1.41L7.29 8.7L7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91c-.36.15-.58.53-.58.92c0 .72.73 1.18 1.39.91c.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 1 0 1.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0M19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87c0-3.83-2.4-7.11-5.78-8.4c-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12m-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7M16.5 12A4.5 4.5 0 0 0 14 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24"
1324
+ /** mdi:snowflake */
1325
+ 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",
1326
+ /** mdi:white-balance-sunny */
1327
+ 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",
1328
+ /** mdi:power */
1329
+ 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",
1330
+ /** mdi:plus */
1331
+ plus: "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z",
1332
+ /** mdi:minus */
1333
+ minus: "M19 13H5v-2h14z",
1334
+ /** mdi:menu-up */
1335
+ increase: "m7 15l5-5l5 5z",
1336
+ /** mdi:menu-down */
1337
+ decrease: "m7 10l5 5l5-5z",
1338
+ /** mdi:fan */
1339
+ 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",
1340
+ /** mdi:arrow-oscillating */
1341
+ 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",
1342
+ /** mdi:volume-high */
1343
+ 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",
1344
+ /** mdi:weather-night */
1345
+ 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",
1346
+ /** mdi:volume-off */
1347
+ 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",
1348
+ /** mdi:leaf */
1349
+ 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"
948
1350
  };
1351
+ //#endregion
1352
+ //#region src/ui.ts
949
1353
  /**
950
1354
  * 声音键在各声音模式下显示的图标。
951
1355
  */
@@ -955,13 +1359,42 @@ const acSoundIcons = {
955
1359
  mute: "soundMute"
956
1360
  };
957
1361
  /**
958
- * 能效标签的点阵与等级条布局。
1362
+ * 遥控器小屏的读屏文字与各按键的悬停提示,保证各框架文案一致。
1363
+ */
1364
+ function getAcRemoteText(state) {
1365
+ const swing = `扫风:${state.swing ? "开" : "关"}`;
1366
+ const sound = `声音:${acSoundModeLabels[state.sound]}`;
1367
+ const fan = `风速:${acFanSpeedLabels[state.fan ?? "auto"]}`;
1368
+ return {
1369
+ display: `${state.status ? "运行" : "待机"},${acModeLabels[state.mode]} ${state.temperature}°C,${fan},${swing},${sound}`,
1370
+ power: `电源:${state.status ? "开" : "关"}`,
1371
+ mode: acModeLabels,
1372
+ fan,
1373
+ swing,
1374
+ sound
1375
+ };
1376
+ }
1377
+ /**
1378
+ * 能效标签的点阵与等级条布局:蓝白背景的「中国能效标识」,带能效信息码
1379
+ * (二维码),样式依据《房间空气调节器能源效率标识实施规则》(CEL 010—2019)。
1380
+ *
1381
+ * 等级按现行 GB 21455-2019 分 5 级,1 级最高。GB 21455 的修订版征求意见稿
1382
+ * (2026 年 5 月)拟改为 3 级、以全年动态能源消耗效率(DAPF)分级;正式发布并
1383
+ * 实施后,再更新 `grades`、`standard` 与 `levels`。
1384
+ *
1385
+ * - `top`:标识名称(白点),右侧是能效信息码
1386
+ * - 等级区:首行是 1 级绿色条和指示箭头,`levels` 是 2–5 级
1387
+ * - `detail`:生产者、型号、APF 等参数(黑点)
1388
+ * - `footer`:依据的国家标准编号(白点)
959
1389
  */
960
1390
  const acEnergyLabel = {
1391
+ grade: 1,
1392
+ grades: 5,
1393
+ standard: "GB 21455-2019",
961
1394
  top: [{
962
- num: 6,
1395
+ num: 4,
963
1396
  size: 4,
964
- mx: 2,
1397
+ mx: 1.5,
965
1398
  my: 0,
966
1399
  color: "white"
967
1400
  }],
@@ -1001,7 +1434,7 @@ const acEnergyLabel = {
1001
1434
  width: "50%"
1002
1435
  },
1003
1436
  {
1004
- background: "#ffc107",
1437
+ background: "#fdd835",
1005
1438
  width: "60%"
1006
1439
  },
1007
1440
  {
@@ -1014,5 +1447,11 @@ const acEnergyLabel = {
1014
1447
  }
1015
1448
  ]
1016
1449
  };
1450
+ /**
1451
+ * 能效标签的读屏文字。
1452
+ */
1453
+ function getAcEnergyLabelText() {
1454
+ return `中国能效标识:能效等级 ${acEnergyLabel.grade} 级(共 ${acEnergyLabel.grades} 级),依据 ${acEnergyLabel.standard}`;
1455
+ }
1017
1456
  //#endregion
1018
- export { acAudioFileNames, acEnergyLabel, acIconPaths, acMaxTilt, acModeIcons, acMotion, acReducedMotionDuration, acReducedMotionQuery, acReducer, acShellSize, acSoundIcons, acSoundModeLabels, acSoundModes, acSoundVolumes, acStorageKey, acTone, celsiusToHeatLevel, clampTemperature, createAcAudioController, createAirConditionerStore, createComputePressureSource, createHeatGovernor, createMemoryStorage, defaultAcLimits, defaultAcState, defaultHeatThresholds, getAcActionNotification, getAcScale, getBrowserStorage, getRecommendedTemperatureMessage, getSoundModeMessage, heatLevelRank, heatLevels, isAcSoundMode, isHeatLevel, maxTemperature, minTemperature, nextAcSoundMode, normalizeAcState, observeAcScale, observeAcTilt, planCooling, prefersReducedMotion, readAcState, recommendedColdTemperature, recommendedHotTemperature, renderAcMotionCssVars, resolveAcAudioAssets, soundTimings, writeAcState };
1457
+ export { acAirflowAnchors, acAudioFileNames, acDripIntervalRange, acDripMaxTemperature, acEnergyLabel, acFanBars, acFanSoundLevels, acFanSpeedLabels, acFanSpeeds, acFormLabels, acForms, acIconPaths, acLouverMotion, acMaxTilt, acModeIcons, acModeLabels, acModelColors, acModelFiles, acModelParts, acMotion, acReducedMotionDuration, acReducedMotionQuery, acReducer, acShellSize, acSoundIcons, acSoundModeLabels, acSoundModes, acSoundVolumes, acStorageKey, acTone, bindAcPressRepeat, celsiusToHeatLevel, clampTemperature, createAcAudioController, createAcNotificationPresenter, createAirConditionerStore, createComputePressureSource, createHeatGovernor, createMemoryStorage, defaultAcLimits, defaultAcState, defaultHeatThresholds, getAcActionNotification, getAcCondensationStyle, getAcDripInterval, getAcEnergyLabelText, getAcLedColor, getAcMist, getAcRemoteText, getAcScale, getBrowserStorage, getEnergyTip, getFanSpeedMessage, getRecommendedTemperatureMessage, getSoundModeMessage, getSwingMessage, heatLevelRank, heatLevels, isAcFanSpeed, isAcForm, isAcSoundMode, isHeatLevel, maxTemperature, minTemperature, nextAcFanSpeed, nextAcSoundMode, normalizeAcState, observeAcScale, observeAcTilt, planCooling, prefersReducedMotion, readAcState, recommendedColdTemperature, recommendedHotTemperature, renderAcMotionCssVars, resolveAcAudioAssets, soundTimings, writeAcState };