给空调做一个“会睡觉”的定时器:Home Assistant + HomeKit 实战与踩坑全记录

从“小程序能定时,为什么 Home 不能”出发,一路撞穿 Apple Home 的隐藏限制,最后用一个“风扇”收的尾

作者: shisaq 日期: June 3, 2026

夏天到了,晚上吹着空调睡觉,最怕两件事:一是吹一整宿,早上冻得鼻子发酸、电表也跑得欢;二是想定时关,可我家这台空调本身没有定时关机功能

厂商的小程序里其实是有定时的——但你想想那个画面:人都躺平了,还得摸黑解锁手机、打开小程序、等它连上云、点进去设定时……困意全没了。我想要的是躺在床上,对着 Apple Home 或 Siri 拨一下,就告诉它“X 小时后关”,然后安心闭眼。

这篇文章记录我把这个“会睡觉的定时器”做出来的完整过程。它最终很简单,但中间撞了好几堵看不见的墙——而那些墙,恰恰是这篇东西最有价值的部分。如果你也在折腾 Home Assistant 接空调,这些坑能帮你少走两个晚上的弯路。

先看最终成果

在 Apple Home 里,多了一个叫「天窗空调定时」的滑块控件。

  • 躺床上拖一下:拖到 30% = 3 小时后关,25% = 2.5 小时,0.5 小时一档;
  • 想取消:拖回 0 / 关掉它;
  • 还有一道保底:万一我设完忘了、或者压根没设就睡着了,到了 23:00 它会自己判断——空调还开着就再给 3 小时,凌晨自动关;
  • 只管卧室那一台,白天开空调完全不受影响,绝不会无缘无故把你白天的空调关掉。

整条链路是这样的:

1
2
3
4
5
6
7
8
9
10
11
Apple Home / Siri
      ↓  拖滑块 / 语音
HomeKit 桥(Home Assistant 自带)
      ↓
fan.skylight_ac_timer   ← 一个“风扇壳”滑块(别急,下面解释为什么是风扇)
      ↓  百分比 ÷ 10 = 小时
input_number.skylight_ac_hours   ← 真正存“几小时”的地方
      ↓
timer.skylight_ac_manual   ← 倒计时
      ↓  到点
climate.turn_off → 卧室空调(本地局域网控制)

看着平平无奇对吧?但“为什么偏偏是一个风扇”“为什么不直接用空调自己的定时”——这两个问题背后,是整整一晚的探案。

需求先拆清楚

动手前我把脑子里的模糊愿望拆成了五条硬指标,这一步特别重要,能帮你判断每个方案“到底行不行”:

  1. 手动、任意时长:今晚想 2 小时,明晚可能 3 小时,得能自己定。
  2. 保底兜底:人是会忘事的。啥都没设就睡着了,也得有个安全网把它关掉。
  3. 只针对一间卧室:家里两台空调,另一台不许碰。
  4. 白天不误伤:白天开空调是正常使用,定时逻辑绝不能在白天把它关了。
  5. 躺着能操作:最好就在 Apple Home / Siri 里,不用再开第三个 App。

带着这五条,我们出发。

第一道弯:Home App 自己能定时吗?

最自然的想法:Apple Home 不是能做自动化吗,直接设个“X 小时后关”不就行了?

不行。 Apple Home 的自动化触发器只有那么几种(时间、配件状态、有人到家/离开、传感器),它没有“倒计时 / 延时 X 小时”这种原生控件。你能做的只有“每天固定几点关”——可我的时长每天不一样,固定时间直接出局。

iOS 的「快捷指令」里倒是有“等待”动作,但手机上的“等待”跑在你手机上,锁屏久了就断——拿来扛 2 小时极不靠谱。

结论:真正的倒计时得交给一台一直醒着的机器。我家正好有一台树莓派跑着 Home Assistant(HA),它就是干这个的最佳人选。于是问题变成:HA 怎么控这台空调、又怎么把控件递回 Apple Home。

第二道弯(探案时间):空调自己不就有定时吗?

这是我钻进去最久、也最长见识的一段。

我家这台是华凌(美的体系),在 HA 里通过 midea_ac_lan 这个本地局域网集成接入——也就是说 HA 直接在内网跟空调对话,不走云。那么,小程序里那个“定时关机”,到底是云端在帮我倒计时,还是空调自己在数?

我一度想当然地说“云端”。但转念一想不对:厂商要是给千千万万台空调在云上各跑一个倒计时,这成本和逻辑都说不通。倒计时几乎一定是写进空调本机、由它固件自己数的,小程序只是把“N 分钟后关”这条指令下发下去而已。

