mcpField
H
haos-addon-deploy

haos-addon-deploy

github.com/caanio云与 DevOps047 次安装今天更新
代码仓库

关于 haos-addon-deploy

指导开发者将自编写的长期运行应用部署为 Home Assistant OS 或 Supervised 上的本地加载项,使其在 Raspberry Pi 等设备上持续运行。内容涵盖打包、传输、Supervisor 操作与常见部署陷阱。

主要功能

  1. 01组织加载项文件与配置
  2. 02通过 SSH 传送代码快照
  3. 03处理本地加载项目录与 slug
  4. 04调用 Supervisor CLI 和 API
  5. 05检查部署可达性与重复清单

适用场景

  1. 01将轮询器或监控程序部署到 HAOS
  2. 02在无需先提交代码时传送紧急修复
  3. 03排查加载项更新未生效或容器名称变化

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/caanio/agent-skills --skill haos-addon-deploy

安装后在 Claude Code 中输入/haos-addon-deploy确认已加载。

详细介绍

HAOS 本地附加组件部署(RPi,全天候运行)

将自行编写的长时间运行应用(轮询器、监视器等)部署为 Home Assistant OS 本地附加组件,让它在 Raspberry Pi 上全天候运行,而不是运行在会进入睡眠状态的工作站上。 以下每一步都已在真实硬件上验证;所有注意事项均已标记 ⚠️。 在示例中,<alias> = HA 主机的 SSH 别名,<slug> = 附加组件 slug(安装后会变为 local_<slug>),$C = 由下方“范围”部分的代码片段在运行时解析出的 Docker 容器名称——绝不硬编码,因为其前缀会因 Supervisor 版本而异。请替换为你自己的值;此处内容不依赖特定主机。

范围

  • 仅适用于 Home Assistant OS(或 Supervised)安装——Container/Core 安装没有 Supervisor、没有 /addons,也没有 ha CLI;本指南不适用。
  • 已在 Raspberry Pi 4(aarch64)、HAOS 17.x–18.1(Supervisor + Docker appliance;下方的容器命名方式是在 Supervisor 2026.07.5 上观察到的)以及 Advanced SSH & Web Terminal 附加组件上经过实测(按 §8 设置;关闭保护模式——见 §0)。
  • ⚠️ 最近的 HAOS 在 UI 和 CLI(ha apps)中将附加组件标为 “Apps”,但这次改名 尚未完全完成——不要假设各个界面使用哪种拼写,务必逐一确认:
    • 仍为 addon:/addons 交付路径、Supervisor REST API(/addons/<slug>/…), 以及 Docker 镜像名称(local/<arch>-addon-<slug>:<version>)
    • 现在为 app:CLI(ha apps)、Supervisor 自身的存储路径(/data/apps/local/…), 以及 Docker 容器名称(app_local_<slug>,在 Supervisor 2026.07.5 上观察到)
    • ⚠️ 不要根据 HAOS 版本来判断。 容器名称由 Supervisor 生成,而 Supervisor 会 独立于 OS 镜像自动更新,因此“HAOS 越新 ⇒ app_”不是可靠规则——并且目前实际上 只观察到一个 Supervisor 版本。不要硬编码任何一种前缀;只需解析一次 $C,然后 在所有地方复用:
      # 加上 -a,以便也能找到已停止的附加组件——这些正是你需要检查的对象
      C=$(ssh <alias> "sudo docker ps -a --format '{{.Names}}' \
           | grep -E '^(app|addon)_local_<slug>$'") || true
      [ -n "$C" ] || { echo "未找到 <slug> 的容器——是否以其他 slug 安装?" >&2; exit 1; }
      
      如果没有这个保护,空的 $C 会让 docker exec $C python3 … 变成 docker exec python3 …,然后 Docker 报告“no such container: python3”——这是一个容易让人 困惑的错误,会让你去错误的地方排查。

