智能家居笔记Home-Assistant+小智AI
设备框架图
概述: 总体而言借用各种开源项目,致力做到好用可控。输入设备小智AI作为用户前端,后端用小智AI华南理工开源服务器。智能家庭中控采用树莓派5搭载HAOS,用homeassistant,包括手机APP。各种终端设备,支持zigbee通信协议,wifi通信协议,小米设备支持milot的设备可以连接,其他类似美的海尔的设备也看home assistant的插件支持程度,没有本身开源的使用舒适。

主机: thinkpad-S5-yoga 地址xxxxxxxxxxxxx 用户:xxxxxxxxxxx
| 服务 | 备注 | 服务端口 |
|---|---|---|
| frigate | 5000 | |
| samba | mnt/media/usbshare | |
| http | /mnt/usb_share/podcast | 10086 |
| mediamtx.service | xxxxxxxxxxx | 8554 |
| napcat | ||
| koi | ||
| miloco | ||
| micam | xxxxxxxxxxxxxx | |
| astra-color.service | ||
| zaokafei-fetch.timer | /mnt/usb_share/podcast/zaokafei | |
| bambucam.service | Desktop/bambustudio/bambucam | 5004 |
| glances.service | 61208 |
主机: MacBookAir 地址:xxxxxxxxxxxxx 用户:xxxxxxxxxxxxx
| 服务 | 备注 | 服务端口 |
|---|---|---|
| intel-color | ||
| ir | ||
| switch | Desktop/swtich_rtsp/swtich_rtsp.py | |
| mediamtx | xxxxxxxxxxxxx | 8554 |
主机: PC 地址:xxxxxxxxxxxxxxx 用户:xxxxxxxxxxx

小智ESP32嵌入式前端
开源地址:78/xiaozhi-esp32: An MCP-based chatbot | 一个基于MCP的聊天机器人
前端若需要连M3.5的扬声器接口,可换PCM5102A模块
参考链接:用 ESP32 + PCM5102 打造一个无线的HIFI音乐播放器-ZEEKLOG博客
重新配置OTA地址,开机时按boot键
小智ESP32后端服务器
连接home assistant采用了很多尝试
最先MCP连接方式: ha-mcp-for-xiaozhi/README.md at v0.1.1 · c1pher-cn/ha-mcp-for-xiaozhi
该方式能将多个SSE都连入Home assistant 封装为 function call 让xiaozhi-server调用,在home assistant端采用的是assist提供的接口
也尝试用 xiaozhi-server贡献的home assistant代码,其实用了home assisttant的API,好处是可以精简MCP,对音乐播放进行了一些修改。
Home Assistant
树莓派地址:xxxxxxxxxxxxxx
公网访问

使用frp 加载项 内网穿透至阿里云服务器,再nginx反向代理出来。
参考:Frp内网穿透使用记录 - kfzzzzzz_blog
感觉有一个bug,重启HAOS之后,自启动FRP Clinet,但是FRP Server会还在重新尝试原先的客户端,就会崩溃。因此必须开崩溃自动恢复。
网络存储
此处用部署在局域网的samba服务,用户名:kfzzzzzz

Music Assistant
使用provider模式,youtube music等使用延迟较大,因此还是本地使用方案。
歌曲下载地址
SQUID.WTF - Home
lucida | music at internet speed
使用 Filesystem provider

podcast收听地址
发现RSS真的还挺方便
使用podcast provider

