Upload 10 files
Browse files- .dockerignore +7 -0
- Dockerfile +14 -1
- README.md +59 -2
- mcp-proxy.conf +8 -0
- mcp_server/requirements.txt +3 -0
- mcp_server/server.py +75 -0
- nginx.conf +27 -0
- scripts/backup-homeassistant.py +6 -3
- scripts/run-space.py +96 -0
- scripts/start-homeassistant +1 -61
.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
**/__pycache__
|
| 3 |
+
.pytest_cache
|
| 4 |
+
.git
|
| 5 |
+
tests
|
| 6 |
+
.env
|
| 7 |
+
*.zip
|
Dockerfile
CHANGED
|
@@ -2,7 +2,7 @@ FROM ghcr.io/home-assistant/home-assistant:stable
|
|
| 2 |
|
| 3 |
USER root
|
| 4 |
|
| 5 |
-
RUN apk add --no-cache git bash \
|
| 6 |
&& git clone --depth 1 https://github.com/al-one/hass-xiaomi-miot.git /tmp/hass-xiaomi-miot \
|
| 7 |
&& mkdir -p /opt/ha-custom-components \
|
| 8 |
&& cp -a /tmp/hass-xiaomi-miot/custom_components/xiaomi_miot /opt/ha-custom-components/xiaomi_miot \
|
|
@@ -10,6 +10,19 @@ RUN apk add --no-cache git bash \
|
|
| 10 |
&& python3 -m pip install --no-cache-dir --disable-pip-version-check huggingface_hub \
|
| 11 |
&& mkdir -p /data/home-assistant /config
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
COPY scripts/start-homeassistant /usr/local/bin/start-homeassistant
|
| 14 |
COPY scripts/backup-homeassistant.py /usr/local/bin/backup-homeassistant.py
|
| 15 |
RUN chmod +x /usr/local/bin/start-homeassistant
|
|
|
|
| 2 |
|
| 3 |
USER root
|
| 4 |
|
| 5 |
+
RUN apk add --no-cache git bash nginx \
|
| 6 |
&& git clone --depth 1 https://github.com/al-one/hass-xiaomi-miot.git /tmp/hass-xiaomi-miot \
|
| 7 |
&& mkdir -p /opt/ha-custom-components \
|
| 8 |
&& cp -a /tmp/hass-xiaomi-miot/custom_components/xiaomi_miot /opt/ha-custom-components/xiaomi_miot \
|
|
|
|
| 10 |
&& python3 -m pip install --no-cache-dir --disable-pip-version-check huggingface_hub \
|
| 11 |
&& mkdir -p /data/home-assistant /config
|
| 12 |
|
| 13 |
+
# Pre-install the China-region Haier Smart Home custom integration.
|
| 14 |
+
RUN git clone --depth 1 https://github.com/sfairy/ha_haier.git /tmp/ha_haier \
|
| 15 |
+
&& mkdir -p /opt/ha-custom-components \
|
| 16 |
+
&& cp -a /tmp/ha_haier/custom_components/haier /opt/ha-custom-components/haier \
|
| 17 |
+
&& rm -rf /tmp/ha_haier
|
| 18 |
+
|
| 19 |
+
COPY mcp_server/requirements.txt /opt/mcp-server/requirements.txt
|
| 20 |
+
RUN python3 -m venv /opt/mcp-venv \
|
| 21 |
+
&& /opt/mcp-venv/bin/pip install --no-cache-dir -r /opt/mcp-server/requirements.txt
|
| 22 |
+
COPY mcp_server/server.py /opt/mcp-server/server.py
|
| 23 |
+
COPY nginx.conf /etc/nginx/nginx.conf
|
| 24 |
+
COPY mcp-proxy.conf /etc/nginx/mcp-proxy.conf
|
| 25 |
+
COPY scripts/run-space.py /usr/local/bin/run-space.py
|
| 26 |
COPY scripts/start-homeassistant /usr/local/bin/start-homeassistant
|
| 27 |
COPY scripts/backup-homeassistant.py /usr/local/bin/backup-homeassistant.py
|
| 28 |
RUN chmod +x /usr/local/bin/start-homeassistant
|
README.md
CHANGED
|
@@ -10,7 +10,7 @@ suggested_hardware: cpu-basic
|
|
| 10 |
|
| 11 |
# Home Assistant on Hugging Face Spaces
|
| 12 |
|
| 13 |
-
This Space runs Home Assistant with
|
| 14 |
|
| 15 |
## Required Space secrets
|
| 16 |
|
|
@@ -34,4 +34,61 @@ Home account. This integration generally requires the Xiaomi cloud region that o
|
|
| 34 |
the devices; it does not require the Home Assistant container to be on the same LAN.
|
| 35 |
|
| 36 |
Do not put `HF_TOKEN` in this repository or in `README.md`; use Space Settings ->
|
| 37 |
-
Repository secrets.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
# Home Assistant on Hugging Face Spaces
|
| 12 |
|
| 13 |
+
This Space runs Home Assistant with Xiaomi Miot and the China-region Haier Smart Home custom integrations pre-installed.
|
| 14 |
|
| 15 |
## Required Space secrets
|
| 16 |
|
|
|
|
| 34 |
the devices; it does not require the Home Assistant container to be on the same LAN.
|
| 35 |
|
| 36 |
Do not put `HF_TOKEN` in this repository or in `README.md`; use Space Settings ->
|
| 37 |
+
Repository secrets.
|
| 38 |
+
## Agent MCP 接入(同一个 Space)
|
| 39 |
+
|
| 40 |
+
网页继续使用 Space 的直接应用地址;MCP 地址为该地址后加 /mcp。采用 Streamable HTTP,非旧版 SSE。
|
| 41 |
+
|
| 42 |
+
### 你需要完成的步骤
|
| 43 |
+
|
| 44 |
+
1. 在当前 Home Assistant 中确认配置已备份成功,再上传本项目文件。Dockerfile 已包含米家和海尔智家集成安装命令,无需手动合并。不要上传 .venv、tests、__pycache__ 或任何令牌。
|
| 45 |
+
2. 创建专用于 Agent 的 Home Assistant 用户,以该用户登录,进入个人资料 → 长期访问令牌,生成令牌。非管理员用户权限仍可能涵盖大量设备;本项目按要求不设置设备或服务白名单。
|
| 46 |
+
3. 在 Space Settings → Variables and secrets → Secrets 中设置 HA_TOKEN 为该长期令牌,MCP_API_KEY 为你生成的随机密钥(建议至少 32 字节随机值)。密钥不要包含换行。保留 HF_TOKEN 与 BACKUP_REPO_ID。可以先部署再创建 HA 令牌;未配置密钥时 MCP 默认拒绝请求,网页仍可使用。
|
| 47 |
+
4. 等待重新构建完成,再在 Agent 中添加远程 MCP,URL 为 https://你的实际应用域名.hf.space/mcp,传输方式 Streamable HTTP,Header 为 Authorization: Bearer 你的MCP_API_KEY。Agent 不需要 HA_TOKEN。
|
| 48 |
+
5. 使用 Agent 的连接测试,然后先调用 list_entities 或 list_services,再控制设备。
|
| 49 |
+
|
| 50 |
+
通用配置示例(具体字段取决于 Agent 平台):
|
| 51 |
+
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"mcpServers": {
|
| 55 |
+
"home-assistant": {
|
| 56 |
+
"url": "https://YOUR-SPACE.hf.space/mcp",
|
| 57 |
+
"headers": {"Authorization": "Bearer YOUR_MCP_API_KEY"}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
可用工具:list_entities(domain 可选)、get_entity_state(entity_id)、list_services()、call_service(domain, service, service_data)。call_service 可以调用该 HA 用户有权执行的服务,包括自动化与脚本;没有实体白名单。它不提供任意路径的 HTTP 代理。服务失败会返回工具错误,不返回 HA 令牌。
|
| 64 |
+
|
| 65 |
+
MCP 采用独立 Bearer 密钥,不实现 OAuth;Agent 必须支持自定义 Authorization header。如果仅支持 OAuth 自动登录或旧版 HTTP+SSE,此配置不能直接接入。公开 Space 使用应用直接域名;私有 Space 还有 Hugging Face 平台鉴权,不能仅靠 MCP 密钥访问。
|
| 66 |
+
|
| 67 |
+
### 运行与配置迁移
|
| 68 |
+
|
| 69 |
+
Nginx 对外监听 7860;HA 改为内部 8123;MCP 仅监听 127.0.0.1:8000。Nginx 支持 HA WebSocket 和 MCP 请求转发。启动时修改 configuration.yaml 中 inline http 配置的端口与代理信任列表,保留其他 YAML,包括 !include;http: !include 形式需要先手工改为内联映射。代理信任列表改为 loopback 和 10.0.0.0/8。
|
| 70 |
+
|
| 71 |
+
首次无快照会继续初始化;Hub 恢复遇到权限或网络错误时停止,防止新空配置覆盖原备份。首次定时备份在启动约 60 秒后,此后每 10 分钟,正常停止时会尝试最后一次备份。强制终止仍可能丢失未上传配置。免费 Space 休眠时 MCP 和 HA 均不可用。
|
| 72 |
+
|
| 73 |
+
排查:401 表示 MCP 密钥未匹配;503 表示 MCP_API_KEY 未配置;工具报 HA HTTP 401 表示 HA_TOKEN 无效;工具报连接错误或代理 502 可能是 HA 尚未就绪。浏览器直接打开 /mcp 没带鉴权时返回 401 属正常现象。
|
| 74 |
+
|
| 75 |
+
## 直接上传文件到 Space
|
| 76 |
+
|
| 77 |
+
一次提交上传以下文件,保留相对路径,不需要部署压缩包:
|
| 78 |
+
|
| 79 |
+
```text
|
| 80 |
+
Dockerfile
|
| 81 |
+
README.md
|
| 82 |
+
.dockerignore
|
| 83 |
+
nginx.conf
|
| 84 |
+
mcp-proxy.conf
|
| 85 |
+
mcp_server/requirements.txt
|
| 86 |
+
mcp_server/server.py
|
| 87 |
+
scripts/start-homeassistant
|
| 88 |
+
scripts/run-space.py
|
| 89 |
+
scripts/backup-homeassistant.py
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
在 Space 的 Files 页面通过 Add file → Upload files 上传;请核对 scripts 与 mcp_server 下的文件未被放在仓库根目录。所有文件准备好后在同一次提交中保存,Space 将自动重新构建。不要上传 .venv、tests、__pycache__、.pytest_cache、.env 或旧压缩包。
|
| 93 |
+
|
| 94 |
+
构建时安装海尔智家(sfairy/ha_haier),启动脚本会将镜像中缺少的集成复制到配置目录的 custom_components 中。启动后进入 设置 → 设备与服务 → 添加集成 → 搜索海尔智家,使用中国区海尔智家账号登录。已经恢复的同名集成目录会保留,不会强制覆盖其代码或账号配置。
|
mcp-proxy.conf
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
proxy_http_version 1.1;
|
| 2 |
+
proxy_set_header Host $http_host;
|
| 3 |
+
proxy_set_header Authorization $http_authorization;
|
| 4 |
+
proxy_set_header Connection "";
|
| 5 |
+
proxy_buffering off;
|
| 6 |
+
proxy_cache off;
|
| 7 |
+
proxy_read_timeout 3600s;
|
| 8 |
+
proxy_send_timeout 3600s;
|
mcp_server/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mcp==1.30.0
|
| 2 |
+
uvicorn>=0.30,<1
|
| 3 |
+
httpx>=0.27,<1
|
mcp_server/server.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated Streamable HTTP bridge to Home Assistant."""
|
| 2 |
+
import os
|
| 3 |
+
import secrets
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
from mcp.server.fastmcp import FastMCP
|
| 8 |
+
from mcp.server.transport_security import TransportSecuritySettings
|
| 9 |
+
from starlette.responses import JSONResponse
|
| 10 |
+
|
| 11 |
+
HA_URL = os.getenv("HA_URL", "http://127.0.0.1:8123").rstrip("/")
|
| 12 |
+
HA_TOKEN = os.getenv("HA_TOKEN", "")
|
| 13 |
+
MCP_API_KEY = os.getenv("MCP_API_KEY", "")
|
| 14 |
+
mcp = FastMCP("Home Assistant", stateless_http=True, json_response=True,
|
| 15 |
+
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
|
| 16 |
+
|
| 17 |
+
async def ha_request(method: str, path: str, data: dict | None = None) -> Any:
|
| 18 |
+
if not HA_TOKEN:
|
| 19 |
+
raise ValueError("Set the HA_TOKEN Space secret to a Home Assistant long-lived token.")
|
| 20 |
+
async with httpx.AsyncClient(timeout=45, trust_env=False) as client:
|
| 21 |
+
response = await client.request(method, HA_URL + "/api/" + path,
|
| 22 |
+
headers={"Authorization": "Bearer " + HA_TOKEN}, json=data)
|
| 23 |
+
if response.is_error:
|
| 24 |
+
raise ValueError(f"Home Assistant returned HTTP {response.status_code}.")
|
| 25 |
+
return response.json()
|
| 26 |
+
|
| 27 |
+
@mcp.tool()
|
| 28 |
+
async def list_entities(domain: str | None = None) -> list[dict]:
|
| 29 |
+
"""List actual entity IDs, states and attributes. Optionally filter by domain (light, climate, etc.)."""
|
| 30 |
+
states = await ha_request("GET", "states")
|
| 31 |
+
return [s for s in states if domain is None or s["entity_id"].startswith(domain + ".")]
|
| 32 |
+
|
| 33 |
+
@mcp.tool()
|
| 34 |
+
async def get_entity_state(entity_id: str) -> dict:
|
| 35 |
+
"""Read an entity's current state and attributes using its actual entity ID."""
|
| 36 |
+
validate_name(entity_id, dotted=True)
|
| 37 |
+
return await ha_request("GET", "states/" + entity_id)
|
| 38 |
+
|
| 39 |
+
@mcp.tool()
|
| 40 |
+
async def list_services() -> list[dict]:
|
| 41 |
+
"""Discover available HA services and their parameters before controlling devices."""
|
| 42 |
+
return await ha_request("GET", "services")
|
| 43 |
+
|
| 44 |
+
@mcp.tool()
|
| 45 |
+
async def call_service(domain: str, service: str, service_data: dict[str, Any] | None = None) -> Any:
|
| 46 |
+
"""Execute a Home Assistant service. This changes devices or automations. Use actual service names and entity IDs discovered by the read tools; service_data is the HA JSON body, e.g. {"entity_id":"light.room"}. No device whitelist is applied."""
|
| 47 |
+
validate_name(domain)
|
| 48 |
+
validate_name(service)
|
| 49 |
+
return await ha_request("POST", f"services/{domain}/{service}", service_data or {})
|
| 50 |
+
|
| 51 |
+
def validate_name(value: str, dotted: bool = False):
|
| 52 |
+
import re
|
| 53 |
+
pattern = r"[a-z0-9_]+\.[a-z0-9_]+" if dotted else r"[a-z0-9_]+"
|
| 54 |
+
if not re.fullmatch(pattern, value):
|
| 55 |
+
raise ValueError("Invalid Home Assistant identifier.")
|
| 56 |
+
|
| 57 |
+
class BearerAuth:
|
| 58 |
+
def __init__(self, app):
|
| 59 |
+
self.app = app
|
| 60 |
+
|
| 61 |
+
async def __call__(self, scope, receive, send):
|
| 62 |
+
if scope["type"] == "http":
|
| 63 |
+
if not MCP_API_KEY:
|
| 64 |
+
response = JSONResponse({"error": "MCP is disabled until MCP_API_KEY is configured"}, status_code=503)
|
| 65 |
+
return await response(scope, receive, send)
|
| 66 |
+
headers = dict(scope["headers"])
|
| 67 |
+
actual = headers.get(b"authorization", b"")
|
| 68 |
+
expected = ("Bearer " + MCP_API_KEY).encode()
|
| 69 |
+
if not secrets.compare_digest(actual, expected):
|
| 70 |
+
response = JSONResponse({"error": "Unauthorized"}, status_code=401,
|
| 71 |
+
headers={"WWW-Authenticate": "Bearer"})
|
| 72 |
+
return await response(scope, receive, send)
|
| 73 |
+
await self.app(scope, receive, send)
|
| 74 |
+
|
| 75 |
+
app = BearerAuth(mcp.streamable_http_app())
|
nginx.conf
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
worker_processes 1;
|
| 2 |
+
pid /tmp/nginx.pid;
|
| 3 |
+
error_log /dev/stderr warn;
|
| 4 |
+
events { worker_connections 1024; }
|
| 5 |
+
http {
|
| 6 |
+
access_log off;
|
| 7 |
+
client_max_body_size 100m;
|
| 8 |
+
map $http_upgrade $connection_upgrade {
|
| 9 |
+
default upgrade;
|
| 10 |
+
'' close;
|
| 11 |
+
}
|
| 12 |
+
server {
|
| 13 |
+
listen 7860;
|
| 14 |
+
location = /mcp { proxy_pass http://127.0.0.1:8000; include /etc/nginx/mcp-proxy.conf; }
|
| 15 |
+
location /mcp/ { proxy_pass http://127.0.0.1:8000; include /etc/nginx/mcp-proxy.conf; }
|
| 16 |
+
location / {
|
| 17 |
+
proxy_pass http://127.0.0.1:8123;
|
| 18 |
+
proxy_http_version 1.1;
|
| 19 |
+
proxy_set_header Host $http_host;
|
| 20 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 21 |
+
proxy_set_header Connection $connection_upgrade;
|
| 22 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 23 |
+
proxy_set_header X-Forwarded-Proto https;
|
| 24 |
+
proxy_read_timeout 3600s;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
scripts/backup-homeassistant.py
CHANGED
|
@@ -48,21 +48,24 @@ def backup(config: Path) -> None:
|
|
| 48 |
|
| 49 |
|
| 50 |
def restore(config: Path) -> None:
|
|
|
|
| 51 |
try:
|
| 52 |
_, repo_id, repo_type, token = hub_api()
|
| 53 |
from huggingface_hub import hf_hub_download
|
| 54 |
archive = hf_hub_download(repo_id=repo_id, filename=ARCHIVE_NAME,
|
| 55 |
repo_type=repo_type, token=token, force_download=True)
|
| 56 |
-
except
|
| 57 |
-
print(
|
| 58 |
return
|
|
|
|
|
|
|
| 59 |
config.mkdir(parents=True, exist_ok=True)
|
| 60 |
with tarfile.open(archive, "r:gz") as tar:
|
| 61 |
root = config.resolve()
|
| 62 |
for member in tar.getmembers():
|
| 63 |
target = (config / member.name).resolve()
|
| 64 |
if root not in target.parents: raise RuntimeError(f"Unsafe backup path: {member.name}")
|
| 65 |
-
tar.extractall(config)
|
| 66 |
print(f"Restored {ARCHIVE_NAME} into {config}", flush=True)
|
| 67 |
|
| 68 |
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def restore(config: Path) -> None:
|
| 51 |
+
from huggingface_hub.errors import EntryNotFoundError
|
| 52 |
try:
|
| 53 |
_, repo_id, repo_type, token = hub_api()
|
| 54 |
from huggingface_hub import hf_hub_download
|
| 55 |
archive = hf_hub_download(repo_id=repo_id, filename=ARCHIVE_NAME,
|
| 56 |
repo_type=repo_type, token=token, force_download=True)
|
| 57 |
+
except EntryNotFoundError:
|
| 58 |
+
print("No existing Hub snapshot; starting a new configuration.", flush=True)
|
| 59 |
return
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
raise RuntimeError("Hub restore failed; refusing to overwrite the remote backup with a fresh configuration.") from exc
|
| 62 |
config.mkdir(parents=True, exist_ok=True)
|
| 63 |
with tarfile.open(archive, "r:gz") as tar:
|
| 64 |
root = config.resolve()
|
| 65 |
for member in tar.getmembers():
|
| 66 |
target = (config / member.name).resolve()
|
| 67 |
if root not in target.parents: raise RuntimeError(f"Unsafe backup path: {member.name}")
|
| 68 |
+
tar.extractall(config, filter="data")
|
| 69 |
print(f"Restored {ARCHIVE_NAME} into {config}", flush=True)
|
| 70 |
|
| 71 |
|
scripts/run-space.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prepare HA configuration and supervise the three Space services."""
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import re
|
| 5 |
+
import shutil
|
| 6 |
+
import signal
|
| 7 |
+
import subprocess
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
CONFIG = Path(os.getenv("HOME_ASSISTANT_CONFIG", "/data/home-assistant"))
|
| 11 |
+
BACKUP = "/usr/local/bin/backup-homeassistant.py"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def prepare_http(text):
|
| 15 |
+
# Change only the inline http mapping, preserving !include and other YAML elsewhere.
|
| 16 |
+
match = re.search(r"(?m)^http:[^\n]*(?:\n(?:[ \t]+[^\n]*|#[^\n]*|[ \t]*))*", text)
|
| 17 |
+
block = match.group(0) if match else "http:"
|
| 18 |
+
if block.splitlines()[0].strip() != "http:":
|
| 19 |
+
raise ValueError("http must be an inline YAML mapping in configuration.yaml; move its settings there before deployment.")
|
| 20 |
+
block = re.sub(r"(?m)^ (server_port|use_x_forwarded_for):[^\n]*\n?", "", block)
|
| 21 |
+
block = re.sub(r"(?m)^ trusted_proxies:[^\n]*(?:\n[ \t]{4,}[^\n]*)*\n?", "", block)
|
| 22 |
+
block = block.rstrip() + "\n server_port: 8123\n use_x_forwarded_for: true\n trusted_proxies:\n - 127.0.0.1\n - ::1\n - 10.0.0.0/8\n"
|
| 23 |
+
if match:
|
| 24 |
+
return text[:match.start()] + block + text[match.end():]
|
| 25 |
+
return text.rstrip() + "\n\n" + block
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def backup(action):
|
| 29 |
+
result = subprocess.run(["python3", BACKUP, action, "--config", str(CONFIG)], timeout=120)
|
| 30 |
+
if result.returncode:
|
| 31 |
+
print(f"Backup operation {action} failed; check Hub secrets and permissions.", flush=True)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
CONFIG.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
config_file = CONFIG / "configuration.yaml"
|
| 37 |
+
if not config_file.exists():
|
| 38 |
+
backup("restore")
|
| 39 |
+
text = config_file.read_text() if config_file.exists() else "default_config:\n"
|
| 40 |
+
config_file.write_text(prepare_http(text))
|
| 41 |
+
components = CONFIG / "custom_components"
|
| 42 |
+
components.mkdir(exist_ok=True)
|
| 43 |
+
for source in Path("/opt/ha-custom-components").iterdir():
|
| 44 |
+
if not (components / source.name).exists():
|
| 45 |
+
shutil.copytree(source, components / source.name)
|
| 46 |
+
|
| 47 |
+
stopping = False
|
| 48 |
+
def stop(signum, frame):
|
| 49 |
+
nonlocal stopping
|
| 50 |
+
stopping = True
|
| 51 |
+
signal.signal(signal.SIGTERM, stop)
|
| 52 |
+
signal.signal(signal.SIGINT, stop)
|
| 53 |
+
processes = []
|
| 54 |
+
exit_code = 0
|
| 55 |
+
try:
|
| 56 |
+
for command in (
|
| 57 |
+
["hass", "--config", str(CONFIG)],
|
| 58 |
+
["/opt/mcp-venv/bin/python", "-m", "uvicorn", "server:app", "--app-dir", "/opt/mcp-server", "--host", "127.0.0.1", "--port", "8000", "--no-access-log"],
|
| 59 |
+
["nginx", "-g", "daemon off;"],
|
| 60 |
+
):
|
| 61 |
+
processes.append(subprocess.Popen(command, start_new_session=True))
|
| 62 |
+
next_backup = time.monotonic() + 60
|
| 63 |
+
enabled = bool(os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID"))
|
| 64 |
+
while not stopping:
|
| 65 |
+
for process in processes:
|
| 66 |
+
if process.poll() is not None:
|
| 67 |
+
print(f"Service exited with code {process.returncode}; stopping container.", flush=True)
|
| 68 |
+
exit_code = process.returncode or 1
|
| 69 |
+
stopping = True
|
| 70 |
+
break
|
| 71 |
+
if enabled and not stopping and time.monotonic() >= next_backup:
|
| 72 |
+
try:
|
| 73 |
+
backup("backup")
|
| 74 |
+
except Exception as exc:
|
| 75 |
+
print(f"Periodic backup failed: {type(exc).__name__}", flush=True)
|
| 76 |
+
next_backup = time.monotonic() + 600
|
| 77 |
+
time.sleep(1)
|
| 78 |
+
finally:
|
| 79 |
+
for process in reversed(processes):
|
| 80 |
+
if process.poll() is None:
|
| 81 |
+
os.killpg(process.pid, signal.SIGTERM)
|
| 82 |
+
for process in processes:
|
| 83 |
+
try:
|
| 84 |
+
process.wait(timeout=25)
|
| 85 |
+
except subprocess.TimeoutExpired:
|
| 86 |
+
os.killpg(process.pid, signal.SIGKILL)
|
| 87 |
+
process.wait()
|
| 88 |
+
if os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID"):
|
| 89 |
+
try:
|
| 90 |
+
backup("backup")
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
print(f"Shutdown backup failed: {type(exc).__name__}", flush=True)
|
| 93 |
+
return exit_code
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
raise SystemExit(main())
|
scripts/start-homeassistant
CHANGED
|
@@ -1,63 +1,3 @@
|
|
| 1 |
#!/bin/sh
|
| 2 |
set -eu
|
| 3 |
-
|
| 4 |
-
CONFIG_DIR="${HOME_ASSISTANT_CONFIG:-/data/home-assistant}"
|
| 5 |
-
CUSTOM_COMPONENT="/opt/ha-custom-components/xiaomi_miot"
|
| 6 |
-
CONFIG_FILE="$CONFIG_DIR/configuration.yaml"
|
| 7 |
-
|
| 8 |
-
mkdir -p "$CONFIG_DIR/custom_components"
|
| 9 |
-
if [ ! -f "$CONFIG_FILE" ]; then
|
| 10 |
-
python3 /usr/local/bin/backup-homeassistant.py restore --config "$CONFIG_DIR" || true
|
| 11 |
-
fi
|
| 12 |
-
|
| 13 |
-
if [ ! -d "$CONFIG_DIR/custom_components/xiaomi_miot" ]; then
|
| 14 |
-
cp -a "$CUSTOM_COMPONENT" "$CONFIG_DIR/custom_components/xiaomi_miot"
|
| 15 |
-
fi
|
| 16 |
-
|
| 17 |
-
if [ ! -f "$CONFIG_FILE" ]; then
|
| 18 |
-
cat > "$CONFIG_FILE" <<'YAML'
|
| 19 |
-
default_config:
|
| 20 |
-
|
| 21 |
-
http:
|
| 22 |
-
server_port: 7860
|
| 23 |
-
use_x_forwarded_for: true
|
| 24 |
-
trusted_proxies:
|
| 25 |
-
- 10.0.0.0/8
|
| 26 |
-
YAML
|
| 27 |
-
elif ! grep -q 'use_x_forwarded_for:' "$CONFIG_FILE"; then
|
| 28 |
-
if grep -q '^ server_port: 7860' "$CONFIG_FILE"; then
|
| 29 |
-
sed -i '/^ server_port: 7860/a\ use_x_forwarded_for: true\n trusted_proxies:\n - 10.0.0.0/8' "$CONFIG_FILE"
|
| 30 |
-
else
|
| 31 |
-
cat >> "$CONFIG_FILE" <<'YAML'
|
| 32 |
-
|
| 33 |
-
http:
|
| 34 |
-
server_port: 7860
|
| 35 |
-
use_x_forwarded_for: true
|
| 36 |
-
trusted_proxies:
|
| 37 |
-
- 10.0.0.0/8
|
| 38 |
-
YAML
|
| 39 |
-
fi
|
| 40 |
-
fi
|
| 41 |
-
|
| 42 |
-
if [ -n "${HF_TOKEN:-}" ] && [ -n "${BACKUP_REPO_ID:-}" ]; then
|
| 43 |
-
(
|
| 44 |
-
while sleep 600; do
|
| 45 |
-
python3 /usr/local/bin/backup-homeassistant.py backup --config "$CONFIG_DIR" || true
|
| 46 |
-
done
|
| 47 |
-
) &
|
| 48 |
-
BACKUP_PID=$!
|
| 49 |
-
else
|
| 50 |
-
BACKUP_PID=""
|
| 51 |
-
fi
|
| 52 |
-
|
| 53 |
-
cleanup() {
|
| 54 |
-
if [ -n "$BACKUP_PID" ]; then
|
| 55 |
-
kill "$BACKUP_PID" 2>/dev/null || true
|
| 56 |
-
fi
|
| 57 |
-
if [ -n "${HF_TOKEN:-}" ] && [ -n "${BACKUP_REPO_ID:-}" ]; then
|
| 58 |
-
python3 /usr/local/bin/backup-homeassistant.py backup --config "$CONFIG_DIR" || true
|
| 59 |
-
fi
|
| 60 |
-
}
|
| 61 |
-
trap cleanup EXIT TERM INT
|
| 62 |
-
|
| 63 |
-
exec hass --config "$CONFIG_DIR"
|
|
|
|
| 1 |
#!/bin/sh
|
| 2 |
set -eu
|
| 3 |
+
exec python3 /usr/local/bin/run-space.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|