0. 全局规则(请先阅读)

  • ⚠️ 任何备份、交付或构建之前,先进行预检以确认连接可达: 使用较短的墙上时钟时间限制(≤30 秒——使用工具自身的超时;macOS 没有 timeout 命令)运行 ssh -n -o ConnectTimeout=20 <alias> true。命令在该时限内以 0 退出即表示预检通过。其他任何情况——出错、超时或转入后台——都意味着必须停止,报告失败的别名和工作站当前连接的网络,并等待用户确认后再执行任何远程步骤。
    • 原因:离开家庭局域网后,像 homeassistant.local 这样的 mDNS 名称不会快速失败——ssh 可能卡在名称解析上,完全没有任何输出,而 ConnectTimeout(TCP 连接超时限制)无法涵盖这种情况。若备份使用 10 分钟超时,它只会看起来很慢,并一直停留在后台(真实案例:约浪费了 1 小时)。
    • 通过 cloudflared access ssh 建立隧道的别名需要 20 秒:它在 5 秒时超时,在 20 秒内通过;并且它可能会先要求用户在浏览器中授权。
  • 每次网络发生变化后都要重新运行预检;如果某个远程步骤的输出在预期耗时后仍为空,应将其视为不可达,而不是运行缓慢。
  • ⚠️ 每次调用 ha CLI / Supervisor API 都需要登录 shell,它会加载 SUPERVISOR_TOKEN;缺少该令牌时,调用会因 unauthorized 失败。普通的 docker/ls/grep 不受影响——只有 ha 和直接调用 http://supervisor 时需要令牌。
    • 单条命令:ssh <alias> 'bash -lc "ha …"'
    • 多步操作:ssh <alias> bash -l -s <<'EOF' … EOF——-l 是 heredoc 能正常工作的关键;直接使用 bash -s 的 heredoc 会启动非登录 shell,其中每次 ha 调用都会失败。
    • ⚠️ 了解这种失败的表现很重要。 ha 会将 Error: unauthorized: missing or invalid API token 写入标准错误并以 1 退出。因此,只读取标准输出的管道(ha apps info … | grep …)会显示空结果,看起来就像“该字段未设置”,而不是“调用失败”。在根据空输出得出结论前,请检查退出代码,或添加 2>&1。
  • ⚠️ ssh 会读取标准输入,因此要为每个 ssh 提供它实际需要的标准输入。 两种方向都曾导致实际部署失败,而且都会悄无声息地失败:没有错误,命令就是不会运行。
    • 通过 heredoc 提供给远程端的脚本 → 使用 ssh <alias> bash -l -s <<'EOF',不要加 -n:-n 会将标准输入指向 /dev/null,因此 heredoc 不会传给远程 shell。
    • 在本身通过标准输入读取的脚本中执行一次性命令 ssh <alias> '…'(例如 bash <<'EOF' 包装脚本,或任何通过管道传给 bash 的脚本)→ 添加 -n(或 </dev/null)。否则,该 ssh 会把本地脚本剩余内容作为自己的标准输入读走,后续所有行都会被跳过。
    • 判断方法:某个步骤的输出完全缺失。将缺失的输出块视为“没有运行”,绝不能视为“已通过”;请单独重新运行该步骤以确认。
  • ⚠️ SSH 插件的保护模式必须关闭,否则无法访问 Docker 套接字(包括 § 范围解析器的 docker ps 在内的 sudo docker …)。用户可在插件信息页面关闭此选项;Agent 应询问用户,如果保护模式仍然开启,则改用基于日志的验证(§5 说明原因)。
  • ⚠️ CLI 名称:使用 ha apps(ha addons 已弃用);要重新检测本地插件,请使用 ha store reload(ha apps reload 不存在)。
  • ⚠️ 绝不能让同一应用同时在两台机器上运行(工作站守护进程 + RPi 插件):这会让外部 API/网站承受双倍负载,并且任何使用轮换令牌的服务都会导致两个实例互相挤掉对方。迁移完成后,停止工作站上的副本并禁用其自动启动。
  • 只有一张 SD 卡的 RPi 没有冗余:操作前请先完整备份 HA,并将备份文件下载到卡外。网页下载速度很慢(HA 流式传输瓶颈);使用 scp <alias>:/backup/<file>.tar ~/backups/ 会明显更快。

1. 仓库布局

将所有 HA 专用文件放在仓库的 haos/ 文件夹中;交付时展平:

haos/
├── config.yaml      # add-on manifest (name/slug/version/arch/options/schema)
├── Dockerfile
├── run.sh           # entrypoint: create /data subdirs, then exec the main app
└── .dockerignore    # exclude .env, logs/, __pycache__, .git/

config.yaml 要点:

name: My App
slug: my_app            # installed as local_my_app
version: "1.0.0"        # ⚠️ bump this to make HA detect a code update
arch: [aarch64, amd64]
startup: application
boot: auto
map:
  - data:rw             # /data = persistent volume, survives rebuild/update
init: false
options:                # user-editable settings (HA web UI "Configuration" tab)
  some_secret: ""
schema:
  some_secret: password # the password type masks the value in the UI

⚠️ 基础镜像选择(musl 与 glibc):纯 Python/requests → 使用 python:3.12-alpine 即可(体积小);但任何依赖 onnxruntime / opencv / numpy 生态 (例如 ddddocr)的内容,都必须使用 python:3.12-slim(Debian glibc)——这些软件包不提供 musllinux wheel,因此在 Alpine 上,pip 会退回到源码构建(卡住或失败)。 非 headless 的 opencv 还需要 apt-get install libgl1 libglib2.0-0。 不使用 HA 官方基础镜像 → 没有 bashio;应用改为从 /data/options.json 读取自己的配置。

惯用的配置加载方式:如果 /data/options.json 存在,就将每个键转为大写并设为 环境变量(直接解析列表类型字段,例如监视列表);如果文件不存在(本地开发), 则回退到 .env。

2. 交付代码(在工作站上运行,目标为 /addons)

设备不会克隆仓库(避免使用私有仓库凭据);工作站会推送 快照。选择一种方式:

**已提交的代码树(仓库有提交记录后的默认方式):**只交付 HEAD,这样不会意外带上 多余的本地文件(尚未完成的编辑、调试脚本)。这项保证正是将其设为默认方式的原因。如果仓库中还有另一个附加组件的 文件夹,请使用下文“两个目录绝不能声明相同的 slug”中的按路径归档命令,替代完整 HEAD 的命令。

# /addons is owned by root; create the folder once to gain write access
ssh <alias> 'sudo mkdir -p /addons/<slug> && sudo chown $(whoami) /addons/<slug>'
git archive --format=tar HEAD | ssh <alias> 'tar -x -C /addons/<slug>'
git archive --format=tar HEAD haos | ssh <alias> 'tar -x --strip-components=1 -C /addons/<slug>'
ssh <alias> 'rm -rf /addons/<slug>/haos'   # remove the duplicate haos/ subfolder the full-HEAD archive brought along

直接交付工作树,无需先提交——适用于仓库还没有提交记录,或 需要立即将热修复部署到设备上的情况;提交是单独的后续步骤(参见 git-helper),并非部署的前提。只指定此附加组件实际需要的路径,像下文重复 slug 修复中按路径指定的 git archive 一样——绝不要盲目打包整个项目根目录(该修复会说明原因):

ssh <alias> 'sudo mkdir -p /addons/<slug> && sudo chown $(whoami) /addons/<slug>'
tar -cf - <app files/dirs, e.g. main.py requirements.txt app/> | ssh <alias> 'tar -x -C /addons/<slug>'
tar -cf - -C haos . | ssh <alias> 'tar -x -C /addons/<slug>'

⚠️ tar 不会读取 .dockerignore(这是 Docker 构建时使用的机制,不是 tar 的机制)——如果不明确排除,本地存在的 __pycache__、.venv、多余的 .pyc 文件等都会一并打包。若这些文件混进交付目录,之后请将它们清理掉:ssh <alias> "find /addons/<slug> -name __pycache__ -type d -exec rm -rf {} +"。

⚠️ 此路径会交付磁盘上的当前内容,包括未提交的工作——不会像已提交代码树的方式那样筛选。用于有意进行的热修复没有问题;如果仓库还没有提交记录,请将其视为一次性初始化,并在首次提交后切换到已提交代码树的方式,而不要将其作为常规方法。无论采用哪种方式,热修复之后仍需单独提交——这种方式只是将提交的时间与设备收到修复的时间解耦。