链接:weekend-project-space/top-rss-list: 订阅人数最多的rss源,中文优质rss源
也可以自己抓取节目,小宇宙的音频都可以抓比较方便
抓取代码Desktop/zaokafei
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import json import os import re from datetime import datetime, timezone from email.utils import format_datetime from urllib.parse import quote import requests import xml.etree.ElementTree as ET PODCAST_URL = "https://www.xiaoyuzhoufm.com/podcast/60de7c003dd577b40d5a40f3" # 音频/封面/XML 都放这里(U 盘) OUT_DIR = "/mnt/usb_share/podcast/zaokafei" XML_PATH = os.path.join(OUT_DIR, "zaokafei.xml") # 你这台 ThinkPad 的 HTTP 服务地址(用你确认的 IP) BASE_HTTP = "http://192.168.31.75:10086" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", } def get_next_data(url: str) -> dict: """从小宇宙网页里解析 __NEXT_DATA__ JSON""" html = requests.get(url, headers=HEADERS, timeout=30).text m = re.search( r'<script type="application/json">(.*?)</script>', html, re.S, ) if not m: raise RuntimeError("没找到 __NEXT_DATA__,小宇宙页面结构可能变了") return json.loads(m.group(1)) def sanitize_filename(name: str) -> str: """文件名安全化(保留中文,但去掉不允许字符,避免太长)""" name = re.sub(r'[\\/:*?"<>|]', "_", name).strip() name = re.sub(r"\s+", " ", name) return name[:160] if len(name) > 160 else name def parse_pubdate_to_utc(pub: str) -> datetime: """ 解析小宇宙 pubDate(通常 ISO8601,可能带 Z) 返回 timezone-aware UTC datetime """ try: if pub.endswith("Z"): dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) else: dt = datetime.fromisoformat(pub) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) except Exception: # 解析失败就用当前时间兜底(不影响下载,只影响 pubDate) return datetime.now(timezone.utc) def download_file(url: str, path: str): """流式下载,先写 .part 再原子替换""" r = requests.get(url, headers=HEADERS, stream=True, timeout=180) r.raise_for_status() tmp = path + ".part" with open(tmp, "wb") as f: for chunk in r.iter_content(chunk_size=1024 * 256): if chunk: f.write(chunk) os.replace(tmp, path) def ensure_xml_exists(): if os.path.exists(XML_PATH): return raise RuntimeError( f"找不到 {XML_PATH}\n" f"请先把 zaokafei.xml 放到 {OUT_DIR} 下(至少包含 <rss><channel>...</channel></rss>)" ) def xml_has_guid(root: ET.Element, guid: str) -> bool: for item in root.findall("./channel/item"): g = item.findtext("guid") if g and g.strip() == guid: return True return False def add_item_to_xml( title: str, guid: str, pub_dt_utc: datetime, enclosure_url: str, length_bytes: int, ): """ 把新一期插入到 XML 的最前面(靠前显示) 仅写入最必要字段:title/pubDate/guid/enclosure """ ET.register_namespace("itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd") tree = ET.parse(XML_PATH) root = tree.getroot() channel = root.find("channel") if channel is None: raise RuntimeError("XML 里没找到 <channel>") if xml_has_guid(root, guid): print("XML 已存在该期 guid,跳过写入:", guid) return item = ET.Element("item") t = ET.SubElement(item, "title") t.text = title pub = ET.SubElement(item, "pubDate") pub.text = format_datetime(pub_dt_utc) # RFC 2822 g = ET.SubElement(item, "guid", attrib={"isPermaLink": "false"}) g.text = guid ET.SubElement( item, "enclosure", attrib={ "url": enclosure_url, "length": str(length_bytes), "type": "audio/mp4", }, ) # 插到最前面:放在第一个 item 前 first_item = channel.find("item") if first_item is None: channel.append(item) else: children = list(channel) idx = children.index(first_item) channel.insert(idx, item) tree.write(XML_PATH, encoding="UTF-8", xml_declaration=True) print("已更新 XML:", XML_PATH) def main(): os.makedirs(OUT_DIR, exist_ok=True) ensure_xml_exists() data = get_next_data(PODCAST_URL) podcast = data["props"]["pageProps"]["podcast"] latest = podcast["episodes"][0] eid = latest["eid"] title = latest["title"] pub_raw = latest.get("pubDate", "") # 音频 URL 兼容两种字段 audio_url = ( latest.get("media", {}).get("source", {}).get("url") or latest.get("enclosure", {}).get("url") ) if not audio_url: raise RuntimeError("没找到 media.source.url / enclosure.url") pub_dt_utc = parse_pubdate_to_utc(pub_raw) date_str = pub_dt_utc.strftime("%Y-%m-%d") safe_title = sanitize_filename(title) filename = f"{date_str} - {safe_title}.m4a" out_path = os.path.join(OUT_DIR, filename) # 先判断 XML 是否已经有 guid:有的话可以直接结束(更省事) # 但为了保险(避免 XML 手动改动),我们仍然会检查文件是否存在。 print("最新一期:", title) print("EID:", eid) print("发布时间(UTC):", pub_dt_utc.isoformat()) print("音频:", audio_url) if os.path.exists(out_path): print("文件已存在,跳过下载:", out_path) else: download_file(audio_url, out_path) print("下载完成:", out_path) # URL 编码文件名(中文/空格更稳) enclosure_url = f"{BASE_HTTP}{quote('/' + filename)}" length_bytes = os.path.getsize(out_path) add_item_to_xml( title=title, guid=eid, pub_dt_utc=pub_dt_utc, enclosure_url=enclosure_url, length_bytes=length_bytes, ) if __name__ == "__main__": main() 然后放一个xml文件
<?xml version='1.0' encoding='UTF-8'?> <rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> <channel> <title>声动早咖啡</title> <link>http://192.168.31.75:10086/</link> <description> 一个十五分钟的晨间仪式,轻松同步日常生活与商业世界。 工作日早晨更新,来自声动活泼的清晨播客。 </description> <itunes:image href="http://192.168.31.75:10086/cover.png" /> <image> <url>http://192.168.31.75:10086/cover.png</url> <title>声动早咖啡</title> <link>http://192.168.31.75:10086/</link> </image> <item><title>咖啡豆|18 世纪就被誉为「全球最好」的美利奴羊毛,为何最近在国内热度大增?</title><pubDate>Thu, 08 Jan 2026 23:00:00 +0000</pubDate><guid isPermaLink="false">695fce90d4b8fa56f5f949fe</guid><enclosure url="http://192.168.31.75:10086/2026-01-08%20-%20%E5%92%96%E5%95%A1%E8%B1%86%EF%BD%9C18%20%E4%B8%96%E7%BA%AA%E5%B0%B1%E8%A2%AB%E8%AA%89%E4%B8%BA%E3%80%8C%E5%85%A8%E7%90%83%E6%9C%80%E5%A5%BD%E3%80%8D%E7%9A%84%E7%BE%8E%E5%88%A9%E5%A5%B4%E7%BE%8A%E6%AF%9B%EF%BC%8C%E4%B8%BA%E4%BD%95%E6%9C%80%E8%BF%91%E5%9C%A8%E5%9B%BD%E5%86%85%E7%83%AD%E5%BA%A6%E5%A4%A7%E5%A2%9E%EF%BC%9F.m4a" length="14036410" type="audio/mp4" /></item><item><title>图拉斯|必胜客推出独立汉堡门店,外卖大战补贴降温</title><pubDate>Wed, 07 Jan 2026 23:00:00 +0000</pubDate><guid isPermaLink="false">695e474fc1e012a7abdcfb1e</guid><enclosure url="http://192.168.31.75:10086/2026-01-07%20-%20%E5%9B%BE%E6%8B%89%E6%96%AF%EF%BD%9C%E5%BF%85%E8%83%9C%E5%AE%A2%E6%8E%A8%E5%87%BA%E7%8B%AC%E7%AB%8B%E6%B1%89%E5%A0%A1%E9%97%A8%E5%BA%97%EF%BC%8C%E5%A4%96%E5%8D%96%E5%A4%A7%E6%88%98%E8%A1%A5%E8%B4%B4%E9%99%8D%E6%B8%A9.m4a" length="16226181" type="audio/mp4" /></item><item><title>没有鱼子酱饮食传统的中国,为何成为全球鱼子酱的主要产地?</title><pubDate>Tue, 06 Jan 2026 23:00:00 +0000</pubDate><guid isPermaLink="false">695d07efc1e012a7abad496a</guid><enclosure url="http://192.168.31.75:10086/2026-01-06%20-%20%E6%B2%A1%E6%9C%89%E9%B1%BC%E5%AD%90%E9%85%B1%E9%A5%AE%E9%A3%9F%E4%BC%A0%E7%BB%9F%E7%9A%84%E4%B8%AD%E5%9B%BD%EF%BC%8C%E4%B8%BA%E4%BD%95%E6%88%90%E4%B8%BA%E5%85%A8%E7%90%83%E9%B1%BC%E5%AD%90%E9%85%B1%E7%9A%84%E4%B8%BB%E8%A6%81%E4%BA%A7%E5%9C%B0%EF%BC%9F.m4a" length="14320957" type="audio/mp4" /></item><item><title>山姆中国付费会员首次破千万,泡泡玛特将加速海外扩张</title><pubDate>Mon, 05 Jan 2026 23:00:00 +0000</pubDate><guid isPermaLink="false">695bafd9bdbeb6a09ee00719</guid><enclosure url="http://192.168.31.75:10086/2026-01-05%20-%20%E5%B1%B1%E5%A7%86%E4%B8%AD%E5%9B%BD%E4%BB%98%E8%B4%B9%E4%BC%9A%E5%91%98%E9%A6%96%E6%AC%A1%E7%A0%B4%E5%8D%83%E4%B8%87%EF%BC%8C%E6%B3%A1%E6%B3%A1%E7%8E%9B%E7%89%B9%E5%B0%86%E5%8A%A0%E9%80%9F%E6%B5%B7%E5%A4%96%E6%89%A9%E5%BC%A0.m4a" length="11324711" type="audio/mp4" /></item><item><title>频繁发射火箭的 SpaceX 今年上市,为什么星链服务是其价值关键?</title><pubDate>Sun, 04 Jan 2026 23:00:00 +0000</pubDate><guid isPermaLink="false">695a8775b9fb62614108f66e</guid><enclosure url="http://192.168.31.75:10086/2026-01-04%20-%20%E9%A2%91%E7%B9%81%E5%8F%91%E5%B0%84%E7%81%AB%E7%AE%AD%E7%9A%84%20SpaceX%20%E4%BB%8A%E5%B9%B4%E4%B8%8A%E5%B8%82%EF%BC%8C%E4%B8%BA%E4%BB%80%E4%B9%88%E6%98%9F%E9%93%BE%E6%9C%8D%E5%8A%A1%E6%98%AF%E5%85%B6%E4%BB%B7%E5%80%BC%E5%85%B3%E9%94%AE%EF%BC%9F.m4a" length="15438817" type="audio/mp4" /></item></channel> 把自己的文件夹地址代理出来就可以
Frigate
摄像头管理NVR,目前我是跑在I5-5500U,M840上,其实无法硬解H265视频,并且做detect其实也比较吃力。
官方地址:Frigate中文文档 - Frigate 中文文档
视频硬解适配

