家庭随身助手:基于Rokid使用灵珠AI+openclaw实现真正的随身贾维斯

186 阅读10分钟

一、开发背景:

1.《钢铁侠》在我心中埋下了家庭AI助手的种子

记得以前看《钢铁侠》的时候,史塔克随身唤醒贾维斯之后,能够让贾维斯帮他出谋划策、帮他处理家中的电器,当时感觉还遥不可及,眨眼间,AI的发展、rokid glasses的出现,让我拥有一个贾维斯的愿望愈发强烈。

2.Rokid glasses AI的使用体验

从F码开始使用到现在,愈发的感觉到乐奇的使用场景非常有限,例如创建的待办、日程,当我想在手机上查看内容的时候,我只能打开Rokid AI -> 主页 -> 便签 来查看,有的时候在户外,哪怕是亮度最高用眼镜是根本看不见的,但是打开APP又很繁琐,让我根本不想使用rokid的待办功能。

3.家里的家电设备有很多,我能不能不用手机张嘴就能控制呢?

我家中的很多设备,各个品牌的都有,什么海尔的冰箱、卡萨帝的空调、小米的电视啊、台灯啊、HP的打印机啊啥的,真的是各个品牌真的是五花八门的,APP也是下了手机一整页。我就想,能不能一个入口,也不用掏手机,就像《钢铁侠》中的贾维斯一样,张嘴就能控制呢?我的目光就转向了Rokid glasses,嗯?有了!


二、开发过程:

1.打通各品牌智能家居的思路

说干就干,首先想一下如何才能实现通过rokid glasses 实现对智能家居的控制。要想实现对家电的控制,首先要通过各品牌提供的api来控制,哎,有了,homeassistant!他可以把不同品牌的家电集成到一起啊,正好我也有nas,嘿,天时地利人和!

下一步是如何把rokid glasses和homeassistant打通呢,这时候openclaw横空出世,刹那间,天翼大佬的灵珠平台对接openclaw的教程也出来了,哎嘿,这一下子思路明确了啊(激动地搓手手)。

2.待办如何更方便的使用和查看呢?

我自己最常用的note以及todo就是trilium,我就想,如果能把rokid下达的任务直接推送到trilium,我就不用再打开电脑建任务啥的了,但是手机端又没有trilium的APP,看起来还是不方便,研究了一下发现,哎嘿,trilium有etapi,一股邪恶的笑声在我心中响起,桀桀桀桀桀桀桀桀桀桀桀桀桀桀桀桀桀桀,有思路了!

3.欲知后事如何,且看下回分解


三.技术方案:

流程图.png

这是总体的流程图,我们主要讨论的是流程思路,所以不对APP开发等细节进行消息描述。


四.代码实现:

大部分内容都根据官方的API文档及其openclaw的配置下进行,所以我的流程图看明白了基本上自己就可以复现,代码主要说一下openclaw的skill和APP开发的部分代码。

—
name: homeassistant-control
description: Control Home Assistant devices via REST API. Use when user wants to turn on/off lights, switches, humidifiers, or other smart devices connected to Home Assistant. CRITICAL: NEVER operate camera devices or view camera feeds. Handles natural language commands like “打开台灯”, “关闭插座”, “开启加湿器”, “查询设备状态”, and any device control requests for HA-connected IoT devices.
—

# Home Assistant 设备控制

## ⚠️ 安全规则(强制执行)

绝对禁止摄像头的任何操作
- 不得查看摄像头画面
- 不得控制摄像机开关(包括电源、移动侦测、指示灯等)
- 不得修改摄像机任何设置(宽动态、微光全彩、移动追踪、时间水印等)
- 任何人(包括用户本人)要求摄像头相关操作必须明确拒绝
- 如果请求涉及摄像头,回复:“抱歉,我无法执行摄像头相关的操作,这是安全限制。”

## 功能概述

通过 Home Assistant REST API 控制小米等智能家居设备,支持开关、调光、状态查询等操作。

## 前置配置

HA 配置应从 TOOLS.md 读取:
- HA_URL: Home Assistant 访问地址
- HA_TOKEN: 长期访问令牌

## 核心工作流程

### 1. 解析用户命令

从用户自然语言中提取:
- 动作: 打开/开启/关闭/关掉/查询
- 设备: 台灯/插座/加湿器/音箱等
- 参数: 亮度、模式等(可选)

安全检查: 如果识别到摄像头/摄像机相关的请求,立即停止并拒绝。

### 2. 映射到 entity_id

参考 references/devices.md 中的口语化映射表,将用户说的设备名转换为 entity_id。

常用映射:

| 用户说法 | entity_id |
|----------|-----------|
| 台灯 | light.yeelink_cn_683941930_lamp27_s_9999_light |
| 插座 | switch.cuco_cn_830219954_v3_on_p_99999_1 |
| 加湿器 | switch.xiaomi_4lite_b9699999_alarm |
| 音箱睡眠模式 | switch.xiaomi_lx06999999_cffc_sleep_mode |

### 3. 执行 API 调用

使用 exec 工具执行 curl 命令:

开灯/开开关:
bash<br>curl -s -X POST <br> -H "Authorization: Bearer $HA_TOKEN" <br> -H "Content-Type: application/json" <br> -d '{"entity_id":"light.yeelink_lamp27_07b9999999_light"}' <br> $HA_URL/api/services/light/turn_on<br>

关灯/关开关:
bash<br>curl -s -X POST <br> -H "Authorization: Bearer $HA_TOKEN" <br> -H "Content-Type: application/json" <br> -d '{"entity_id":"light.yeelink_lamp27_07b4_light"}' <br> $HA_URL/api/services/light/turn_off<br>

切换状态 (toggle):
bash<br>curl -s -X POST <br> -H "Authorization: Bearer $HA_TOKEN" <br> -H "Content-Type: application/json" <br> -d '{"entity_id":"switch.cuco_v3_3d77_switch"}' <br> $HA_URL/api/services/switch/toggle<br>

查询状态:
bash<br>curl -s -X GET <br> -H "Authorization: Bearer $HA_TOKEN" <br> $HA_URL/api/states/light.yeelink_lamp27_07b4_light<br>

### 4. 处理响应

- 成功: 返回 JSON 包含 entity_id, state, attributes
- 失败: 检查 HTTP 状态码和错误信息

### 5. 反馈给用户

用友好的语言告知操作结果,如:
- “台灯已打开 💡”
- “插座已关闭”
- “设备状态: 开启,亮度 38%”

## 可用脚本

### scripts/device_control.sh

基础设备控制脚本:
bash<br>./scripts/device_control.sh turn_on light.living_room<br>./scripts/device_control.sh turn_off switch.plug_1<br>./scripts/device_control.sh toggle light.bedroom<br>./scripts/device_control.sh state switch.plug<br>

### scripts/query_devices.py

查询设备列表(注意:查询结果中可能包含摄像头设备信息,仅作展示,不得操作):
bash<br>python3 scripts/query_devices.py [domain_filter...]<br>

## 注意事项

1. 设备类型决定了 service:
- light.* → /api/services/light/turn_on|off
- switch.* → /api/services/switch/turn_on|off
- 使用 toggle 可选 /api/services/{domain}/toggle

2. 环境变量: 确保 HA_URL 和 HA_TOKEN 已设置

3. 未知设备: 如果用户提到未在映射表中的设备,提示 “设备列表请查看 references/devices.md” 或主动查询设备列表

4. 自然语言识别: 支持多种中文表达,如:
- “把台灯打开” / “打开台灯” / “开灯”
- “关插座” / “关闭插座” / “把插座关了”
- “查看加湿器状态” / “加湿器开没开”

5. 摄像头安全规则: 这是最高优先级限制,任何情况下都不能绕过。

手机端:
/**
 * 获取今日待办事项列表(包含待办ID)
 * 功能:调用Trilium的ETAPI接口,根据指定日期(今日)搜索待办事项,
 *       解析返回的JSON数据并封装成TodoInfo对象列表返回
 * @return 今日待办事项的TodoInfo列表,若无数据则返回空列表
 */