⚠️ 部署目录必须平铺且自包含:config.yaml、Dockerfile 和 run.sh 都必须位于 /addons/<slug>/ 的顶层。

⚠️ 两个目录绝不能声明相同的 slug(上面任一种交付方式都可能导致这种情况)

Supervisor 的本地存储会递归遍历本地附加组件树——其源码使用 path.glob("**/config.*")(supervisor/store/data.py),因此会在任意深度接受 config.yaml、 config.yml 和 config.json。已提交树的交付方式会通过 git archive --format=tar HEAD 打包整个仓库,所以如果 仓库还包含第二个附加组件的文件夹(一个仓库、两个附加组件——例如一个轮询器和一个 数据导入服务),第二个清单就会进入这个附加组件的部署目录,导致现在两个目录都声明了第二个 slug。

⚠️ 哪个目录生效并不确定。 Supervisor 遍历时只会覆盖(apps[app_slug] = app),因此最后访问到的目录生效——而 Path.glob 返回的是文件系统的 scandir 顺序,不会排序,不按修改时间,也不会选择最高版本。重新交付或重启后,结果可能发生变化,因此仅检查一次并确认“这台机器上看起来没问题”毫无证明力。唯一安全的状态是:绝不允许两个清单声明相同的 slug。

当旧副本生效时,对于另一个附加组件:

  • version_latest 会报告旧副本的版本,因此 update_available 会保持为 false,ha apps update 也不会执行任何操作——无论你重新交付真实目录多少次都一样
  • Docker 构建上下文也会是这个旧目录,因此看似成功的 ha apps rebuild 会把旧代码打包进镜像
  • ha store reload、ha supervisor restart、docker builder prune -a、完整的 HAOS 重启,甚至 ha apps uninstall + install 都无济于事——Supervisor 并没有过时,生效的目录确实就是旧目录。(真实案例:整整浪费了一天,误以为是 Supervisor 的版本跟踪缺陷。)

最佳修复方法:不要打包整个仓库。 只发送此附加组件所需的路径,从而彻底避免冲突:

git archive --format=tar HEAD -- <app-dir> <shared-dir> | ssh <alias> 'tar -x -C /addons/<slug>'

(在 .gitattributes 中对另一个附加组件的目录设置 export-ignore 也能达到相同效果。)

这个指定路径的归档只替换上面已提交树代码块中的整份 HEAD 归档命令:仍需运行其中的 haos 展平命令;只有当你指定的路径包含 haos 时,才需要其中的 rm -rf …/haos 命令。

如果必须打包所有内容,请在之后删除另一个附加组件的目录——但必须为删除操作添加保护条件,因为这里的变量若未设置,会展开为 rm -rf /addons//,从而删除设备上的所有附加组件:

ssh <alias> bash -l -s <<'EOF'
set -euo pipefail
SLUG=<slug>; OTHER=<other-addon-dir>
ls -la -- "/addons/${SLUG:?}/${OTHER:?}"      # look before you delete
rm -rf -- "/addons/${SLUG:?}/${OTHER:?}"
EOF

然后,无论采用上面的哪种方式,每次交付后都要用这个机械式检查进行验证(发现重复项时会以非零状态退出);当检查以 0 退出且 config.yaml 位于 /addons/<slug>/ 顶层时,第 2 节即告完成(遵循上面的扁平布局规则)。此检查只比较 config.yaml / config.yml 的 slug:config.json 清单会显示在列表中,但不带 slug,也不会参与比较,因此请手动检查该文件。

ssh <alias> bash -l -s <<'EOF'
find /addons -name 'config.*' ! -name '.*' -not -path '*/rootfs/*' \
  -exec sh -c 'printf "%s → " "$1"; grep "^slug:" "$1"' _ {} \;
find /addons -name 'config.*' ! -name '.*' -not -path '*/rootfs/*' \
  -exec grep -h "^slug:" {} \; | sort | uniq -d > /tmp/dupe_slugs
[ ! -s /tmp/dupe_slugs ] || { echo "DUPLICATE SLUG:"; cat /tmp/dupe_slugs; exit 1; }
EOF

诊断已部署的情况——比较容器内某个源文件的字节数与磁盘上同一文件的字节数;如果二者不同,说明构建上下文不是你交付到的目录,请找到另一个副本(§Scope 中的 $C):