intel cpu:

nvidia:Video Encode and Decode Support Matrix | NVIDIA Developer
在我的配置中,用vappi对H264硬解,对H265软解
hwaccel_args: preset-vaapi 有时候会报错,需要指定设备及视频格式
hwaccel_args: - -hwaccel - vaapi - -hwaccel_device - /dev/dri/renderD128 - -hwaccel_output_format - yuv420p 视频推流
rtsp推流,但是用qsv进行硬编码。
各种嵌入式设备
Marshall音箱
因为该音箱是一个有源音箱,连接方式有蓝牙和3.5mm连接两种
music assistant 安装 snapcast provider

在终端设备安装snapcast 客户端
甲醛检测仪
使用ZH08 CH2O传感器,是电化学传感器


具体代码
ze08-ch2o.yaml
esphome: name: ze08-ch2o friendly_name: ZE08 CH2O esp32: board: esp32dev framework: type: arduino wifi: ssid: "xxxxxxxxxxxxx" password: "xxxxxxxxxxx" logger: baud_rate: 0 api: ota: - platform: esphome external_components: - source: type: local path: components components: [ ze08_ch2o ] uart: id: uart_ze08 rx_pin: GPIO3 # 板子上 RX tx_pin: GPIO1 # 板子上 TX baud_rate: 9600 data_bits: 8 parity: NONE stop_bits: 1 ze08_ch2o: id: ze08_sensor uart_id: uart_ze08 ch2o_ppb: name: "CH2O (ppb)" ch2o_mg_m3: name: "CH2O (mg/m³)" sensor: - platform: template name: dummy_sensor id: dummy_sensor lambda: |- return 0.0; update_interval: 1h internal: true init.py
import esphome.codegen as cg import esphome.config_validation as cv from esphome.components import uart, sensor from esphome.const import CONF_ID ze08_ns = cg.esphome_ns.namespace("ze08_ch2o") ZE08CH2OUart = ze08_ns.class_("ZE08CH2OUart", cg.Component, uart.UARTDevice) CONF_PPB = "ch2o_ppb" CONF_MGM3 = "ch2o_mg_m3" CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(ZE08CH2OUart), cv.Required(CONF_PPB): sensor.sensor_schema( unit_of_measurement="ppb", accuracy_decimals=0, icon="mdi:molecule", ), cv.Required(CONF_MGM3): sensor.sensor_schema( unit_of_measurement="mg/m³", accuracy_decimals=3, icon="mdi:molecule", ), } ).extend(uart.UART_DEVICE_SCHEMA).extend(cv.COMPONENT_SCHEMA) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) ppb = await sensor.new_sensor(config[CONF_PPB]) mgm3 = await sensor.new_sensor(config[CONF_MGM3]) cg.add(var.set_sensors(ppb, mgm3)) ze08_ch2o_uart.h
#pragma once #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" #include "esphome/components/sensor/sensor.h" namespace esphome { namespace ze08_ch2o { class ZE08CH2OUart : public Component, public uart::UARTDevice { public: ZE08CH2OUart() : uart::UARTDevice(nullptr) {} explicit ZE08CH2OUart(uart::UARTComponent *parent) : uart::UARTDevice(parent) {} void set_sensors(sensor::Sensor *ppb, sensor::Sensor *mgm3) { ch2o_ppb_ = ppb; ch2o_mg_m3_ = mgm3; } void loop() override { while (available()) { uint8_t b = read(); if (idx_ == 0) { if (b != 0xFF) continue; buf_[idx_++] = b; continue; } buf_[idx_++] = b; if (idx_ >= 9) { parse_frame_(); idx_ = 0; } } } protected: sensor::Sensor *ch2o_ppb_{nullptr}; sensor::Sensor *ch2o_mg_m3_{nullptr}; uint8_t buf_[9]{0}; uint8_t idx_{0}; void parse_frame_() { // FF 17 04 dec hi lo fs_hi fs_lo checksum if (buf_[0] != 0xFF) return; if (buf_[1] != 0x17) return; // CH2O if (buf_[2] != 0x04) return; // ppb uint16_t sum = 0; for (int i = 1; i <= 7; i++) sum += buf_[i]; uint8_t cs = (uint8_t)(~(sum & 0xFF) + 1); if (cs != buf_[8]) return; uint16_t ppb = ((uint16_t) buf_[4] << 8) | buf_[5]; float mgm3 = (ppb / 1000.0f) * 1.25f; if (ch2o_ppb_) ch2o_ppb_->publish_state(ppb); if (ch2o_mg_m3_) ch2o_mg_m3_->publish_state(mgm3); } }; } // namespace ze08_ch2o } // namespace esphome 刷机页面
IntelRealsense摄像头/astra_color摄像头
采用qsv硬编码

夜视模式根据灰度直方图判断自动切换切换夜里用IR流和RGB流,同一个流输出

小米4C摄像头
由于小米摄像头本身无提供RTSP视频流,使用小米官方开源的miloco项目接受websocket流,之后三方的micam将此流转换为rtsp视频流,官方用go2rtc推流,我这边改用了mediamtx,另外小米4C为H265编码,docker运行
miloco
开源地址:XiaoMi/xiaomi-miloco: Xiaomi Miloco
micam
拓竹A1 mini
使用插件:Integration Overview
根据官方配置,能进行的操作似乎蛮多
开启局域网模式以后获取视频流方法
链接:jtessler/bambucam: Linux command line tool to access camera feed from a Bambu 3D printer
但我是A1 mini 似乎智能获取HTTP视频流,效果如下

ESPHOME排插/MQTT插座/zigbee插座
淘宝购入zigbee信号接收器插在跑HA的树莓派上

在homeassistant/configuration.yaml 配置

终端设备淘宝非常多,几种通信协议的设备都能够支持,参考店家说明配置没啥难度

Tasmota设置Set’Option 为OFF时为switch,设置为ON时为light