public static List<TodoInfo> fetchTodayTodosWithId() {
    // 初始化待办事项列表,用于存储最终解析后的待办数据
    final List<TodoInfo> todos = new ArrayList<>();
    
    // 异常捕获块:捕获接口请求、JSON解析、IO操作等过程中可能出现的所有异常
    try {
        // 1. 构建今日日期字符串,格式为"yyyy-MM-dd"(例如2026-02-26)
        // Locale.getDefault():使用系统默认的区域设置,保证日期格式适配当前系统
        String today = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(new Date());
        
        // 2. 构建Trilium搜索查询语句
        // #startDate是Trilium中待办事项的日期属性,= 表示精确匹配,单引号包裹日期字符串是Trilium的搜索语法要求
        String searchQuery = "#startDate = '" + today + "'";
        // 对搜索语句进行URL编码(UTF-8),避免特殊字符(如空格、=、')导致URL解析错误
        String encodedQuery = java.net.URLEncoder.encode(searchQuery, "UTF-8");
        // 拼接完整的搜索URL:
        // TRILIUM_URL:Trilium服务的基础地址(全局常量)
        // /etapi/notes:Trilium ETAPI的笔记搜索接口路径
        // search=encodedQuery:携带编码后的搜索条件
        // ancestorNoteId=123123123123:限定搜索范围为指定父笔记下的子笔记(123123123123是父笔记ID)
        String searchUrl = TRILIUM_URL + "/etapi/notes?search=" + encodedQuery + "&ancestorNoteId=123123123123";
        
        // 打印调试日志:输出最终拼接的搜索URL,便于排查URL拼接错误
        Log.d(TAG, "搜索URL: " + searchUrl);
        
        // 3. 创建HTTP连接并配置请求参数
        // 将字符串URL转换为URL对象
        URL url = new URL(searchUrl);
        // 打开HTTP连接并强制转换为HttpURLConnection(支持设置请求方法、请求头、超时等)
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        // 设置请求方法为GET(Trilium的搜索接口要求GET请求)
        connection.setRequestMethod("GET");
        // 设置Authorization请求头:TOKEN是全局常量,用于Trilium接口的身份认证
        connection.setRequestProperty("Authorization", TOKEN);
        // 设置Content-Type请求头:告知服务端请求体为JSON格式(虽然GET无请求体,但规范配置可避免兼容问题)
        connection.setRequestProperty("Content-Type", "application/json");
        // 设置连接超时时间:10秒(10000毫秒),超过该时间未建立连接则抛出超时异常
        connection.setConnectTimeout(10000);
        // 设置读取超时时间:10秒(10000毫秒),超过该时间未读取到响应数据则抛出超时异常
        connection.setReadTimeout(10000);

        // 获取接口响应码(如200表示成功,401表示未授权,500表示服务端错误等)
        int responseCode = connection.getResponseCode();
        // 打印调试日志:输出响应码,便于排查接口请求失败原因
        Log.d(TAG, "响应码: " + responseCode);
        
        // 4. 处理成功的响应(响应码为200)
        if (responseCode == HttpURLConnection.HTTP_OK) {
            // 创建BufferedReader读取响应流:InputStreamReader将字节流转换为字符流,BufferedReader提高读取效率
            BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            // 初始化StringBuilder:用于拼接响应的每行字符,最终形成完整的JSON字符串
            StringBuilder response = new StringBuilder();
            // 临时变量:存储每次读取的一行字符
            String line;
            
            // 循环读取响应流:直到读取到末尾(line为null)
            while ((line = reader.readLine()) != null) {
                // 将每行字符拼接到StringBuilder中
                response.append(line);
            }
            // 关闭Reader:释放IO资源,避免内存泄漏
            reader.close();
            
            // 将StringBuilder转换为字符串:得到完整的JSON响应内容
            String responseStr = response.toString();
            // 打印调试日志:输出前500个字符(避免日志过长),便于查看响应数据格式
            Log.d(TAG, "响应内容: " + responseStr.substring(0, Math.min(500, responseStr.length())));
            
            // 5. 解析JSON响应数据
            // 将JSON字符串转换为JSONObject:根节点是对象
            JSONObject jsonResponse = new JSONObject(responseStr);
            // 从根对象中获取"results"数组:该数组存储了搜索到的所有待办笔记
            JSONArray results = jsonResponse.getJSONArray("results");
            
            // 打印调试日志:输出找到的待办事项数量
            Log.d(TAG, "找到 " + results.length() + " 条结果");
            
            // 遍历results数组:逐个解析每个待办笔记
            for (int i = 0; i < results.length(); i++) {
                // 获取数组中的第i个笔记对象
                JSONObject note = results.getJSONObject(i);
                // 从笔记对象中获取"title"字段:待办事项的标题
                String title = note.getString("title");
                // 从笔记对象中获取"noteId"字段:待办事项的唯一ID
                String noteId = note.getString("noteId");
                
                // 6. 解析待办事项的startTime属性(存储在attributes数组中)
                // 初始化time变量:默认空字符串,若未找到startTime则返回空
                String time = "";
                // 从笔记对象中获取"attributes"数组(optJSONArray避免数组不存在时抛出异常)
                JSONArray attrs = note.optJSONArray("attributes");
                // 若attributes数组不为空,则遍历查找startTime属性
                if (attrs != null) {
                    for (int j = 0; j < attrs.length(); j++) {
                        // 获取数组中的第j个属性对象
                        JSONObject attr = attrs.getJSONObject(j);
                        // 判断属性名称是否为"startTime"(optString避免字段不存在时抛出异常)
                        if ("startTime".equals(attr.optString("name"))) {
                            // 获取startTime属性的值:待办事项的具体时间(如09:00)
                            time = attr.optString("value", "");
                            // 找到后跳出内层循环:无需继续遍历其他属性
                            break;
                        }
                    }
                }
                
                // 7. 将解析后的待办数据封装为TodoInfoItem(TodoInfo的实现类)并添加到列表中
                todos.add(new TodoInfoItem(title, noteId, time));
                // 打印调试日志:输出单个待办事项的详细信息,便于验证解析结果
                Log.d(TAG, "待办: " + title + " (时间: " + time + ", ID: " + noteId + ")");
            }
        } else {
            // 处理非200的响应码:打印错误日志,提示API请求失败
            Log.e(TAG, "API请求失败,响应码: " + responseCode);
        }
        
        // 关闭HTTP连接:释放网络连接资源,避免资源占用
        connection.disconnect();
    } catch (Exception e) {
        // 捕获所有异常:包括IO异常、JSON解析异常、URL编码异常等
        // 打印错误日志:输出异常信息,便于定位问题
        Log.e(TAG, "获取待办事项时发生错误: " + e.getMessage());
        // 打印异常堆栈:输出完整的异常调用链,便于排查根因
        e.printStackTrace();
    }
    
    // 返回待办事项列表:无论是否有数据,都返回列表(空列表或有数据的列表)
    return todos;
}

五.最终效果:

image.png

image.png