# WORKDIR is whatever this add-on's Dockerfile sets, so resolve it rather than hardcoding
ssh <alias> "sudo docker exec $C sh -c 'wc -c \"\$(pwd)/<file>.py\"'"
ssh <alias> 'wc -c /addons/<slug>/<file>.py'   # what you delivered

# host-side sources of this container's mounts: where this install keeps Supervisor data
# (not a manifest list — the duplicate-slug check above lists manifests)
ssh <alias> "sudo docker inspect $C --format '{{range .Mounts}}{{.Source}}{{\"\n\"}}{{end}}'"

“Supervisor 能从磁盘读取正确版本”和“Supervisor 正在使用该副本”是两个不同的说法——只验证前者,才会让这个缺陷看起来无法解决。

3. 安装

ssh <alias> 'bash -lc "ha store reload && ha apps install local_<slug>"'
# First build takes minutes (longer with heavy deps like onnxruntime/opencv);
# the build runs in the Supervisor background, so a dropped SSH session doesn't matter
ssh <alias> 'bash -lc "ha apps info local_<slug> | grep -E \"state:|version:\""'   # a version line = installed

4. 配置(附加组件选项)

⚠️ ha apps 没有 options 子命令——请选择以下两种方式之一:

  • HA 网页界面(最可靠):附加组件 → Configuration 选项卡 → 填写并保存。 密钥(令牌、推送主题)始终在此处填写——绝不要放进 .env,也不要放进代码库。
  • Supervisor REST API(可编写脚本):
    echo '{"options":{...complete object...}}' \
      | ssh <alias> 'bash -lc "curl -sS -X POST -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \
                -H \"Content-Type: application/json\" -d @- http://supervisor/addons/local_<slug>/options"'
    

⚠️ 此 POST 会替换整个 options 对象,而不是执行补丁更新。 只发送你想更改的 某个字段(例如 {"options":{"some_token":"..."}})会丢弃其他所有选项;如果有任何必填 字段因此缺失,Supervisor 就会拒绝该请求: "App local_<slug> has invalid options: Missing option '<required_option>' in root..."。 要添加或更改单个字段,请先 GET 当前的完整对象,在本地合并你要做的更改,然后 POST 完整的合并对象 ——绝不要手动编写不完整对象:

ssh <alias> 'bash -lc "curl -sS -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \
    http://supervisor/addons/local_<slug>/info"' > opts.json   # full current options
python3 - <<'EOF'
import json
d = json.load(open('opts.json'))['data']['options']
d['some_token'] = 'new-value'
json.dump({'options': d}, open('payload.json', 'w'))
EOF
chmod 600 opts.json payload.json   # password-typed options come back in plaintext — see below
ssh <alias> 'bash -lc "curl -sS -X POST -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \
    -H \"Content-Type: application/json\" -d @- http://supervisor/addons/local_<slug>/options"' < payload.json

注意:info 端点会以明文返回 password 类型的架构字段,不会遮蔽——这正是可以进行合并的原因,但也正因如此,两个文件在 POST 前都会执行 chmod 600,而且整个交互都应视为涉及真实密钥(不要将合并后的对象输出到日志或聊天记录中)。