如果是这样,那这条本地指令能不能我自己发?我把 midea-local 库的代码扒开看了。空调的设置命令是协议里的 0x40 包,我直接去读它构造命令体的那段:

1
2
3
4
5
6
7
8
9
10
11
# midealocal/devices/ac/message.py · MessageGeneralSet._body
return bytearray([
    power | prompt_tone,
    mode | target_temperature,
    fan_speed,
    0x00,   # ← 这几个写死的 0x00……
    0x00,   # ← 正是 Midea 协议里 on-timer / off-timer 的槽位
    0x00,
    swing_mode,
    ...
])

真相大白:协议槽位就在那儿(那几个 0x00 正是定时字节),但这个本地库根本没去填它。再去翻状态回包的解析(XC0MessageBody),它读了三十来个字段——温度、模式、睡眠、防冻……唯独不读 timer。全库唯一的 timer 只是子协议里的一个布尔“能力标志”,不是时长。

所以结论很清醒:

  • 我的直觉对了——空调固件确实支持本地定时,不是云端逻辑
  • 但这个开源本地集成没把这功能接出来
  • 想用,就得魔改这个库(改命令编码、加可写实体),代价是每次更新被覆盖、还得为你这具体型号把字节编码试准;
  • 而且最关键:就算接通了,HomeKit 那头照样没有“时长控件”可用,UI 问题一点没解决。

💡 给同路人的提醒:遇到“某功能 App 能用、集成里没有”,先去读集成/底层库的源码,往往能在十分钟内判断“是没接出来”还是“协议不支持”。这一步省下的是你瞎试的好几个晚上。

于是我果断收手:别碰库。倒计时放 HA 自己做就好——一个 timer 助手足够稳,连重启都能续上(restore: true)。“倒计时跑在哪”其实根本不影响体验,真正没解决的是下一关。

第三道弯(翻车现场):HomeKit 的三道墙

需求第 5 条是“躺着在 Home 里操作”。我想要一个像调空调温度那样、能 +/- 连续拨的控件,一眼看到“几小时”。听起来不难吧?Apple 用三堵墙教我做人。

第一堵:HomeKit 没有“数字 / 时长滑块”。 我翻了 HA 的 HomeKit 桥源码,它支持的控件类型就这些:fans / lights / covers / humidifiers / thermostats / locks / switches / sensors…——没有 number。你的 input_number 根本递不过去。

第二堵:能连续滑的,只有两种单位。 翻下来,Apple 原生 Home 里能渲染成“连续滑块/表盘”的就两类:

控件 来源 显示单位
灯亮度 / 风扇转速 / 窗帘 / 加湿器 百分比特征 锁死 %
恒温器 温度特征 锁死 °

单位是协议特征写死的,Apple 不让你把 ° 改成“小时”。(第三方 HomeKit App 如 Eve 能显示自定义单位,但用苹果原生 Home 就这两种。)

那……恒温器至少数字是真的啊!我设 2.5,它就显示 2.5,只是旁边带个 °,能忍。于是我真的用 generic_thermostat 造了个假恒温器,把“温度”当“小时”,范围声明成 0–8、步进 0.5。

第三堵,也是压垮这个方案的那根稻草:Apple Home 给恒温器目标温度设了一个 ~7° 的硬地板。 我在 HA 里明明写了 min_temp: 0,可手机上那个表盘只能拨 7.0 / 7.5 / 8.0 三档——0 到 7 全被 Apple 吞了。总不能让人“最少定 7 小时”吧?这个方案当场报废。

🧱 一句话记住:在 Apple 原生 Home 里,想要全程连续的控件,要么接受“显示是 %”,要么接受“恒温器最低 7°”。鱼与熊掌,real-number 和 full-range 不可兼得。

最终方案:用一个“风扇”收尾

撞完三堵墙,路反而清楚了:既然“显示真实小时数”做不到,那就退一步选全程不卡的百分比滑块,把“是 %”这个瑕疵用一个好记的映射抹平。

我选了风扇(也可以用灯,风扇胜在能设“档数”、好对齐):

  • speed_count: 20,于是滑块有 20 档,每档 5% = 0.5 小时,最高 100% = 10 小时,拖动时会卡档,半小时精度轻松对准;
  • 映射做成最好心算的:百分比 ÷ 10 = 小时。30% → 3 小时,25% → 2.5 小时;
  • 这个“风扇”其实是个空壳,真正存小时数的是背后的 input_number,风扇只是它递给 HomeKit 的外衣。

