@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.cjs CHANGED
@@ -33,7 +33,13 @@ const defaultAcState = {
33
33
  mode: "cold",
34
34
  status: false,
35
35
  temperature: 26,
36
- sound: "normal"
36
+ temperatures: {
37
+ cold: 26,
38
+ hot: 26
39
+ },
40
+ sound: "normal",
41
+ swing: false,
42
+ fan: "auto"
37
43
  };
38
44
  /**
39
45
  * 遥控器「声音」键依次切换的顺序。
@@ -43,6 +49,13 @@ const acSoundModes = [
43
49
  "sleep",
44
50
  "mute"
45
51
  ];
52
+ /**
53
+ * 各模式的中文名称(遥控器小屏与按键提示共用)。
54
+ */
55
+ const acModeLabels = {
56
+ cold: "制冷",
57
+ hot: "制热"
58
+ };
46
59
  const acSoundModeLabels = {
47
60
  normal: "标准",
48
61
  sleep: "睡眠",
@@ -56,6 +69,48 @@ const acSoundVolumes = {
56
69
  sleep: .3,
57
70
  mute: 0
58
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
+ }
59
114
  function isAcSoundMode(value) {
60
115
  return acSoundModes.includes(value);
61
116
  }
@@ -127,6 +182,25 @@ function createDefaultAudioContext() {
127
182
  return AudioContextConstructor ? new AudioContextConstructor() : null;
128
183
  }
129
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
+ /**
130
204
  * 等功率淡入 / 淡出曲线,噪声类声音交叉淡化时中段不会变小。
131
205
  */
132
206
  function fadeCurve(direction) {
@@ -165,6 +239,11 @@ function stopElement(element) {
165
239
  function createAcAudioController(elements, options = {}) {
166
240
  const random = options.random ?? Math.random;
167
241
  let soundMode = options.soundMode ?? "normal";
242
+ let fanSpeed = options.fanSpeed ?? "auto";
243
+ /** 开机后风声循环已经接上(启动声结束) */
244
+ let fanRunning = false;
245
+ /** 每次重启「达标 / 发力」循环自增,丢弃旧循环的回调 */
246
+ let fanCycle = 0;
168
247
  /** undefined:尚未尝试;null:不可用 */
169
248
  let engine;
170
249
  let run;
@@ -299,20 +378,32 @@ function createAcAudioController(elements, options = {}) {
299
378
  gain.linearRampToValueAtTime(level, now + rampMs / 1e3);
300
379
  } else tweenFallbackFan(level, rampMs);
301
380
  }
381
+ /** 风声循环刚接上时的音量:自动风速从满音量开始,固定风速保持各自的音量。 */
382
+ function initialFanLevel() {
383
+ return fanSpeed === "auto" ? 1 : acFanSoundLevels[fanSpeed];
384
+ }
302
385
  /**
303
- * 室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
386
+ * 自动风速:室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
387
+ * 固定风速不走这个循环。
304
388
  */
305
389
  function startFanCycle() {
390
+ fanRunning = true;
391
+ const cycle = ++fanCycle;
392
+ if (fanSpeed !== "auto") return;
393
+ const current = () => cycle === fanCycle && fanSpeed === "auto";
306
394
  const quiet = () => {
307
395
  later(() => {
396
+ if (!current()) return;
308
397
  setFanLevel(soundTimings.surgeLevel, soundTimings.surgeRampMs);
309
398
  later(() => {
399
+ if (!current()) return;
310
400
  setFanLevel(soundTimings.settleLevel, soundTimings.surgeRampMs);
311
401
  quiet();
312
402
  }, randomBetween(soundTimings.surgeDurationMs, random));
313
403
  }, randomBetween(soundTimings.quietDurationMs, random));
314
404
  };
315
405
  later(() => {
406
+ if (!current()) return;
316
407
  setFanLevel(soundTimings.settleLevel, soundTimings.settleRampMs);
317
408
  quiet();
318
409
  }, soundTimings.settleAfterMs);
@@ -321,6 +412,7 @@ function createAcAudioController(elements, options = {}) {
321
412
  const output = web.context.createGain();
322
413
  output.connect(web.master);
323
414
  const fan = web.context.createGain();
415
+ fan.gain.value = initialFanLevel();
324
416
  fan.connect(output);
325
417
  const target = {
326
418
  output,
@@ -361,7 +453,7 @@ function createAcAudioController(elements, options = {}) {
361
453
  applyElementVolume(work, 0);
362
454
  if (work.loop !== void 0) work.loop = true;
363
455
  playElement(work);
364
- tweenFallbackFan(1, soundTimings.loopCrossfadeMs);
456
+ tweenFallbackFan(initialFanLevel(), soundTimings.loopCrossfadeMs);
365
457
  startFanCycle();
366
458
  }, soundTimings.startDelayMs);
367
459
  }
@@ -372,6 +464,8 @@ function createAcAudioController(elements, options = {}) {
372
464
  }
373
465
  function powerOn() {
374
466
  const id = ++session;
467
+ fanRunning = false;
468
+ fanCycle++;
375
469
  clearTimers();
376
470
  cancelFallbackTween();
377
471
  stopWeb(0);
@@ -390,6 +484,8 @@ function createAcAudioController(elements, options = {}) {
390
484
  }
391
485
  function powerOff() {
392
486
  session++;
487
+ fanRunning = false;
488
+ fanCycle++;
393
489
  clearTimers();
394
490
  stopWeb(soundTimings.stopFadeMs);
395
491
  stopFallback(soundTimings.stopFadeMs);
@@ -408,6 +504,13 @@ function createAcAudioController(elements, options = {}) {
408
504
  applyElementVolume(elements.beep);
409
505
  playElement(elements.beep);
410
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);
513
+ },
411
514
  toggleStatusAudio(previousStatus) {
412
515
  if (previousStatus) powerOff();
413
516
  else powerOn();
@@ -419,6 +522,21 @@ function createAcAudioController(elements, options = {}) {
419
522
  applyElementVolume(elements.start);
420
523
  applyElementVolume(elements.work, fallbackFanLevel);
421
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();
535
+ } else {
536
+ fanCycle++;
537
+ setFanLevel(acFanSoundLevels[fan], soundTimings.loopCrossfadeMs);
538
+ }
539
+ },
422
540
  dispose() {
423
541
  session++;
424
542
  clearTimers();
@@ -431,6 +549,54 @@ function createAcAudioController(elements, options = {}) {
431
549
  };
432
550
  }
433
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
434
600
  //#region src/motion.ts
435
601
  /**
436
602
  * 动效 token:各框架视图层共用的时长与缓动曲线。
@@ -449,7 +615,9 @@ const acMotion = {
449
615
  /** 导风板、出风淡入淡出 */
450
616
  slow: 420,
451
617
  /** 出风气流单次循环 */
452
- flow: 1800
618
+ flow: 1800,
619
+ /** 扫风:左右摆动一个来回 */
620
+ swing: 5200
453
621
  },
454
622
  easing: {
455
623
  /** 常规状态切换 */
@@ -471,7 +639,8 @@ const acReducedMotionDuration = {
471
639
  fast: 80,
472
640
  base: 120,
473
641
  slow: 120,
474
- flow: 0
642
+ flow: 0,
643
+ swing: 0
475
644
  };
476
645
  const acReducedMotionQuery = "(prefers-reduced-motion: reduce)";
477
646
  /**
@@ -593,6 +762,124 @@ function observeAcTilt(target, onTilt, maxTilt = 4) {
593
762
  };
594
763
  }
595
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
596
883
  //#region src/reducer.ts
597
884
  /**
598
885
  * Merge a partial state over the defaults and clamp its temperature.
@@ -601,11 +888,38 @@ function observeAcTilt(target, onTilt, maxTilt = 4) {
601
888
  * @param limits - Device limits; omit to keep the default 16–31 °C range
602
889
  */
603
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
+ };
604
897
  return {
605
898
  ...defaultAcState,
606
899
  ...state,
607
- temperature: clampTemperature(state?.temperature ?? defaultAcState.temperature, limits),
608
- sound: isAcSoundMode(state?.sound) ? state.sound : defaultAcState.sound
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"
909
+ };
910
+ }
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
609
923
  };
610
924
  }
611
925
  /**
@@ -631,14 +945,8 @@ function clampTemperature(temperature, limits) {
631
945
  function acReducer(state, action, limits) {
632
946
  const step = limits?.step ?? defaultAcLimits.step;
633
947
  switch (action.type) {
634
- case "increment": return {
635
- ...state,
636
- temperature: clampTemperature(state.temperature + step, limits)
637
- };
638
- case "decrement": return {
639
- ...state,
640
- temperature: clampTemperature(state.temperature - step, limits)
641
- };
948
+ case "increment": return withTemperature(state, clampTemperature(state.temperature + step, limits));
949
+ case "decrement": return withTemperature(state, clampTemperature(state.temperature - step, limits));
642
950
  case "toggleStatus": return {
643
951
  ...state,
644
952
  status: !state.status
@@ -647,10 +955,26 @@ function acReducer(state, action, limits) {
647
955
  ...state,
648
956
  status: action.status
649
957
  };
650
- case "mode": return {
651
- ...state,
652
- mode: action.mode
653
- };
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
+ }
654
978
  case "sound": return {
655
979
  ...state,
656
980
  sound: isAcSoundMode(action.sound) ? action.sound : state.sound
@@ -659,6 +983,22 @@ function acReducer(state, action, limits) {
659
983
  ...state,
660
984
  sound: nextAcSoundMode(state.sound)
661
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 {
999
+ ...state,
1000
+ fan: nextAcFanSpeed(state.fan)
1001
+ };
662
1002
  case "update": return normalizeAcState({
663
1003
  ...state,
664
1004
  ...action.payload
@@ -666,6 +1006,32 @@ function acReducer(state, action, limits) {
666
1006
  default: throw new Error("Unexpected Ac Action");
667
1007
  }
668
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
+ }
669
1035
  function getRecommendedTemperatureMessage(mode, temperature) {
670
1036
  return `建议将空调的制${mode === "cold" ? "冷" : "热"}温度调至 ${temperature} 度以${mode === "cold" ? "上" : "下"},为节能减排贡献一份力量!`;
671
1037
  }
@@ -676,6 +1042,12 @@ function getSoundModeMessage(sound) {
676
1042
  mute: "空调不再发出任何声音"
677
1043
  }[sound]}`;
678
1044
  }
1045
+ function getFanSpeedMessage(fan) {
1046
+ return fan === "auto" ? "风速:自动,由空调根据室温调节" : `风速:${acFanSpeedLabels[fan]}`;
1047
+ }
1048
+ function getSwingMessage(swing) {
1049
+ return swing ? "扫风已开启:导风板上下摆动,出风左右扫" : "扫风已关闭:导风板停在当前位置";
1050
+ }
679
1051
  /**
680
1052
  * Visual tone for a state: `idle` when off, otherwise `cool` or `heat`.
681
1053
  *
@@ -688,23 +1060,38 @@ function acTone(state) {
688
1060
  function getAcActionNotification(previousState, nextState, action, limits = defaultAcLimits) {
689
1061
  if (action.type === "increment" && previousState.temperature >= limits.max) return {
690
1062
  message: "已经是最大温度啦!",
691
- severity: "error"
1063
+ severity: "error",
1064
+ kind: "limit"
692
1065
  };
693
1066
  if (action.type === "decrement" && previousState.temperature <= limits.min) return {
694
1067
  message: "已经是最小温度啦!",
695
- severity: "error"
1068
+ severity: "error",
1069
+ kind: "limit"
696
1070
  };
697
1071
  if ((action.type === "sound" || action.type === "cycleSound") && nextState.sound !== previousState.sound) return {
698
1072
  message: getSoundModeMessage(nextState.sound),
699
- severity: "info"
1073
+ severity: "info",
1074
+ kind: "setting"
700
1075
  };
701
- if (action.type === "mode" && nextState.mode === "cold" && previousState.temperature < 26) return {
702
- message: getRecommendedTemperatureMessage("cold", 26),
703
- severity: "success"
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"
704
1080
  };
705
- if (action.type === "mode" && nextState.mode === "hot" && previousState.temperature > 20) return {
706
- message: getRecommendedTemperatureMessage("hot", 20),
707
- severity: "success"
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"
708
1095
  };
709
1096
  }
710
1097
  //#endregion
@@ -929,24 +1316,41 @@ function createComputePressureSource(options = {}) {
929
1316
  };
930
1317
  }
931
1318
  //#endregion
932
- //#region src/ui.ts
1319
+ //#region src/icons.ts
933
1320
  /**
934
- * 各框架视图层共用的界面数据:遥控器图标与能效标签的点阵布局。
935
- * 保证 Vue / React / Web Component 渲染出完全一致的空调外观。
936
- */
937
- /**
938
- * 遥控器按钮图标(24 × 24 viewBox 的 SVG path)。
1321
+ * 遥控器按钮与提示图标(24 × 24 viewBox 的 SVG path),全部来自 Material Design
1322
+ * Icons,和站点通过 UnoCSS presetIcons 使用的 i-mdi-* 是同一套。
939
1323
  */
940
1324
  const acIconPaths = {
941
- 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",
942
- 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",
943
- 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",
944
- increase: "m12 8l6 8H6z",
945
- decrease: "m12 16l-6-8h12z",
946
- 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",
947
- 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",
948
- 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"
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"
949
1351
  };
1352
+ //#endregion
1353
+ //#region src/ui.ts
950
1354
  /**
951
1355
  * 声音键在各声音模式下显示的图标。
952
1356
  */
@@ -956,13 +1360,42 @@ const acSoundIcons = {
956
1360
  mute: "soundMute"
957
1361
  };
958
1362
  /**
959
- * 能效标签的点阵与等级条布局。
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`:依据的国家标准编号(白点)
960
1390
  */
961
1391
  const acEnergyLabel = {
1392
+ grade: 1,
1393
+ grades: 5,
1394
+ standard: "GB 21455-2019",
962
1395
  top: [{
963
- num: 6,
1396
+ num: 4,
964
1397
  size: 4,
965
- mx: 2,
1398
+ mx: 1.5,
966
1399
  my: 0,
967
1400
  color: "white"
968
1401
  }],
@@ -1002,7 +1435,7 @@ const acEnergyLabel = {
1002
1435
  width: "50%"
1003
1436
  },
1004
1437
  {
1005
- background: "#ffc107",
1438
+ background: "#fdd835",
1006
1439
  width: "60%"
1007
1440
  },
1008
1441
  {
@@ -1015,12 +1448,32 @@ const acEnergyLabel = {
1015
1448
  }
1016
1449
  ]
1017
1450
  };
1451
+ /**
1452
+ * 能效标签的读屏文字。
1453
+ */
1454
+ function getAcEnergyLabelText() {
1455
+ return `中国能效标识:能效等级 ${acEnergyLabel.grade} 级(共 ${acEnergyLabel.grades} 级),依据 ${acEnergyLabel.standard}`;
1456
+ }
1018
1457
  //#endregion
1458
+ exports.acAirflowAnchors = acAirflowAnchors;
1019
1459
  exports.acAudioFileNames = acAudioFileNames;
1460
+ exports.acDripIntervalRange = acDripIntervalRange;
1461
+ exports.acDripMaxTemperature = acDripMaxTemperature;
1020
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;
1021
1469
  exports.acIconPaths = acIconPaths;
1470
+ exports.acLouverMotion = acLouverMotion;
1022
1471
  exports.acMaxTilt = acMaxTilt;
1023
1472
  exports.acModeIcons = acModeIcons;
1473
+ exports.acModeLabels = acModeLabels;
1474
+ exports.acModelColors = acModelColors;
1475
+ exports.acModelFiles = acModelFiles;
1476
+ exports.acModelParts = acModelParts;
1024
1477
  exports.acMotion = acMotion;
1025
1478
  exports.acReducedMotionDuration = acReducedMotionDuration;
1026
1479
  exports.acReducedMotionQuery = acReducedMotionQuery;
@@ -1032,9 +1485,11 @@ exports.acSoundModes = acSoundModes;
1032
1485
  exports.acSoundVolumes = acSoundVolumes;
1033
1486
  exports.acStorageKey = acStorageKey;
1034
1487
  exports.acTone = acTone;
1488
+ exports.bindAcPressRepeat = bindAcPressRepeat;
1035
1489
  exports.celsiusToHeatLevel = celsiusToHeatLevel;
1036
1490
  exports.clampTemperature = clampTemperature;
1037
1491
  exports.createAcAudioController = createAcAudioController;
1492
+ exports.createAcNotificationPresenter = createAcNotificationPresenter;
1038
1493
  exports.createAirConditionerStore = createAirConditionerStore;
1039
1494
  exports.createComputePressureSource = createComputePressureSource;
1040
1495
  exports.createHeatGovernor = createHeatGovernor;
@@ -1043,16 +1498,28 @@ exports.defaultAcLimits = defaultAcLimits;
1043
1498
  exports.defaultAcState = defaultAcState;
1044
1499
  exports.defaultHeatThresholds = defaultHeatThresholds;
1045
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;
1046
1507
  exports.getAcScale = getAcScale;
1047
1508
  exports.getBrowserStorage = getBrowserStorage;
1509
+ exports.getEnergyTip = getEnergyTip;
1510
+ exports.getFanSpeedMessage = getFanSpeedMessage;
1048
1511
  exports.getRecommendedTemperatureMessage = getRecommendedTemperatureMessage;
1049
1512
  exports.getSoundModeMessage = getSoundModeMessage;
1513
+ exports.getSwingMessage = getSwingMessage;
1050
1514
  exports.heatLevelRank = heatLevelRank;
1051
1515
  exports.heatLevels = heatLevels;
1516
+ exports.isAcFanSpeed = isAcFanSpeed;
1517
+ exports.isAcForm = isAcForm;
1052
1518
  exports.isAcSoundMode = isAcSoundMode;
1053
1519
  exports.isHeatLevel = isHeatLevel;
1054
1520
  exports.maxTemperature = maxTemperature;
1055
1521
  exports.minTemperature = minTemperature;
1522
+ exports.nextAcFanSpeed = nextAcFanSpeed;
1056
1523
  exports.nextAcSoundMode = nextAcSoundMode;
1057
1524
  exports.normalizeAcState = normalizeAcState;
1058
1525
  exports.observeAcScale = observeAcScale;