POST 后,请保留更改前的 opts.json,作为回滚副本;然后重新执行 GET info,并检查你不打算更改的字段(例如刷新令牌)是否仍为非空——错误的合并悄无声息地清空轮换凭据,后果远比 POST 被拒绝严重,而 opts.json 是旧值唯一的留存位置。如果值被清空,请用相同方式 POST {"options": <opts.json's data.options>} 恢复。确认无误后,执行 rm -f opts.json payload.json。

该架构支持字典列表(允许用户在界面中添加或删除条目,例如监控目标):

options:
  targets:
    - name: example
      date: "2026-01-01"
schema:
  targets:
    - name: str
      date: str

⚠️ 架构只声明类型——格式验证由应用在启动时负责; 应报告出错的条目及原因,然后启动失败(这样用户能立即在日志中看到)。

5. 启动与验证

ssh <alias> 'bash -lc "ha apps start local_<slug>"'
ssh <alias> 'bash -lc "ha apps logs local_<slug>"'

当下方回退段落中的日志检查通过时(新代码才会打印的行,且错误 grep 无结果),第 5 节的工作即告完成;docker exec 测试则是额外的佐证。

在容器内测试(例如手动触发一次推送通知):

ssh <alias> "sudo docker exec $C python3 -c '…'"
# ⚠️ the SSH add-on user has no docker socket permission — sudo required
# ⚠️ $C must come from the resolver in §Scope — never hardcode app_local_/addon_local_,
#    and never skip its empty check (an empty $C makes docker read the next arg as the
#    container name, giving a "no such container: python3" that looks unrelated)

⚠️ docker exec 要求关闭 SSH 附加组件的 Protection mode(§0);如果该模式已开启,你就无法使用这整套基于字节指纹和 exec 的验证方法。 如果你是自主编码 Agent,不要假设自己可以直接切换此开关——关闭 Protection mode 属于 Supervisor 的安全设置变更;有权限意识的 Agent 运行环境可能会(也理应)拒绝在用户未监督的情况下让你切换。不要通过寻找变通方法来绕过这一限制;应将其视为真正的边界。改用基于日志的验证——通常已经足够: 启动或更新后,ha apps logs local_<slug> 应显示新版本的日志字符串(仅存在于你刚交付的代码中的一行日志、短语或启动横幅),并且执行 grep -iE "error|traceback|exception|Errno|Fatal" 后没有匹配结果。如果此次部署还新增了可访问的端点,实际访问该端点(参见 §2 中的“两个不同的主张”——相信可观察到的行为,而不是推断)无论如何都比静态字节数更有说服力。

6. 更新(⚠️ 三选一——选错会被拒绝)

更改内容命令
仅选项变更(没有代码变更)ha apps restart local_<slug>
代码变更,未提升 config.yaml 中的版本号重新执行 §2 交付 → ha apps rebuild local_<slug>
代码变更,已提升版本号重新执行 §2 交付 → ha store reload → ha apps update local_<slug>(此处会拒绝重建:“Local and store versions differ, use Update instead of Rebuild”)

⚠️ 更新代码时,务必提升 version 并采用更新流程。 重建会导致设备仍报告旧版本,却在运行新代码——ha apps info 将无法再判断设备实际运行的是哪段代码,给后续调试带来麻烦。仅在版本记录暂时无关紧要、需要快速迭代和测试时才使用重建。

⚠️ 运行更新前,务必确认商店确实检测到了版本号变更——这项检查能将耗时一天的谜团变成两分钟就能解决的问题:

ssh <alias> 'bash -lc "ha apps info local_<slug> | grep -E \"^(version|version_latest|update_available):\""'

version_latest 必须与刚交付的版本一致。如果运行 ha store reload 后仍显示旧版本,请停止——不要开始重建或重新安装。 Supervisor 读取的是另一个声明了你这个 slug 的目录:请参阅 §2 的“两个目录绝不能声明相同的 slug”。

更新、重建或重启后,按照 §5 的说明进行验证。

7. 持久化与日志

  • /data/ 是持久化卷(在 Supervisor 容器内:/data/apps/data/local_<slug>/):数据库、日志和令牌都存储于此,并且在重建或更新后仍会保留。
  • 同时将日志写入标准输出(供 ha apps logs 使用)和 /data/logs/(轮转文件)。
  • ⚠️ 启动日志中绝不能打印机密信息(任何能够打开 HA 的人都能查看 ha apps logs)——只打印开/关状态。

8. SSH 前置条件(仅在首次连接新的 HA 主机时)

在工作站的 ~/.ssh/config 中设置别名(HostName、User、IdentityFile);将公钥添加到 Advanced SSH & Web Terminal 加载项配置中(YAML 模式):

ssh:
  username: <account>
  authorized_keys:
    - >-
      ssh-ed25519 AAAA... public key

⚠️ 如果保存的配置不断还原,通常是私钥文件权限过于宽松(chmod 600),或 YAML 嵌套层级不正确。 SSH 会连接到 SSH 加载项容器(而不是 HAOS 主机或 HA 核心容器);在那里可以使用 sudo。

评论

支持 Markdown