整套配置如下,HA 用户可以直接抄(我用 packages 的方式把一整块功能塞进一个文件,方便整体回滚)。你只需要把那个真·空调实体 climate.197912093838856_climate 换成你自己的,其余照搬即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# packages/skylight_ac_timer.yaml
# 卧室空调·睡眠定时关
#   Apple Home 里一个“风扇壳滑块” fan.skylight_ac_timer:
#   百分比 ÷ 10 = 小时(30%→3h,25%→2.5h),20 档、每档 0.5h、最高 10h,拖到 0 / 关闭 = 取消。
#   背后 input_number.skylight_ac_hours 存小时数;倒计时到点关空调并复位 0。
#   保底:23:00 判断一次,空调还开着且没设定时 → 起 3 小时(→02:00 关)。白天开空调不进此逻辑。

input_number:
  skylight_ac_hours:
    name: 卧室空调定时小时数
    min: 0
    max: 10
    step: 0.5
    mode: slider
    icon: mdi:timer-outline

timer:
  skylight_ac_manual:
    name: 卧室空调手动倒计时
    duration: "02:00:00"
    restore: true
  skylight_ac_fallback:
    name: 卧室空调保底倒计时
    duration: "03:00:00"
    restore: true

# ⚠️ 用新版 template 语法(旧 `fan: platform: template` 将在 2026.6 移除)
template:
  - fan:
      - name: 卧室空调定时
        default_entity_id: fan.skylight_ac_timer   # 锁住英文 entity_id,HomeKit 桥引用更稳
        state: "{{ 'on' if (states('input_number.skylight_ac_hours') | float(0) > 0) else 'off' }}"
        percentage: "{{ (states('input_number.skylight_ac_hours') | float(0) * 10) | int }}"
        speed_count: 20
        turn_on:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ states('input_number.skylight_ac_hours') | float(0) < 0.5 }}"
                sequence:
                  - action: input_number.set_value
                    target: { entity_id: input_number.skylight_ac_hours }
                    data: { value: 2 }   # 直接点“开”不拖滑块 = 默认 2 小时
                  - action: timer.start
                    target: { entity_id: timer.skylight_ac_manual }
                    data: { duration: "02:00:00" }
        turn_off:
          - action: input_number.set_value
            target: { entity_id: input_number.skylight_ac_hours }
            data: { value: 0 }
          - action: timer.cancel
            target: { entity_id: timer.skylight_ac_manual }
        set_percentage:
          - variables:
              hrs: "{{ ((percentage | float(0) / 10) * 2) | round(0) / 2 }}"  # 折到最近的 0.5
          - action: input_number.set_value
            target: { entity_id: input_number.skylight_ac_hours }
            data: { value: "{{ hrs }}" }
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ hrs >= 0.5 }}"
                sequence:
                  - action: timer.start
                    target: { entity_id: timer.skylight_ac_manual }
                    data:
                      duration: >
                        {% set s = (hrs * 3600) | int %}
                        {{ '%02d:%02d:00' % (s // 3600, (s % 3600) // 60) }}
            default:
              - action: timer.cancel
                target: { entity_id: timer.skylight_ac_manual }

automation:
  # 倒计时到点:关空调 + 复位小时数到 0
  - id: skylight_ac_manual_finish
    alias: 卧室空调·倒计时到→关机并复位
    trigger:
      - platform: event
        event_type: timer.finished
        event_data: { entity_id: timer.skylight_ac_manual }
    action:
      - service: climate.turn_off
        target: { entity_id: climate.197912093838856_climate }   # ← 换成你的空调
      - service: input_number.set_value
        target: { entity_id: input_number.skylight_ac_hours }
        data: { value: 0 }

  # 保底:23:00 定点判断
  - id: skylight_ac_fallback_check_2300
    alias: 卧室空调·23点判断→若开着且没设定时则起3小时保底
    trigger:
      - platform: time
        at: "23:00:00"
    condition:
      - condition: template
        value_template: >
          {{ states('climate.197912093838856_climate') not in ['off','unavailable','unknown'] }}
      - condition: state
        entity_id: timer.skylight_ac_manual
        state: idle
    action:
      - service: timer.start
        target: { entity_id: timer.skylight_ac_fallback }
        data: { duration: "03:00:00" }

  - id: skylight_ac_fallback_disarm
    alias: 卧室空调·关机→取消保底
    trigger:
      - platform: state
        entity_id: climate.197912093838856_climate
        to: "off"
    action:
      - service: timer.cancel
        target: { entity_id: timer.skylight_ac_fallback }

  - id: skylight_ac_fallback_finish
    alias: 卧室空调·保底3小时到→关机
    trigger:
      - platform: event
        event_type: timer.finished
        event_data: { entity_id: timer.skylight_ac_fallback }
    action:
      - service: climate.turn_off
        target: { entity_id: climate.197912093838856_climate }

几个设计点,值得单独说说

为什么不直接让风扇存小时数,要多一个 input_number 因为模板风扇本身是“无状态”的外壳,它的百分比需要一个真实的地方存。input_number 就是那个“单一事实来源”:风扇读它显示、写它生效,倒计时到点把它清零,整条逻辑就闭环了,也不怕重启丢值。

保底为什么是“23:00 判断一次”,而不是“开机就起 3 小时”? 这正是需求第 4 条(白天不误伤)的关键。如果做成“一开机就 arm 3 小时”,那你白天开空调,3 小时后也会被自动关——这不是我们要的。改成只在 23:00 这个点判断一次:那时候空调还开着、又没设手动定时,才认为“这人是开着空调睡了”,给它兜底。白天的空调,从头到尾不进这套逻辑。

(小取舍:如果你 23:00 之后才进卧室开空调,那一晚保底不生效——这种情况手动拖一下滑块即可。我作息规律,基本卡得上,就接受了这个边界。)

手动定时优先于保底。 23:00 判断时有一条 timer.skylight_ac_manual == idle 的条件:只要你已经拖了滑块(手动倒计时在跑),保底就不插手;它俩还能自然叠加——手动更短会先关,关机那一刻又会触发“取消保底”,不会重复。

怎么把这个控件递进 Apple Home

HA 自带一个 HomeKit 桥集成,能把指定实体暴露成 Apple Home 里的配件。把上面的 fan.skylight_ac_timer 加进去就行:

设置 → 设备与服务 → 找到 HomeKit Bridge → 配置 → 在“要包含的实体”里勾上 fan.skylight_ac_timer → 保存。

稍等片刻,Apple Home 里就会冒出一个「卧室空调定时」的风扇配件。是的,图标是个风扇——这是我们为了“全程连续滑块”付出的代价,你就当它是“把热气吹走的倒计时”好了 🌬️。拖它、或者对 Siri 说“把卧室空调定时设成 30%”,3 小时后空调自己就睡了。

临门一脚的小坑:旧版模板语法要迁移

做完没几天,HA 后台弹了个“修复”提示:旧的 fan: platform: template 写法将在 2026.6 移除,让我迁到新版 template:fan: 语法。

好在 HA 的修复向导会直接生成迁移好的新配置,照着替换就行。上面那份完整配置我已经是迁移后的新语法了,你直接抄不会再碰到这个提醒。这里也顺手记一条经验:

🔧 定期看一眼 HA 的“修复(Repairs)”面板。很多弃用都会提前一两个版本预警,并附带自动生成的新配置——趁早处理,别等升级当天炸给你看。

踩坑速查表

把这一晚的弯路浓缩成一张表,方便你对号入座:

真相 对策
Home App 想直接定时 没有原生“延时/倒计时”控件 倒计时交给常醒的 HA(timer 助手)
想用空调自带定时 固件支持,但本地集成没把它接出来(命令字节写死 0) 别魔改库,HA 自己做倒计时即可
想要数字滑块 HomeKit 桥没有 number 类型 借“风扇/灯”的百分比壳
想用恒温器显示真实小时 Apple Home 把目标温度卡在 ~7° 地板 放弃“真实数字”,用 % 滑块 + 好记映射
百分比不直观 单位改不了 ÷10 这种心算友好的映射 + speed_count 卡档
旧模板语法弃用 2026.6 移除 fan: platform: template 用 Repairs 向导迁到新版 template:

写在最后

绕了一大圈,最后落地的东西特别朴素:躺床上拖一下滑块,空调就会在我设定的时间安静睡去;哪怕我先睡了,也有一道 23 点的安全网替我兜着。 一个不起眼的小工具,却实实在在地把“起夜关空调”这件小事从我的生活里抹掉了。

我很喜欢这种折腾。它表面是在跟空调较劲,本质是在跟那些“看不见的限制”打交道——Apple 的地板、协议里写死的零、即将被移除的旧语法。每撞一堵墙,你对这套系统的理解就厚一分。而最有意思的部分,往往不是最终那段能抄的配置,而是“为什么偏偏是一个风扇”背后的那一整晚。

愿你也能给自己的生活,装上一两个这样“会睡觉”的小开关。

—— 给你的生活加点阳光 ☀️