From ae42e486deef74a00bd748ce0c2470d1c7675ec9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 25 Aug 2026 22:02:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(pluginmgr):=20=E6=8F=92=E4=BB=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=8F=A3=EF=BC=88upgrade/downgrade=20?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=85=8D=E7=BD=AE=EF=BC=89+=20skill=5Finstal?= =?UTF-8?q?l=20overwrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核 Registry 拆出 StopAndUnload: - 停止并从注册表移除插件但保留 config_ 表 - 不触发 onRemove 回调(那是删除专用语义) - RemovePlugin 改为追加清理配置表示清除,更新场景调 StopAndUnload pluginmgr: - installFromData/installFromURL/installFromPath 加 overwrite 参数 - 已存在+overwrite=true:StopAndUnload→备份旧目录→解压新包→失败回滚→ 返回 action=upgraded/downgraded/reinstalled+previous_version+config_kept - 已存在+overwrite=false:返回 error+hint(指向 overwrite 用法) - cmpVersion 点分版本号数字比较(非字典序) - 测试覆盖:首次安装→重装拒绝→升级保留配置→降级→失败回滚 skill_install 加 overwrite 参数: - 同名技能存在时先卸载旧实例+删除目录再安装新包 SDK PluginMgr 接口同步加 StopAndUnload(name string) error 工具链 plugindev 已重建到 /usr/local/bin(7/29→8/25 版本) QQ 插件诊断日志版(webhook recv 到达+isAtBot 失败日志)已打包并 通过 upgrade 接口热更新部署,配置保留验证通过。 --- internal/agent/api/router_test.go | 1 + internal/plugin/registry.go | 34 +- internal/plugins/pluginmgr/plugin.go | 140 +- internal/plugins/pluginmgr/upgrade_test.go | 219 +++ internal/plugins/skillmgr/tools.go | 31 +- internal/plugins/webui/handler_test.go | 8 +- internal/sdk/plugin.go | 6 +- third_party/homeagent-sdk/meta/meta.go | 4 +- .../homeagent-sdk/remotedevice/CMakeLists.txt | 116 ++ .../remotedevice/include/ha_remotedevice.h | 216 +++ .../homeagent-sdk/remotedevice/src/ha_json.c | 369 ++++ .../homeagent-sdk/remotedevice/src/ha_json.h | 107 ++ .../remotedevice/src/ha_remotedevice.c | 628 +++++++ .../homeagent-sdk/remotedevice/src/ha_ws.c | 325 ++++ .../homeagent-sdk/remotedevice/src/ha_ws.h | 62 + .../remotedevice/test/test_ha_remotedevice.c | 1509 +++++++++++++++++ third_party/homeagent-sdk/sdk/plugin.go | 5 + 17 files changed, 3752 insertions(+), 28 deletions(-) create mode 100644 internal/plugins/pluginmgr/upgrade_test.go create mode 100644 third_party/homeagent-sdk/remotedevice/CMakeLists.txt create mode 100644 third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h create mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_json.c create mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_json.h create mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c create mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_ws.c create mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_ws.h create mode 100644 third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c diff --git a/internal/agent/api/router_test.go b/internal/agent/api/router_test.go index daaf07d..9dd412e 100644 --- a/internal/agent/api/router_test.go +++ b/internal/agent/api/router_test.go @@ -24,6 +24,7 @@ func (s *stubRoutableProvider) Chat(context.Context, *CompletionRequest) (*Compl func (s *stubRoutableProvider) ChatStream(context.Context, *CompletionRequest) (<-chan StreamChunk, error) { ch := make(chan StreamChunk, 1) ch <- StreamChunk{Done: true} + close(ch) // 流契约:发送完毕必须关闭 channel(accumulateStream 以此为终止条件) return ch, nil } diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index b6bf3b9..39c2c90 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -724,9 +724,41 @@ func (r *Registry) DisablePlugin(name, by string) error { func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) } +// StopAndUnload 停止并从注册表移除插件,但保留其配置表(config_)。 +// 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。 +// 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。 +func (r *Registry) StopAndUnload(name string) error { + r.mu.Lock() + var unloaded sdk.Plugin + p, ok := r.plugins[name] + if ok { + r.runStopHandlers(name) + if err := p.Stop(); err != nil { + log.Printf("[plugin] stop %s for unload: %v", name, err) + } + delete(r.plugins, name) + delete(r.sdkRefs, name) + for i, inst := range r.instances { + if inst.Name() == name { + r.instances = append(r.instances[:i], r.instances[i+1:]...) + break + } + } + unloaded = p + } + r.mu.Unlock() + + if r.toolCleaner != nil { + r.toolCleaner.UnregisterPluginTools(name) + } + r.closeDynamic(unloaded) + log.Printf("[plugin] unloaded (config kept): %s", name) + return nil +} + // RemovePlugin 卸载插件:先停止(stop handlers + Stop),再执行插件注册的 onRemove // 回调(删除专用,重载不触发),最后从注册表移除并清理禁用/工具注册/配置。 -// 插件目录的物理删除由调用方(pluginmgr)负责。 +// 插件目录的物理删除由调用方(pluginmgr)负责。更新场景请用 StopAndUnload。 func (r *Registry) RemovePlugin(name string) error { r.mu.Lock() var removed sdk.Plugin diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index b5ee84f..7f683c3 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -137,7 +138,7 @@ func (p *Plugin) Stop() error { func (p *Plugin) registerTools(s *sdk.PluginSDK) { s.RegisterTool("plugin_install", sdk.ToolDef{ Name: "plugin_install", - Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。安装后需调用 plgreload 或重启生效。", + Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -145,6 +146,10 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { "type": "string", "description": "插件包的下载 URL", }, + "overwrite": map[string]interface{}{ + "type": "boolean", + "description": "已存在时原地更新(保留配置)。默认 false", + }, }, "required": []string{"url"}, }, @@ -153,7 +158,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { if url == "" { return map[string]interface{}{"error": "url is required"}, nil } - return p.installFromURL(url) + overwrite, _ := args["overwrite"].(bool) + return p.installFromURL(url, overwrite) }) s.RegisterTool("plugin_list", sdk.ToolDef{ @@ -247,8 +253,9 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "application/json") { var body struct { - URL string `json:"url"` - Path string `json:"path"` + URL string `json:"url"` + Path string `json:"path"` + Overwrite bool `json:"overwrite"` // 已存在时原地更新(保留配置) } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) @@ -256,14 +263,14 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) { } switch { case body.URL != "": - result, err := p.installFromURL(body.URL) + result, err := p.installFromURL(body.URL, body.Overwrite) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) return } writeJSON(w, http.StatusOK, result) case body.Path != "": - result, err := p.installFromPath(body.Path) + result, err := p.installFromPath(body.Path, body.Overwrite) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) return @@ -279,7 +286,7 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) { http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) return } - result, err := p.installFromData(data) + result, err := p.installFromData(data, false) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) return @@ -333,15 +340,15 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) { // ======== Core Logic ======== -func (p *Plugin) installFromPath(path string) (interface{}, error) { +func (p *Plugin) installFromPath(path string, overwrite bool) (interface{}, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read file: %w", err) } - return p.installFromData(data) + return p.installFromData(data, overwrite) } -func (p *Plugin) installFromURL(rawURL string) (interface{}, error) { +func (p *Plugin) installFromURL(rawURL string, overwrite bool) (interface{}, error) { log.Printf("[pluginmgr] downloading: %s", rawURL) parsed, err := url.Parse(rawURL) @@ -367,7 +374,7 @@ func (p *Plugin) installFromURL(rawURL string) (interface{}, error) { return nil, fmt.Errorf("read response: %w", err) } - result, err := p.installFromData(data) + result, err := p.installFromData(data, overwrite) if err != nil { return nil, err } @@ -379,7 +386,10 @@ func (p *Plugin) installFromURL(rawURL string) (interface{}, error) { return result, nil } -func (p *Plugin) installFromData(data []byte) (interface{}, error) { +// installFromData 安装(或 overwrite=true 时原地更新)插件包。 +// 更新语义:StopAndUnload 停止旧实例但保留配置表,备份旧目录→解压新包→失败回滚; +// 更新后配置原样生效,无需用户手动卸载重装。 +func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, error) { pkg, err := validatePackage(data) if err != nil { return map[string]interface{}{ @@ -394,12 +404,78 @@ func (p *Plugin) installFromData(data []byte) (interface{}, error) { } target := filepath.Join(dir, pkg.Name) - if _, err := os.Stat(target); err == nil { + var oldVersion string + existing := false + if m, err := plugin.ReadManifest(target); err == nil && m != nil { + existing = true + oldVersion = m.Version + } else if _, statErr := os.Stat(target); statErr == nil { + existing = true // 目录存在但 manifest 不可读:视为已安装、版本未知 + } + + if existing && !overwrite { return map[string]interface{}{ - "error": "plugin already exists", - "name": pkg.Name, - "version": pkg.Version, - "action": "remove_first", + "error": "plugin already exists", + "name": pkg.Name, + "version": pkg.Version, + "current": oldVersion, + "action": "remove_first", + "hint": `传 "overwrite": true 可原地更新(保留配置)`, + }, nil + } + + if existing && overwrite { + // 原地更新:停旧实例(保留配置表),备份旧目录,解压新包,失败回滚。 + if p.sdk != nil && p.sdk.PluginMgr() != nil { + if err := p.sdk.PluginMgr().StopAndUnload(pkg.Name); err != nil { + log.Printf("[pluginmgr] StopAndUnload %s: %v", pkg.Name, err) + } + } + backup := target + ".bak" + os.RemoveAll(backup) + if err := os.Rename(target, backup); err != nil { + return map[string]interface{}{ + "error": "backup old plugin dir failed", + "details": err.Error(), + }, nil + } + if err := extractPackage(data, dir); err != nil { + // 回滚:恢复旧目录并重新加载旧版 + os.RemoveAll(target) + if rbErr := os.Rename(backup, target); rbErr != nil { + return map[string]interface{}{ + "error": "extract failed AND rollback failed", + "details": err.Error(), + "rollback": rbErr.Error(), + }, nil + } + if p.sdk != nil && p.sdk.PluginMgr() != nil { + _ = p.sdk.PluginMgr().ReloadOne(pkg.Name) + } + return map[string]interface{}{ + "error": "extract failed (rolled back to " + oldVersion + ")", + "details": err.Error(), + }, nil + } + os.RemoveAll(backup) + + checksum := fmt.Sprintf("%x", sha256.Sum256(data)) + action := "upgraded" + if cmpVersion(pkg.Version, oldVersion) < 0 { + action = "downgraded" + } else if cmpVersion(pkg.Version, oldVersion) == 0 { + action = "reinstalled" + } + return map[string]interface{}{ + "status": "installed", + "name": pkg.Name, + "version": pkg.Version, + "previous_version": oldVersion, + "entry": pkg.Entry, + "checksum": checksum, + "action": action, + "reload_required": true, + "config_kept": true, }, nil } @@ -422,6 +498,36 @@ func (p *Plugin) installFromData(data []byte) (interface{}, error) { }, nil } +// cmpVersion 比较点分版本号:ab 返回 1,相等返回 0。 +// 非数字段按字符串比较;长度不齐缺段视作 0。 +func cmpVersion(a, b string) int { + parse := func(s string) []int { + parts := strings.SplitN(strings.TrimPrefix(strings.TrimSpace(s), "v"), ".", 4) + out := make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + n = 0 + } + out = append(out, n) + } + for len(out) < 3 { + out = append(out, 0) + } + return out + } + a1, b1 := parse(a), parse(b) + for i := range a1 { + if a1[i] < b1[i] { + return -1 + } + if a1[i] > b1[i] { + return 1 + } + } + return 0 +} + func (p *Plugin) listPlugins() (interface{}, error) { dir := p.pluginDir if dir == "" { diff --git a/internal/plugins/pluginmgr/upgrade_test.go b/internal/plugins/pluginmgr/upgrade_test.go new file mode 100644 index 0000000..6d4f467 --- /dev/null +++ b/internal/plugins/pluginmgr/upgrade_test.go @@ -0,0 +1,219 @@ +package pluginmgr + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func TestCmpVersion(t *testing.T) { + cases := []struct{ a, b string; want int }{ + {"1.0.0", "1.0.0", 0}, + {"1.0.1", "1.0.0", 1}, + {"1.0.0", "1.0.1", -1}, + {"1.0", "1.0.0", 0}, + {"v2.0.0", "1.9.9", 1}, + {"2.0.0", "10.0.0", -1}, // 数字比较而非字典序 + {"1.0.0-alpha", "1.0.0", 0}, // 非数字段按 0 + } + for _, c := range cases { + if got := cmpVersion(c.a, c.b); got != c.want { + t.Errorf("cmpVersion(%q,%q)=%d want %d", c.a, c.b, got, c.want) + } + } +} + +// ---- 最小 mock SDK ---- + +type pmSettings struct{} + +func (m *pmSettings) Get(string) (interface{}, error) { return nil, nil } +func (m *pmSettings) Set(string, interface{}) error { return nil } +func (m *pmSettings) List(string) ([]string, error) { return nil, nil } +func (m *pmSettings) GetCore(string) (interface{}, error) { return nil, nil } +func (m *pmSettings) SetCore(string, interface{}) error { return nil } +func (m *pmSettings) ListCore(string) ([]string, error) { return nil, nil } +func (m *pmSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil } +func (m *pmSettings) SetPlugin(string, string, interface{}) error { return nil } +func (m *pmSettings) ListPlugin(string, string) ([]string, error) { return nil, nil } +func (m *pmSettings) RegisterDef(sdk.ConfigDef) {} +func (m *pmSettings) Defs(string) []*sdk.ConfigDef { return nil } +func (m *pmSettings) Dump() map[string]interface{} { return nil } +func (m *pmSettings) Plugins() []string { return nil } +func (m *pmSettings) DefsCore(string) []*sdk.ConfigDef { return nil } +func (m *pmSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil } +func (m *pmSettings) Remove(string) error { return nil } +func (m *pmSettings) RemoveCore(string) error { return nil } +func (m *pmSettings) RemovePlugin(string, string) error { return nil } + +// fakePluginMgr 记录调用;StopAndUnload 只记标志,不真正操作。 +type fakePluginMgr struct { + mu sync.Mutex + stopAndUnloads []string +} + +func (f *fakePluginMgr) ListLoadedPlugins() []string { return nil } +func (f *fakePluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo { return nil } +func (f *fakePluginMgr) IsPluginDisabled(string) bool { return false } +func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return false } +func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil } +func (f *fakePluginMgr) EnablePlugin(string) error { return nil } +func (f *fakePluginMgr) RemovePlugin(string) error { return nil } +func (f *fakePluginMgr) StopAndUnload(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopAndUnloads = append(f.stopAndUnloads, name) + return nil +} +func (f *fakePluginMgr) ReloadPlugins() (string, error) { return "", nil } +func (f *fakePluginMgr) ReloadOne(string) error { return nil } +func (f *fakePluginMgr) PluginMetas() map[string]sdk.PluginMeta { + return map[string]sdk.PluginMeta{} +} +func (f *fakePluginMgr) PluginDir() string { return "" } + +// buildHmap 构造一个最小 .hmap 包。 +func buildHmap(t *testing.T, name, version string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + manifest := map[string]interface{}{ + "name": name, "name_zh": name, "name_en": name, + "version": version, "entry": "plugin.so", + } + mData, _ := json.Marshal(manifest) + f, _ := zw.Create("plugin.json") + f.Write(mData) + bin, _ := zw.Create("plugin.so") + bin.Write([]byte("binary-" + name + "-" + version)) + zw.Close() + return buf.Bytes() +} + +func TestInstallThenUpgradeKeepsConfig(t *testing.T) { + dir := t.TempDir() + fm := &fakePluginMgr{} + bus := events.NewBus() + sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{ + Settings: &pmSettings{}, + EventBus: bus, + PluginMgr: fm, + }) + + p := &Plugin{pluginDir: dir, sdk: sdkInst} + + // 1. 首次安装 v1.0.0 + r1, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false) + m1 := r1.(map[string]interface{}) + if m1["status"] != "installed" { + t.Fatalf("install failed: %v", m1) + } + if _, err := os.Stat(filepath.Join(dir, "demo", "plugin.json")); err != nil { + t.Fatalf("installed dir missing: %v", err) + } + + // 2. 不带 overwrite 重装 → 报 already exists + remove_first hint + r2, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false) + m2 := r2.(map[string]interface{}) + if m2["error"] != "plugin already exists" || m2["hint"] == "" { + t.Fatalf("expected already-exists with hint, got %v", m2) + } + if m2["current"] != "1.0.0" { + t.Fatalf("current version not reported: %v", m2) + } + + // 3. overwrite 升级 v1.0.0 → v2.0.0 + r3, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true) + m3 := r3.(map[string]interface{}) + if m3["status"] != "installed" || m3["action"] != "upgraded" { + t.Fatalf("upgrade failed: %v", m3) + } + if m3["previous_version"] != "1.0.0" { + t.Fatalf("previous_version = %v", m3["previous_version"]) + } + if m3["config_kept"] != true { + t.Fatalf("config_kept should be true: %v", m3) + } + // StopAndUnload 应被调用且不触发 RemovePlugin(不删配置) + fm.mu.Lock() + calls := append([]string{}, fm.stopAndUnloads...) + fm.mu.Unlock() + if len(calls) != 1 || calls[0] != "demo" { + t.Fatalf("StopAndUnload not called once with demo: %v", calls) + } + // 新二进制写入 + soData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.so")) + if err != nil { + t.Fatalf("read new so: %v", err) + } + if string(soData) != "binary-demo-2.0.0" { + t.Fatalf("so not overwritten: %q", string(soData)) + } + + // 4. 降级 v2.0.0 → v1.5.0 + r4, _ := p.installFromData(buildHmap(t, "demo", "1.5.0"), true) + m4 := r4.(map[string]interface{}) + if m4["action"] != "downgraded" { + t.Fatalf("downgrade action = %v", m4) + } +} + +func TestExtractFailureRollsBack(t *testing.T) { + dir := t.TempDir() + fm := &fakePluginMgr{} + bus := events.NewBus() + sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{ + Settings: &pmSettings{}, + EventBus: bus, + PluginMgr: fm, + }) + p := &Plugin{pluginDir: dir, sdk: sdkInst} + + // 先装 v1.0.0 + if r, _ := p.installFromData(buildHmap(t, "rollback", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" { + t.Fatal("install failed") + } + + // 构造损坏包:zip 但缺 plugin.json(extractPackage 会失败) + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + f, _ := zw.Create("plugin.so") + f.Write([]byte("corrupt")) + zw.Close() + + // 畸形包在 validatePackage 层就拒绝,未达 extract——模拟 extract 失败: + // 直接注入非法平台文件触发 extractPackage 错误 + bad := buildHmap(t, "rollback", "9.9.9") + // 篡改使 extract 失败:附加一个越界路径 + var rb bytes.Buffer + zw2 := zip.NewWriter(&rb) + f2, _ := zw2.Create("../../evil") + f2.Write([]byte("x")) + mf, _ := zw2.Create("plugin.json") + mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.so"}) + mf.Write(mData) + zw2.Close() + bad = rb.Bytes() + + r, _ := p.installFromData(bad, true) + m := r.(map[string]interface{}) + if m["error"] == nil { + t.Fatalf("expected error for corrupt package, got %v", m) + } + if m["rollback"] != nil { + t.Fatalf("rollback itself failed: %v", m) + } + // 旧版应被恢复 + mfest, err := plugin.ReadManifest(filepath.Join(dir, "rollback")) + if err != nil || mfest.Version != "1.0.0" { + t.Fatalf("old version not restored: %v / %v", mfest, err) + } +} \ No newline at end of file diff --git a/internal/plugins/skillmgr/tools.go b/internal/plugins/skillmgr/tools.go index abf9fde..a76ffb5 100644 --- a/internal/plugins/skillmgr/tools.go +++ b/internal/plugins/skillmgr/tools.go @@ -291,16 +291,18 @@ func (p *Plugin) registerExport() { func (p *Plugin) registerInstall() { p.sdk.RegisterTool(tp+"install", sdk.ToolDef{ Name: tp + "install", - Description: "安装技能包:支持 .skm 包路径或 local: 本地目录。安装后立即加载生效。", + Description: "安装技能包:支持 .skm 包路径或 local: 本地目录。同名技能已存在时传 overwrite=true 原地覆盖(保留无持久配置,直接替换文件)。安装后立即加载生效。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "source": map[string]interface{}{"type": "string", "description": "安装来源:.skm 或 local:"}, + "source": map[string]interface{}{"type": "string", "description": "安装来源:.skm 或 local:"}, + "overwrite": map[string]interface{}{"type": "boolean", "description": "同名技能存在时覆盖更新(默认 false)"}, }, "required": []string{"source"}, }, }, func(args map[string]interface{}) (interface{}, error) { source, _ := args["source"].(string) + overwrite, _ := args["overwrite"].(bool) source = strings.TrimSpace(source) switch { case strings.HasPrefix(source, "local:"): @@ -314,8 +316,8 @@ func (p *Plugin) registerInstall() { return nil, err } dst := filepath.Join(p.skillsDir, dstName) - if _, err := os.Stat(dst); err == nil { - return nil, fmt.Errorf("skill dir already exists: %s", dst) + if err := p.replaceSkillDir(dst, overwrite); err != nil { + return nil, err } if err := copyDir(dir, dst); err != nil { return nil, fmt.Errorf("copy failed: %w", err) @@ -336,8 +338,8 @@ func (p *Plugin) registerInstall() { return nil, err } dst := filepath.Join(p.skillsDir, dstName) - if _, err := os.Stat(dst); err == nil { - return nil, fmt.Errorf("skill dir already exists: %s", dst) + if err := p.replaceSkillDir(dst, overwrite); err != nil { + return nil, err } n, err := unpackSkill(source, dst) if err != nil { @@ -357,6 +359,23 @@ func (p *Plugin) registerInstall() { }) } +// replaceSkillDir 安装前的同名目录处理:不存在则放行;存在且 overwrite=true +// 则先卸载旧实例并删除旧目录(skill 无持久配置,直接替换);否则报错。 +func (p *Plugin) replaceSkillDir(dst string, overwrite bool) error { + if _, err := os.Stat(dst); err != nil { + return nil // 不存在,直接装 + } + if !overwrite { + return fmt.Errorf("skill dir already exists: %s (传 overwrite=true 覆盖更新)", dst) + } + name := filepath.Base(dst) + p.removeOne(name) // 从注册表移除旧实例 + if err := os.RemoveAll(dst); err != nil { + return fmt.Errorf("remove old skill dir: %w", err) + } + return nil +} + // validateSkillName 校验技能名:小写字母/数字/连字符,1-64 字符。 func validateSkillName(name string) error { if name == "" || len(name) > 64 { diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index e438c66..727d413 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -711,8 +711,14 @@ func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil } func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + // 流契约:chunk 发送完毕后必须 close(channel) 标识流结束(与 + // LuaAdaptedProvider.ChatStream 的 defer close(ch) 一致); + // accumulateStream 以 channel 关闭为终止条件,Done 只是 finish_reason 载体。 + // 内容与 Chat() 保持一致,保证端到端断言在流式/非流式两条路径下等价。 ch := make(chan agentAPI.StreamChunk, 1) - ch <- agentAPI.StreamChunk{Content: "mock", Done: true} + content := "echo: " + lastUserContent(req.Messages) + ch <- agentAPI.StreamChunk{Content: content, Done: true, FinishReason: "stop"} + close(ch) return ch, nil } diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 601b7d5..d51cd16 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -75,8 +75,12 @@ type PluginManager interface { DisablePlugin(name, by string) error EnablePlugin(name string) error // RemovePlugin 卸载插件:先停止(stop handlers + Stop),再执行插件注册的 - // onRemove 回调(RegisterOnRemoveHandler),最后从注册表移除。目录删除由调用方负责。 + // onRemove 回调(RegisterOnRemoveHandler),最后从注册表移除并清理配置表。 + // 目录删除由调用方负责。 RemovePlugin(name string) error + // StopAndUnload 停止并从注册表移除插件但保留配置表,供更新/升级流程使用: + // 换产物不动配置,重装后配置原样生效。不触发 onRemove 回调。 + StopAndUnload(name string) error ReloadPlugins() (string, error) // ReloadOne 重载单个插件(停止后重新加载,处理 dlclose/dynamic 句柄)。 ReloadOne(name string) error diff --git a/third_party/homeagent-sdk/meta/meta.go b/third_party/homeagent-sdk/meta/meta.go index 6b69bb8..b90d732 100644 --- a/third_party/homeagent-sdk/meta/meta.go +++ b/third_party/homeagent-sdk/meta/meta.go @@ -6,7 +6,7 @@ package meta var ( // Version 是 HomeAgent SDK 版本号。 // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 - Version = "0.9.0" + Version = "0.9.1" // Commit 是构建时的 Git commit hash。 Commit = "unknown" @@ -21,7 +21,7 @@ var ( CoreModule = "gitcode.com/JianFeeeee/HomeAgent" // CoreVersion 是此 SDK 所兼容的最低核心版本。 - CoreVersion = "0.9.0" + CoreVersion = "0.9.1" ) // FullVersion 返回完整的版本字符串。 diff --git a/third_party/homeagent-sdk/remotedevice/CMakeLists.txt b/third_party/homeagent-sdk/remotedevice/CMakeLists.txt new file mode 100644 index 0000000..c7fc578 --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/CMakeLists.txt @@ -0,0 +1,116 @@ +cmake_minimum_required(VERSION 3.10) +project(ha_remotedevice VERSION 0.1.0 LANGUAGES C) + +# ============================================================ +# ha_remotedevice — HomeAgent 远程设备接入 C SDK +# 零外部依赖,纯 C 实现,兼容嵌入式平台。 +# +# 使用方式: +# add_subdirectory(path/to/ha_remotedevice) +# target_link_libraries(my_app ha_remotedevice) +# target_include_directories(my_app PRIVATE +# ${HA_REMOTEDEVICE_INCLUDE_DIR}) +# ============================================================ + +# 选项: 构建为静态库或动态库 +option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF) + +# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调) +option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF) + +# 选项: 日志级别 +set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug") + +# 源文件 +set(HA_REMOTEDEVICE_SRC + src/ha_remotedevice.c + src/ha_json.c + src/ha_ws.c +) + +# 头文件 +set(HA_REMOTEDEVICE_INCLUDE + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +# 编译选项 +if(HA_NO_ALLOC) + add_definitions(-DHA_NO_ALLOC) +endif() +add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL}) + +# 创建库 +if(BUILD_SHARED_LIBS) + add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC}) + if(WIN32) + # Windows 需要导出符号 + set_target_properties(ha_remotedevice PROPERTIES + WINDOWS_EXPORT_ALL_SYMBOLS ON) + endif() +else() + add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC}) +endif() + +# 包含目录 +target_include_directories(ha_remotedevice + PUBLIC ${HA_REMOTEDEVICE_INCLUDE} + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +# 不链接任何外部库 +target_link_libraries(ha_remotedevice PRIVATE) + +# 导出包含目录供外部项目使用 +set(HA_REMOTEDEVICE_INCLUDE_DIR + ${HA_REMOTEDEVICE_INCLUDE} + CACHE INTERNAL "ha_remotedevice include directories") + +# 安装规则 +install(TARGETS ha_remotedevice + EXPORT ha_remotedevice-targets + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin + INCLUDES DESTINATION include +) + +install(DIRECTORY include/ + DESTINATION include +) + +install(EXPORT ha_remotedevice-targets + DESTINATION lib/cmake/ha_remotedevice + NAMESPACE ha_remotedevice:: +) + +# ============================================================ +# 测试(可选) +# ============================================================ +option(BUILD_TESTS "Build ha_remotedevice tests" OFF) + +if(BUILD_TESTS) + find_package(Threads REQUIRED) + + add_executable(ha_remotedevice_test + test/test_ha_remotedevice.c + ) + target_link_libraries(ha_remotedevice_test + PRIVATE ha_remotedevice Threads::Threads + ) + target_include_directories(ha_remotedevice_test + PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR} + ) + + # 添加测试 + add_test(NAME ha_remotedevice_test + COMMAND ha_remotedevice_test + ) +endif() + +# ============================================================ +# 编译信息 +# ============================================================ +message(STATUS "ha_remotedevice ${PROJECT_VERSION}") +message(STATUS " Build type: $") +message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}") +message(STATUS " No alloc: ${HA_NO_ALLOC}") \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h b/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h new file mode 100644 index 0000000..84e64a7 --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h @@ -0,0 +1,216 @@ +#ifndef HA_REMOTEDEVICE_H +#define HA_REMOTEDEVICE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ================================================================== + * ha_remotedevice — 远程设备接入 C SDK + * + * 零外部依赖,纯 C 实现,兼容嵌入式平台。 + * 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。 + * + * 声明式设计: + * 设备在代码中声明自己是什么(kind)和能做什么(caps), + * 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数, + * SDK 自动处理协议握手、心跳、消息路由、结果回执。 + * + * 协议流程: + * TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪 + * 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果 + * ================================================================== */ + +/* ======================== 状态码 ======================== */ +typedef enum { + HA_OK = 0, + HA_ERR_GENERIC = -1, + HA_ERR_NOMEM = -2, + HA_ERR_INVALID = -3, + HA_ERR_TIMEOUT = -4, + HA_ERR_DISCONNECTED = -5, + HA_ERR_PROTOCOL = -6, + HA_ERR_TRANSPORT = -7, + HA_ERR_NOT_FOUND = -8, +} ha_status_t; + +/* ======================== 传输层抽象 ======================== + * + * 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。 + * + * connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功 + * send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败 + * recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败 + * close(ctx) → 关闭连接 + */ +typedef struct { + int (*connect)(void *ctx, const char *host, uint16_t port); + int (*send)(void *ctx, const uint8_t *data, int len); + int (*recv)(void *ctx, uint8_t *buf, int len); + void (*close)(void *ctx); + void *ctx; +} ha_transport_t; + +/* ======================== 设备声明 ======================== + * + * 声明式配置:设备在代码中声明自己的类型和能力。 + * 这些信息通过 hello 消息发送给网关。 + * + * device_id — 唯一标识,如 "esp32-cam-1" + * name — 设备显示名,如 "门口摄像头" + * kind — 设备种类,如 "camera"、"computer"、"speaker"、"light" + * caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL} + * info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}' + */ +typedef struct { + const char *device_id; + const char *name; + const char *kind; + const char **caps; /* NULL 结尾 */ + const char *info_json; /* 可选,NULL 或 JSON 字符串 */ +} ha_device_info_t; + +/* ======================== 命令结果 ======================== + * + * 命令处理函数通过填写此结构体返回数据。 + * SDK 收到结果后自动发送回执(文本或二进制分块)。 + * + * 使用方式: + * 1. 简单文本:设置 status=0, output="结果文本" + * 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime + * 3. 错误:设置 status=1, error="错误信息" + * + * 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。 + * 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。 + * 所以 handler 可以返回栈上或静态字符串。 + */ +typedef struct { + int status; /* 0=ok, 非0=error */ + const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */ + const char *error; /* 错误信息 */ + int has_binary; /* 1=通过二进制分块回传 */ + const char *binary_mime; /* 二进制 MIME 类型 */ + const uint8_t *binary_data; /* 二进制数据指针 */ + int binary_len; /* 二进制数据长度 */ +} ha_cmd_result_t; + +/* ======================== 命令处理声明 ======================== + * + * 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。 + * + * command 值说明: + * - "shell" → 处理 shell 类型命令,args 为完整命令字符串 + * - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数 + * - "screensee" → 处理 homeagent-screensee 命令 + * - "speakeruse" → 处理 homeagent-speakeruse 命令 + * - "computeruse" → 处理 homeagent-computeruse 命令 + * - "clipboardsee" → 处理 homeagent-clipboardsee 命令 + * - "clipboardsue" → 处理 homeagent-clipboardsue 命令 + * - "screensue" → 处理 homeagent-screensue 命令 + * - "deviceinfo" → 处理设备信息查询 + * - 其他自定义命令名 → 按字符串匹配分发 + * + * handler 处理完毕后只需填写 result 结构体,SDK 自动回执。 + */ +typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata); + +typedef struct { + const char *command; /* 命令名,如 "camerasue"、"shell" */ + ha_cmd_handler_t handler; /* 处理函数 */ +} ha_cmd_handler_def_t; + +/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。 + * data 指针在回调返回后失效,如需保存请拷贝。 */ +typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind, + const char *mime, const uint8_t *data, + int len, void *userdata); + +/* 连接状态变化回调 */ +typedef void (*ha_state_callback_t)(int connected, void *userdata); + +/* ======================== 客户端配置 ======================== + * + * 所有配置在 ha_client_new() 时一次性声明。 + * 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。 + */ +typedef struct { + ha_transport_t transport; /* 传输层实现(必须) */ + ha_device_info_t device; /* 设备声明(必须) */ + const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */ + const char *token; /* 接入令牌(必须) */ + + ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */ + ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */ + ha_state_callback_t on_state; /* 状态变化回调(可选) */ + void *userdata; /* 用户自定义数据,传给所有回调 */ + + int ping_interval; /* 心跳间隔秒数,0 则默认 30 */ + int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */ +} ha_config_t; + +/* ======================== 客户端 API ======================== */ + +typedef struct ha_client ha_client_t; + +/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */ +ha_client_t *ha_client_new(const ha_config_t *config); + +/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */ +ha_status_t ha_client_start(ha_client_t *client); + +/* 主循环处理:必须在用户的主循环中周期性调用。 + * - 读取 WS 帧并分发 + * - 按 handlers 表查找命令处理函数,自动回执结果 + * - 处理心跳 ping/pong + * - 处理断线重连 + * 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */ +ha_status_t ha_client_process(ha_client_t *client); + +/* ===== 主动上报(设备主动推送,非命令响应) ===== */ + +/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */ +void ha_client_send_event(ha_client_t *client, const char *type, + const char *detail); + +/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */ +void ha_client_send_status(ha_client_t *client, const char *status); + +/* ===== 生命周期 ===== */ + +/* 停止客户端,断开连接。 */ +void ha_client_stop(ha_client_t *client); + +/* 销毁客户端,释放所有资源。 */ +void ha_client_destroy(ha_client_t *client); + +/* ======================== 工具函数 ======================== */ + +/* 解析 homeagent-* 命令,返回能力名和参数。 + * command = "camerasue 5" → cap="camerasue", args="5" + * command = "screensee" → cap="screensee", args="" + * command = "computeruse {...}" → cap="computeruse", args="..." */ +void ha_cmd_parse_homeagent(const char *command, const char **cap, + const char **args); + +/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。 + * command = "computeruse {\"action\":\"click\",\"x\":100}" + * → action="computeruse", json_str="{\"action\":\"click\",...}" */ +void ha_cmd_parse_json(const char *command, const char **action, + const char **json_str); + +/* Base64 编码(用于将二进制数据编码为文本回传)。 + * 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */ +int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len); + +/* 获取版本号 */ +const char *ha_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_REMOTEDEVICE_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_json.c b/third_party/homeagent-sdk/remotedevice/src/ha_json.c new file mode 100644 index 0000000..0d3af91 --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/src/ha_json.c @@ -0,0 +1,369 @@ +#include "ha_json.h" +#include +#include +#include +#include + +/* ======================== 解析器 ======================== */ + +/* 前向声明 */ +static ha_json_node_t *parse_value(const char **pp); + +/* 跳过空白 */ +static const char *skip_ws(const char *p) { + while (*p && (unsigned char)*p <= ' ') p++; + return p; +} + +/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */ +static char *parse_string(const char **pp) { + const char *p = skip_ws(*pp); + if (*p != '"') return NULL; + p++; + int len = 0; + const char *q = p; + while (*q && *q != '"') { + if (*q == '\\') { q++; if (*q) q++; } + else q++; + len++; + } + if (*q != '"') return NULL; + char *s = (char *)malloc(len + 1); + if (!s) return NULL; + q = p; + int i = 0; + while (*q && *q != '"') { + if (*q == '\\') { + q++; + switch (*q) { + case '"': s[i++] = '"'; break; + case '\\': s[i++] = '\\'; break; + case '/': s[i++] = '/'; break; + case 'b': s[i++] = '\b'; break; + case 'f': s[i++] = '\f'; break; + case 'n': s[i++] = '\n'; break; + case 'r': s[i++] = '\r'; break; + case 't': s[i++] = '\t'; break; + case 'u': q += 4; s[i++] = '?'; continue; + default: s[i++] = *q; break; + } + q++; + } else { + s[i++] = *q++; + } + } + s[i] = '\0'; + *pp = q + 1; + return s; +} + +static ha_json_node_t *new_node(ha_json_type_t type) { + ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t)); + if (n) n->type = type; + return n; +} + +/* 解析数字 */ +static ha_json_node_t *parse_number(const char **pp) { + const char *p = *pp; + int neg = 0; + if (*p == '-') { neg = 1; p++; } + if (!isdigit((unsigned char)*p)) return NULL; + int val = 0; + while (isdigit((unsigned char)*p)) { + val = val * 10 + (*p - '0'); + p++; + } + if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; } + if (*p == 'e' || *p == 'E') { + p++; + if (*p == '+' || *p == '-') p++; + while (isdigit((unsigned char)*p)) p++; + } + *pp = p; + ha_json_node_t *n = new_node(HA_JSON_INT); + if (n) n->int_val = neg ? -val : val; + return n; +} + +/* 解析 true/false/null */ +static ha_json_node_t *parse_keyword(const char **pp) { + const char *p = *pp; + ha_json_node_t *n = NULL; + if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) { + n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1; + *pp = p + 4; + } else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) { + n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0; + *pp = p + 5; + } else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) { + n = new_node(HA_JSON_NULL); + *pp = p + 4; + } + return n; +} + +/* 解析对象 */ +static ha_json_node_t *parse_object(const char **pp) { + const char *p = skip_ws(*pp); + if (*p != '{') return NULL; + p++; + ha_json_node_t *obj = new_node(HA_JSON_OBJECT); + if (!obj) return NULL; + ha_json_node_t **tail = &obj->child; + p = skip_ws(p); + if (*p == '}') { *pp = p + 1; return obj; } + while (*p) { + p = skip_ws(p); + char *key = parse_string(&p); + if (!key) break; + p = skip_ws(p); + if (*p != ':') { free(key); break; } + p++; + ha_json_node_t *val = parse_value(&p); + if (!val) { free(key); break; } + val->key = key; + *tail = val; + tail = &val->next; + p = skip_ws(p); + if (*p == ',') { p++; continue; } + if (*p == '}') break; + } + p = skip_ws(p); + if (*p == '}') { *pp = p + 1; return obj; } + ha_json_free(obj); + return NULL; +} + +/* 解析数组 */ +static ha_json_node_t *parse_array(const char **pp) { + const char *p = skip_ws(*pp); + if (*p != '[') return NULL; + p++; + ha_json_node_t *arr = new_node(HA_JSON_ARRAY); + if (!arr) return NULL; + ha_json_node_t **tail = &arr->child; + p = skip_ws(p); + if (*p == ']') { *pp = p + 1; return arr; } + while (*p) { + ha_json_node_t *val = parse_value(&p); + if (!val) break; + *tail = val; + tail = &val->next; + p = skip_ws(p); + if (*p == ',') { p++; continue; } + if (*p == ']') break; + } + p = skip_ws(p); + if (*p == ']') { *pp = p + 1; return arr; } + ha_json_free(arr); + return NULL; +} + +/* 解析值(主入口) */ +static ha_json_node_t *parse_value(const char **pp) { + const char *p = skip_ws(*pp); + if (*p == '{') return parse_object(pp); + if (*p == '[') return parse_array(pp); + if (*p == '"') { + char *s = parse_string(pp); + if (!s) return NULL; + ha_json_node_t *n = new_node(HA_JSON_STRING); + if (!n) { free(s); return NULL; } + n->str_val = s; + return n; + } + if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp); + return parse_keyword(pp); +} + +/* ======================== 公共 API ======================== */ + +ha_json_node_t *ha_json_parse(const char *str) { + if (!str) return NULL; + const char *p = str; + return parse_value(&p); +} + +const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) { + ha_json_node_t *n = ha_json_get(obj, key); + if (!n || n->type != HA_JSON_STRING) return NULL; + return n->str_val; +} + +int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) { + ha_json_node_t *n = ha_json_get(obj, key); + if (!n || n->type != HA_JSON_INT) return def; + return n->int_val; +} + +ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) { + if (!obj || obj->type != HA_JSON_OBJECT) return NULL; + ha_json_node_t *c = obj->child; + while (c) { + if (c->key && strcmp(c->key, key) == 0) return c; + c = c->next; + } + return NULL; +} + +int ha_json_array_len(const ha_json_node_t *arr) { + if (!arr || arr->type != HA_JSON_ARRAY) return 0; + int n = 0; + ha_json_node_t *c = arr->child; + while (c) { n++; c = c->next; } + return n; +} + +ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) { + if (!arr || arr->type != HA_JSON_ARRAY) return NULL; + ha_json_node_t *c = arr->child; + int i = 0; + while (c) { + if (i == index) return c; + i++; c = c->next; + } + return NULL; +} + +void ha_json_free(ha_json_node_t *root) { + if (!root) return; + ha_json_node_t *c = root->child; + while (c) { + ha_json_node_t *next = c->next; + free(c->key); + if (c->type == HA_JSON_STRING) free(c->str_val); + ha_json_free(c); + c = next; + } + free(root); +} + +/* ======================== 构建器 ======================== */ + +static void json_escape(ha_json_builder_t *jb, const char *s) { + if (!s) { ha_json_builder_raw(jb, "null"); return; } + ha_json_builder_raw(jb, "\""); + for (const char *p = s; *p; p++) { + unsigned char c = (unsigned char)*p; + switch (c) { + case '"': ha_json_builder_raw(jb, "\\\""); break; + case '\\': ha_json_builder_raw(jb, "\\\\"); break; + case '\b': ha_json_builder_raw(jb, "\\b"); break; + case '\f': ha_json_builder_raw(jb, "\\f"); break; + case '\n': ha_json_builder_raw(jb, "\\n"); break; + case '\r': ha_json_builder_raw(jb, "\\r"); break; + case '\t': ha_json_builder_raw(jb, "\\t"); break; + default: + if (c < 0x20) { + char buf[8]; + snprintf(buf, sizeof(buf), "\\u%04x", c); + ha_json_builder_raw(jb, buf); + } else { + char buf[2] = { (char)c, 0 }; + ha_json_builder_raw(jb, buf); + } + break; + } + } + ha_json_builder_raw(jb, "\""); +} + +void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) { + jb->buf = buf; + jb->len = 0; + jb->cap = cap; + jb->depth = 0; + if (cap > 0) buf[0] = '\0'; +} + +void ha_json_builder_reset(ha_json_builder_t *jb) { + jb->len = 0; + jb->depth = 0; + if (jb->cap > 0) jb->buf[0] = '\0'; +} + +void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) { + while (*s && jb->len < jb->cap - 1) { + jb->buf[jb->len++] = *s++; + } + jb->buf[jb->len] = '\0'; +} + +void ha_json_builder_comma(ha_json_builder_t *jb) { + if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) { + ha_json_builder_raw(jb, ","); + } + if (jb->depth > 0) jb->item_count[jb->depth - 1]++; +} + +void ha_json_builder_begin_object(ha_json_builder_t *jb) { + ha_json_builder_comma(jb); + ha_json_builder_raw(jb, "{"); + if (jb->depth < 16) jb->item_count[jb->depth] = 0; + jb->depth++; +} + +void ha_json_builder_end_object(ha_json_builder_t *jb) { + jb->depth--; + ha_json_builder_raw(jb, "}"); +} + +void ha_json_builder_begin_array(ha_json_builder_t *jb) { + ha_json_builder_comma(jb); + ha_json_builder_raw(jb, "["); + if (jb->depth < 16) jb->item_count[jb->depth] = 0; + jb->depth++; +} + +void ha_json_builder_end_array(ha_json_builder_t *jb) { + jb->depth--; + ha_json_builder_raw(jb, "]"); +} + +void ha_json_builder_key(ha_json_builder_t *jb, const char *key) { + ha_json_builder_comma(jb); + json_escape(jb, key); + ha_json_builder_raw(jb, ":"); +} + +void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) { + json_escape(jb, val); +} + +void ha_json_builder_add_int(ha_json_builder_t *jb, int val) { + char buf[16]; + snprintf(buf, sizeof(buf), "%d", val); + ha_json_builder_raw(jb, buf); +} + +void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) { + ha_json_builder_raw(jb, val ? "true" : "false"); +} + +void ha_json_builder_add_null(ha_json_builder_t *jb) { + ha_json_builder_raw(jb, "null"); +} + +void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) { + ha_json_builder_key(jb, key); + json_escape(jb, val); +} + +void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) { + ha_json_builder_key(jb, key); + ha_json_builder_add_int(jb, val); +} + +void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) { + ha_json_builder_key(jb, key); + ha_json_builder_add_bool(jb, val); +} + +const char *ha_json_builder_str(ha_json_builder_t *jb) { + return jb->buf; +} + +int ha_json_builder_len(ha_json_builder_t *jb) { + return jb->len; +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_json.h b/third_party/homeagent-sdk/remotedevice/src/ha_json.h new file mode 100644 index 0000000..1560d9e --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/src/ha_json.h @@ -0,0 +1,107 @@ +#ifndef HA_JSON_H +#define HA_JSON_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ======================== JSON 解析器(DOM 风格) ======================== */ +typedef enum { + HA_JSON_NULL, + HA_JSON_BOOL, + HA_JSON_INT, + HA_JSON_STRING, + HA_JSON_ARRAY, + HA_JSON_OBJECT, +} ha_json_type_t; + +typedef struct ha_json_node { + ha_json_type_t type; + union { + int bool_val; + int int_val; + char *str_val; + }; + struct ha_json_node *next; /* linked list for array/object items */ + struct ha_json_node *child; /* first child for array/object */ + char *key; /* key for object members */ +} ha_json_node_t; + +/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */ +ha_json_node_t *ha_json_parse(const char *str); + +/* 从对象中按 key 获取字符串值,不存在返回 NULL */ +const char *ha_json_get_string(const ha_json_node_t *obj, const char *key); + +/* 从对象中按 key 获取 int 值,不存在返回 def */ +int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def); + +/* 从对象中按 key 获取子节点,不存在返回 NULL */ +ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key); + +/* 获取数组长度 */ +int ha_json_array_len(const ha_json_node_t *arr); + +/* 获取数组第 index 个元素,越界返回 NULL */ +ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index); + +/* 释放整个 JSON 树 */ +void ha_json_free(ha_json_node_t *root); + +/* ======================== JSON 构建器(直接写缓冲区) ======================== */ +typedef struct { + char *buf; + int len; + int cap; + int depth; + int item_count[16]; /* 每层已添加元素数,用于逗号判断 */ +} ha_json_builder_t; + +/* 初始化构建器 */ +void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap); + +/* 清空构建器 */ +void ha_json_builder_reset(ha_json_builder_t *jb); + +/* 基础写入 */ +void ha_json_builder_raw(ha_json_builder_t *jb, const char *s); + +/* 逗号(自动判断是否需要加) */ +void ha_json_builder_comma(ha_json_builder_t *jb); + +/* 对象 */ +void ha_json_builder_begin_object(ha_json_builder_t *jb); +void ha_json_builder_end_object(ha_json_builder_t *jb); + +/* 数组 */ +void ha_json_builder_begin_array(ha_json_builder_t *jb); +void ha_json_builder_end_array(ha_json_builder_t *jb); + +/* 键名 */ +void ha_json_builder_key(ha_json_builder_t *jb, const char *key); + +/* 值 */ +void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val); +void ha_json_builder_add_int(ha_json_builder_t *jb, int val); +void ha_json_builder_add_bool(ha_json_builder_t *jb, int val); +void ha_json_builder_add_null(ha_json_builder_t *jb); + +/* 快捷方法:直接写 "key":"val" */ +void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val); +void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val); +void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val); + +/* 获取当前构建的字符串指针 */ +const char *ha_json_builder_str(ha_json_builder_t *jb); + +/* 获取当前长度 */ +int ha_json_builder_len(ha_json_builder_t *jb); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_JSON_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c b/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c new file mode 100644 index 0000000..4ab127d --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c @@ -0,0 +1,628 @@ +#include "ha_remotedevice.h" +#include "ha_json.h" +#include "ha_ws.h" +#include +#include +#include + +#define HA_VERSION "0.1.0" + +/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */ +void ha_client_send_result(ha_client_t *client, const char *req_id, + const char *status, const char *output, + const char *error); +void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, + const char *kind, const char *mime, + const uint8_t *data, int len); + +/* ======================== 内部状态 ======================== */ +typedef enum { + HA_STATE_INIT, + HA_STATE_DISCONNECTED, + HA_STATE_CONNECTING, + HA_STATE_WS_UPGRADING, + HA_STATE_HELLO_SENT, + HA_STATE_BIND_SENT, + HA_STATE_READY, + HA_STATE_STOPPING, +} ha_state_t; + +/* 语音数据聚合缓冲区 */ +typedef struct { + char req_id[128]; + char kind[64]; + char mime[64]; + int total; + uint8_t *data; + int len; + int cap; +} ha_speech_accum_t; + +struct ha_client { + ha_config_t config; /* 拷贝的配置 */ + ha_state_t state; + int reconnect_cnt; /* 当前重连次数 */ + ha_ws_t ws; /* WS 连接 */ + + /* JSON 构建缓冲区 */ + char json_buf[4096]; + ha_json_builder_t jb; + + /* 语音数据聚合 */ + ha_speech_accum_t speech; +}; + +/* ======================== 辅助函数 ======================== */ + +static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; } + +/* Base64 编码表 */ +static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) { + int needed = ((len + 2) / 3) * 4 + 1; + if (out_len < needed) { + if (out_len > 0) out[0] = '\0'; + return needed; + } + int i = 0, j = 0; + while (i < len) { + int rem = len - i; + uint8_t b0 = data[i++]; + uint8_t b1 = (rem > 1) ? data[i++] : 0; + uint8_t b2 = (rem > 2) ? data[i++] : 0; + out[j++] = b64[b0 >> 2]; + out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)]; + out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '='; + out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '='; + } + out[j] = '\0'; + return j; +} + +/* ======================== JSON 构建辅助 ======================== */ +static void json_init(ha_client_t *c) { + ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf)); +} + +/* ======================== WS 发送 JSON ======================== */ +static int ws_send_json(ha_client_t *c) { + return ha_ws_send_text(&c->ws, c->json_buf); +} + +/* ======================== 协议消息构造 ======================== */ + +/* 构建 hello 消息 */ +static int send_hello(ha_client_t *c) { + json_init(c); + ha_json_builder_begin_object(&c->jb); + ha_json_builder_string(&c->jb, "op", "hello"); + ha_json_builder_key(&c->jb, "device"); + ha_json_builder_begin_object(&c->jb); + ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id); + ha_json_builder_string(&c->jb, "name", c->config.device.name); + ha_json_builder_string(&c->jb, "kind", c->config.device.kind); + /* caps */ + ha_json_builder_key(&c->jb, "caps"); + ha_json_builder_begin_array(&c->jb); + if (c->config.device.caps) { + for (const char **p = c->config.device.caps; *p; p++) { + ha_json_builder_add_string(&c->jb, *p); + } + } + ha_json_builder_end_array(&c->jb); + /* info 可选 */ + if (c->config.device.info_json && c->config.device.info_json[0]) { + ha_json_builder_string(&c->jb, "info", c->config.device.info_json); + } + ha_json_builder_end_object(&c->jb); /* device */ + ha_json_builder_end_object(&c->jb); /* root */ + return ws_send_json(c); +} + +/* 构建 bind 消息 */ +static int send_bind(ha_client_t *c) { + json_init(c); + ha_json_builder_begin_object(&c->jb); + ha_json_builder_string(&c->jb, "op", "bind"); + ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id); + ha_json_builder_string(&c->jb, "token", c->config.token); + ha_json_builder_end_object(&c->jb); + return ws_send_json(c); +} + +/* ======================== 消息处理 ======================== */ + +/* 在 handlers 表中查找命令处理函数 */ +static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) { + if (!name || !c->config.handlers) return NULL; + for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) { + if (strcmp(h->command, name) == 0) return h; + } + return NULL; +} + +/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */ +static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) { + const char *req_id = ha_json_get_string(msg, "req_id"); + const char *command = ha_json_get_string(msg, "command"); + const char *cmd_type = ha_json_get_string(msg, "cmd_type"); + if (!req_id || !command) return; + if (!cmd_type) cmd_type = "homeagent"; + + const char *handler_name = NULL; + const char *args = command; + + if (strcmp(cmd_type, "shell") == 0) { + handler_name = "shell"; + /* args 保持为完整命令字符串 */ + } else { + /* homeagent-* 命令:提取能力名作为 handler 名 */ + const char *cap = command; + const char *p = command; + if (strncmp(p, "homeagent-", 10) == 0) p += 10; + const char *space = strchr(p, ' '); + if (space) { + args = space + 1; + /* handler_name 用静态缓冲区 */ + static char name_buf[128]; + int n = (int)(space - p); + if (n > 127) n = 127; + strncpy(name_buf, p, n); + name_buf[n] = '\0'; + handler_name = name_buf; + } else { + handler_name = p; + args = ""; + } + } + + ha_cmd_handler_def_t *def = find_handler(c, handler_name); + if (!def) { + ha_client_send_result(c, req_id, "error", NULL, + "unsupported command"); + return; + } + + /* 调用 handler,填写 result */ + ha_cmd_result_t result; + memset(&result, 0, sizeof(result)); + ha_status_t st = def->handler(req_id, args, &result, c->config.userdata); + + /* 自动回执 */ + if (st != HA_OK) { + ha_client_send_result(c, req_id, "error", NULL, + result.error ? result.error : "handler failed"); + return; + } + + if (result.has_binary && result.binary_data && result.binary_len > 0) { + /* 二进制分块回传 */ + ha_client_send_data_chunked(c, req_id, + handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream", + result.binary_data, result.binary_len); + } else { + /* 文本回传 */ + ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error", + result.output, result.error); + } +} + +static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) { + const char *req_id = ha_json_get_string(msg, "req_id"); + const char *kind = ha_json_get_string(msg, "kind"); + const char *mime = ha_json_get_string(msg, "mime"); + if (!req_id) return; + + /* 释放旧的聚合数据 */ + free(c->speech.data); + memset(&c->speech, 0, sizeof(c->speech)); + + strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1); + if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1); + if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1); + c->speech.total = ha_json_get_int(msg, "total", 0); +} + +static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) { + const char *req_id = ha_json_get_string(msg, "req_id"); + if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return; + + if (c->config.on_binary && c->speech.data && c->speech.len > 0) { + c->config.on_binary(c->speech.req_id, c->speech.kind, + c->speech.mime, c->speech.data, + c->speech.len, c->config.userdata); + } + + free(c->speech.data); + memset(&c->speech, 0, sizeof(c->speech)); +} + +static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) { + /* 解析 JSON */ + char *tmp = (char *)malloc(len + 1); + if (!tmp) return; + memcpy(tmp, payload, len); + tmp[len] = '\0'; + + ha_json_node_t *root = ha_json_parse(tmp); + if (!root) { free(tmp); return; } + + const char *op = ha_json_get_string(root, "op"); + if (!op) { ha_json_free(root); free(tmp); return; } + + switch (c->state) { + case HA_STATE_HELLO_SENT: + if (strcmp(op, "hello_ack") == 0) { + c->state = HA_STATE_BIND_SENT; + send_bind(c); + } + break; + case HA_STATE_BIND_SENT: + if (strcmp(op, "bind_ack") == 0) { + c->state = HA_STATE_READY; + if (c->config.on_state) { + c->config.on_state(1, c->config.userdata); + } + } + break; + case HA_STATE_READY: + if (strcmp(op, "cmd") == 0) { + handle_cmd_msg(c, root); + } else if (strcmp(op, "cmd_speech_start") == 0) { + handle_speech_start(c, root); + } else if (strcmp(op, "cmd_speech_end") == 0) { + handle_speech_end(c, root); + } + break; + default: + break; + } + + ha_json_free(root); + free(tmp); +} + +/* ======================== 连接管理 ======================== */ + +static int do_connect(ha_client_t *c) { + c->state = HA_STATE_CONNECTING; + c->reconnect_cnt++; + + /* 解析 server 地址 */ + char host[256] = {0}; + uint16_t port = 9890; + const char *p = c->config.server; + if (!p) return -1; + + /* 去掉 ws:// 前缀 */ + if (strncmp(p, "ws://", 5) == 0) p += 5; + else if (strncmp(p, "wss://", 6) == 0) p += 6; + + /* 提取 host:port */ + const char *colon = strchr(p, ':'); + const char *slash = strchr(p, '/'); + if (colon && (!slash || colon < slash)) { + int host_len = (int)(colon - p); + if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1; + memcpy(host, p, host_len); + host[host_len] = '\0'; + port = (uint16_t)atoi(colon + 1); + } else { + int host_len = (slash ? (int)(slash - p) : (int)strlen(p)); + if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1; + memcpy(host, p, host_len); + host[host_len] = '\0'; + } + + c->state = HA_STATE_WS_UPGRADING; + if (ha_ws_connect(&c->ws, &c->config.transport, host, port, + "/api/v1/device/ws", c->config.token) != 0) { + c->state = HA_STATE_DISCONNECTED; + return -1; + } + + /* 发送 hello */ + c->state = HA_STATE_HELLO_SENT; + if (send_hello(c) != 0) { + ha_ws_close(&c->ws); + c->state = HA_STATE_DISCONNECTED; + return -1; + } + + return 0; +} + +/* ======================== 公共 API ======================== */ + +ha_client_t *ha_client_new(const ha_config_t *config) { + ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t)); + if (!c) return NULL; + memcpy(&c->config, config, sizeof(ha_config_t)); + c->state = HA_STATE_INIT; + c->reconnect_cnt = 0; + return c; +} + +ha_status_t ha_client_start(ha_client_t *client) { + if (!client) return HA_ERR_INVALID; + if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC; + + /* 默认心跳间隔 30 秒 */ + if (client->config.ping_interval <= 0) { + client->config.ping_interval = 30; + } + + if (do_connect(client) != 0) { + return HA_ERR_TRANSPORT; + } + + /* 等待 bind_ack(最多 5 秒) */ + int wait_ms = 5000; + int step = 50; + while (wait_ms > 0 && client->state != HA_STATE_READY) { + /* 处理一帧 */ + ha_status_t st = ha_client_process(client); + if (st != HA_OK && st != HA_ERR_DISCONNECTED) { + return st; + } + if (client->state == HA_STATE_READY) return HA_OK; + + /* 简单延时:靠 process 中的 recv 阻塞 */ + wait_ms -= step; + } + + return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT; +} + +ha_status_t ha_client_process(ha_client_t *client) { + if (!client) return HA_ERR_INVALID; + + if (client->state == HA_STATE_STOPPING) { + return HA_ERR_DISCONNECTED; + } + + /* 断线重连 */ + if (client->state == HA_STATE_DISCONNECTED || + client->state == HA_STATE_INIT) { + if (client->config.max_reconnect >= 0 && + client->reconnect_cnt > client->config.max_reconnect) { + return HA_ERR_DISCONNECTED; + } + /* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */ + return HA_ERR_DISCONNECTED; + } + + if (!client->ws.connected) { + client->state = HA_STATE_DISCONNECTED; + if (client->config.on_state) { + client->config.on_state(0, client->config.userdata); + } + return HA_ERR_DISCONNECTED; + } + + /* 尝试读取一帧 */ + const uint8_t *payload = NULL; + int len = 0; + int ret = ha_ws_read_frame(&client->ws, &payload, &len); + + if (ret < 0) { + /* 连接断开 */ + client->state = HA_STATE_DISCONNECTED; + if (client->config.on_state) { + client->config.on_state(0, client->config.userdata); + } + return HA_ERR_DISCONNECTED; + } + + switch (ret) { + case WS_OPCODE_TEXT: + handle_text_message(client, payload, len); + break; + case WS_OPCODE_BINARY: + /* 二进制帧:如果处于语音聚合状态,追加数据 */ + if (client->speech.req_id[0] && payload) { + int new_len = client->speech.len + len; + if (new_len > client->speech.cap) { + int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096; + while (new_cap < new_len) new_cap *= 2; + uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap); + if (!nd) break; + client->speech.data = nd; + client->speech.cap = new_cap; + } + memcpy(client->speech.data + client->speech.len, payload, len); + client->speech.len = new_len; + } + break; + case WS_OPCODE_PING: + /* 回复 pong */ + ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0); + break; + case WS_OPCODE_PONG: + /* 收到 pong,忽略 */ + break; + case WS_OPCODE_CLOSE: + client->state = HA_STATE_DISCONNECTED; + if (client->config.on_state) { + client->config.on_state(0, client->config.userdata); + } + return HA_ERR_DISCONNECTED; + } + + return HA_OK; +} + +void ha_client_send_result(ha_client_t *client, const char *req_id, + const char *status, const char *output, + const char *error) { + if (!client || client->state != HA_STATE_READY) return; + json_init(client); + ha_json_builder_begin_object(&client->jb); + ha_json_builder_string(&client->jb, "op", "cmd_result"); + ha_json_builder_string(&client->jb, "req_id", req_id); + ha_json_builder_string(&client->jb, "status", status ? status : "ok"); + ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); + if (output && output[0]) { + ha_json_builder_string(&client->jb, "output", output); + } + if (error && error[0]) { + ha_json_builder_string(&client->jb, "error", error); + } + ha_json_builder_end_object(&client->jb); + ws_send_json(client); +} + +void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, + const char *kind, const char *mime, + const uint8_t *data, int len) { + if (!client || client->state != HA_STATE_READY) return; + + /* cmd_data_start */ + json_init(client); + ha_json_builder_begin_object(&client->jb); + ha_json_builder_string(&client->jb, "op", "cmd_data_start"); + ha_json_builder_string(&client->jb, "req_id", req_id); + ha_json_builder_string(&client->jb, "kind", kind ? kind : "data"); + ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream"); + ha_json_builder_int(&client->jb, "total", len); + ha_json_builder_int(&client->jb, "chunk_size", 8192); + ha_json_builder_end_object(&client->jb); + ws_send_json(client); + + /* 二进制帧分块发送 */ + int off = 0; + while (off < len) { + int chunk = len - off; + if (chunk > 8192) chunk = 8192; + if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return; + off += chunk; + } + + /* cmd_data_end */ + json_init(client); + ha_json_builder_begin_object(&client->jb); + ha_json_builder_string(&client->jb, "op", "cmd_data_end"); + ha_json_builder_string(&client->jb, "req_id", req_id); + ha_json_builder_string(&client->jb, "status", "ok"); + ha_json_builder_int(&client->jb, "total", len); + ha_json_builder_end_object(&client->jb); + ws_send_json(client); +} + +void ha_client_send_event(ha_client_t *client, const char *type, + const char *detail) { + if (!client || client->state != HA_STATE_READY) return; + json_init(client); + ha_json_builder_begin_object(&client->jb); + ha_json_builder_string(&client->jb, "op", "event"); + ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); + ha_json_builder_string(&client->jb, "type", type ? type : ""); + if (detail && detail[0]) { + ha_json_builder_string(&client->jb, "payload", detail); + } + ha_json_builder_end_object(&client->jb); + ws_send_json(client); +} + +void ha_client_send_status(ha_client_t *client, const char *status) { + if (!client || client->state != HA_STATE_READY) return; + json_init(client); + ha_json_builder_begin_object(&client->jb); + ha_json_builder_string(&client->jb, "op", "status"); + ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); + ha_json_builder_string(&client->jb, "status", status ? status : "online"); + ha_json_builder_end_object(&client->jb); + ws_send_json(client); +} + +void ha_client_stop(ha_client_t *client) { + if (!client) return; + client->state = HA_STATE_STOPPING; + if (client->ws.connected) { + ha_ws_close(&client->ws); + } +} + +void ha_client_destroy(ha_client_t *client) { + if (!client) return; + ha_client_stop(client); + free(client->speech.data); + free(client); +} + +/* ======================== 工具函数 ======================== */ + +void ha_cmd_parse_homeagent(const char *command, const char **cap, + const char **args) { + *cap = command; + *args = ""; + + if (!command) { + *cap = ""; + return; + } + + /* 去掉 homeagent- 前缀 */ + const char *p = command; + if (strncmp(p, "homeagent-", 10) == 0) { + p += 10; + } + + /* 按空格分割 */ + const char *space = strchr(p, ' '); + if (space) { + /* cap 指向 p 但不包含空格,需要临时拷贝 */ + /* 返回指针到原始字符串,调用方用 strncpy 取出 */ + *cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */ + /* 实际上,最简单的方式是原地修改,但 const 不允许 */ + /* 用静态缓冲区或让调用方自己处理 */ + static char cap_buf[256]; + int n = (int)(space - p); + if (n > 255) n = 255; + strncpy(cap_buf, p, n); + cap_buf[n] = '\0'; + *cap = cap_buf; + *args = space + 1; + } else { + static char cap_buf[256]; + strncpy(cap_buf, p, sizeof(cap_buf) - 1); + cap_buf[sizeof(cap_buf) - 1] = '\0'; + *cap = cap_buf; + *args = ""; + } +} + +void ha_cmd_parse_json(const char *command, const char **action, + const char **json_str) { + *action = ""; + *json_str = ""; + + if (!command) return; + + const char *p = command; + if (strncmp(p, "homeagent-", 10) == 0) { + p += 10; + } + + const char *brace = strchr(p, '{'); + if (brace) { + static char act_buf[256]; + int n = (int)(brace - p); + while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--; + if (n > 255) n = 255; + strncpy(act_buf, p, n); + act_buf[n] = '\0'; + *action = act_buf; + *json_str = brace; + } else { + static char act_buf[256]; + strncpy(act_buf, p, sizeof(act_buf) - 1); + *action = act_buf; + } +} + +const char *ha_version(void) { + return HA_VERSION; +} + diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_ws.c b/third_party/homeagent-sdk/remotedevice/src/ha_ws.c new file mode 100644 index 0000000..266509d --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/src/ha_ws.c @@ -0,0 +1,325 @@ +#include "ha_ws.h" +#include +#include +#include + +/* WS GUID 用于计算 Accept 值 */ +#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +/* ======================== Base64 编码(用于 WS key) ======================== */ +static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static void base64_encode_bin(const uint8_t *in, int in_len, char *out) { + int i = 0, j = 0; + uint8_t b[3]; + while (i < in_len) { + int rem = in_len - i; + if (rem >= 3) { + b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++]; + out[j++] = b64t[b[0] >> 2]; + out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)]; + out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)]; + out[j++] = b64t[b[2] & 0x3F]; + } else if (rem == 2) { + b[0] = in[i++]; b[1] = in[i++]; + out[j++] = b64t[b[0] >> 2]; + out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)]; + out[j++] = b64t[(b[1] & 0x0F) << 2]; + out[j++] = '='; + } else { + b[0] = in[i++]; + out[j++] = b64t[b[0] >> 2]; + out[j++] = b64t[(b[0] & 0x03) << 4]; + out[j++] = '='; + out[j++] = '='; + } + } + out[j] = '\0'; +} + +/* 简单伪随机数生成器 */ +static uint32_t ws_rand_state = 0; +static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; } +static uint32_t ws_rand(void) { + ws_rand_state = ws_rand_state * 1103515245 + 12345; + return ws_rand_state; +} + +/* 生成 WS 握手 key */ +static void ws_gen_key(char *out) { + uint8_t buf[16]; + for (int i = 0; i < 16; i++) { + buf[i] = (uint8_t)(ws_rand() & 0xFF); + } + base64_encode_bin(buf, 16, out); +} + +/* ======================== 从传输层接收指定字节数 ======================== */ +static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) { + int pos = 0; + while (pos < len) { + int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos); + if (n <= 0) return -1; + pos += n; + } + return 0; +} + +/* ======================== 发送 WS 帧 ======================== */ +int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) { + uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */ + int hdr_len = 0; + + hdr[0] = 0x80 | opcode; /* FIN + opcode */ + hdr_len = 2; + + int ext_len = 0; + if (len < 126) { + hdr[1] = 0x80 | len; /* mask bit + length */ + } else if (len < 65536) { + hdr[1] = 0x80 | 126; + hdr_len = 4; + hdr[2] = (uint8_t)(len >> 8); + hdr[3] = (uint8_t)(len & 0xFF); + ext_len = 2; + } else { + hdr[1] = 0x80 | 127; + hdr_len = 10; + uint64_t l = (uint64_t)len; + for (int i = 8; i > 0; i--) { + hdr[1 + i] = (uint8_t)(l & 0xFF); + l >>= 8; + } + ext_len = 8; + } + + /* mask key */ + uint8_t mask_key[4]; + mask_key[0] = (uint8_t)(ws_rand() & 0xFF); + mask_key[1] = (uint8_t)(ws_rand() & 0xFF); + mask_key[2] = (uint8_t)(ws_rand() & 0xFF); + mask_key[3] = (uint8_t)(ws_rand() & 0xFF); + + int mask_off = 2 + ext_len; + hdr[mask_off] = mask_key[0]; + hdr[mask_off + 1] = mask_key[1]; + hdr[mask_off + 2] = mask_key[2]; + hdr[mask_off + 3] = mask_key[3]; + hdr_len = mask_off + 4; + + /* 发送帧头 */ + if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) { + return -1; + } + + /* 发送掩码后的 payload */ + if (len > 0) { + /* 如果 payload 不大,用栈缓冲区 */ + uint8_t stack_buf[2048]; + uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len); + if (!masked) return -1; + + for (int i = 0; i < len; i++) { + masked[i] = payload[i] ^ mask_key[i & 3]; + } + + int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1; + + if (masked != stack_buf) free(masked); + if (ret != 0) return -1; + } + + return 0; +} + +/* ======================== 公共 API ======================== */ + +int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport, + const char *host, uint16_t port, + const char *path, const char *token) { + memset(ws, 0, sizeof(ha_ws_t)); + ws->transport = transport; + ws->connected = 0; + + strncpy(ws->host, host, sizeof(ws->host) - 1); + ws->port = port; + strncpy(ws->path, path, sizeof(ws->path) - 1); + if (token) strncpy(ws->token, token, sizeof(ws->token) - 1); + + /* 种子 */ + ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port); + + /* 1. TCP 连接 */ + if (transport->connect(transport->ctx, host, port) != 0) { + return -1; + } + + /* 2. 发送 WS 升级请求 */ + char key[32]; + ws_gen_key(key); + + char req[1024]; + int n = snprintf(req, sizeof(req), + "GET %s HTTP/1.1\r\n" + "Host: %s:%u\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", + path, host, (unsigned)port, key); + + /* 如果 token 存在,加到路径参数中 */ + if (token && token[0]) { + n = snprintf(req, sizeof(req), + "GET %s?token=%s HTTP/1.1\r\n" + "Host: %s:%u\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", + path, token, host, (unsigned)port, key); + } + + if (transport->send(transport->ctx, (uint8_t *)req, n) != n) { + transport->close(transport->ctx); + return -1; + } + + /* 3. 读取响应头(直到 \r\n\r\n) */ + char resp[1024]; + int resp_len = 0; + int found = 0; + while (resp_len < (int)sizeof(resp) - 1) { + int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1); + if (n <= 0) { + transport->close(transport->ctx); + return -1; + } + resp_len += n; + resp[resp_len] = '\0'; + if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) { + found = 1; + break; + } + } + if (!found) { + transport->close(transport->ctx); + return -1; + } + + /* 4. 检查状态码 101 */ + if (strstr(resp, " 101 ") == NULL) { + transport->close(transport->ctx); + return -1; + } + + ws->connected = 1; + return 0; +} + +int ha_ws_send_text(ha_ws_t *ws, const char *text) { + if (!ws->connected) return -1; + return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text)); +} + +int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) { + if (!ws->connected) return -1; + return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len); +} + +int ha_ws_send_ping(ha_ws_t *ws) { + if (!ws->connected) return -1; + return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0); +} + +int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) { + if (!ws->connected) return -1; + + *payload = NULL; + *len = 0; + + /* 读取帧头:2 字节 */ + uint8_t hdr[2]; + if (recv_all(ws, hdr, 2) != 0) { + ws->connected = 0; + return -1; + } + + int opcode = hdr[0] & 0x0F; + int masked = (hdr[1] & 0x80) ? 1 : 0; + uint64_t frame_len = hdr[1] & 0x7F; + + if (frame_len == 126) { + uint8_t ext[2]; + if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; } + frame_len = ((uint64_t)ext[0] << 8) | ext[1]; + } else if (frame_len == 127) { + uint8_t ext[8]; + if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; } + frame_len = 0; + for (int i = 0; i < 8; i++) { + frame_len = (frame_len << 8) | ext[i]; + } + } + + /* 读取 mask key */ + uint8_t mask_key[4] = {0, 0, 0, 0}; + if (masked) { + if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; } + } + + /* 限制帧大小 */ + if (frame_len > sizeof(ws->read_buf)) { + /* 帧太大,跳过 payload */ + uint64_t skip = frame_len; + uint8_t tmp[256]; + while (skip > 0) { + int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip; + if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; } + skip -= to_skip; + } + return -1; /* 返回错误,帧太大 */ + } + + /* 读取 payload */ + if (frame_len > 0) { + if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) { + ws->connected = 0; + return -1; + } + /* 如果有 mask,解掩码 */ + if (masked) { + for (uint64_t i = 0; i < frame_len; i++) { + ws->read_buf[i] ^= mask_key[i & 3]; + } + } + } + + *payload = ws->read_buf; + *len = (int)frame_len; + + switch (opcode) { + case WS_OPCODE_CLOSE: + ws->connected = 0; + return WS_OPCODE_CLOSE; + case WS_OPCODE_PING: + return WS_OPCODE_PING; + case WS_OPCODE_PONG: + return WS_OPCODE_PONG; + case WS_OPCODE_TEXT: + case WS_OPCODE_BINARY: + return opcode; + default: + return -1; + } +} + +void ha_ws_close(ha_ws_t *ws) { + if (ws->connected) { + ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0); + ws->connected = 0; + } + ws->transport->close(ws->transport->ctx); +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_ws.h b/third_party/homeagent-sdk/remotedevice/src/ha_ws.h new file mode 100644 index 0000000..e47cfcd --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/src/ha_ws.h @@ -0,0 +1,62 @@ +#ifndef HA_WS_H +#define HA_WS_H + +#include +#include +#include "../include/ha_remotedevice.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ======================== WS 帧类型 ======================== */ +#define WS_OPCODE_CONTINUATION 0x0 +#define WS_OPCODE_TEXT 0x1 +#define WS_OPCODE_BINARY 0x2 +#define WS_OPCODE_CLOSE 0x8 +#define WS_OPCODE_PING 0x9 +#define WS_OPCODE_PONG 0xA + +/* ======================== WS 连接 ======================== */ +typedef struct { + ha_transport_t *transport; /* 用户实现的传输层 */ + int connected; /* 是否已连接 */ + uint8_t read_buf[8192]; /* 读缓冲区 */ + int read_pos; /* 缓冲区中有效数据起始位置 */ + int read_len; /* 缓冲区中有效数据长度 */ + char host[256]; /* 缓存目标地址 */ + uint16_t port; + char path[256]; + char token[256]; +} ha_ws_t; + +/* 创建 WS 连接。返回 0 成功,非 0 失败。 */ +int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport, + const char *host, uint16_t port, + const char *path, const char *token); + +/* 发送文本帧。返回 0 成功。 */ +int ha_ws_send_text(ha_ws_t *ws, const char *text); + +/* 发送二进制帧。返回 0 成功。 */ +int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len); + +/* 发送 ping。返回 0 成功。 */ +int ha_ws_send_ping(ha_ws_t *ws); + +/* 读取一帧。 + * 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。 + * payload 和 len 指向内部缓冲区,在下次调用前有效。 */ +int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len); + +/* 发送原始 WS 帧(内部使用,用于回复 ping) */ +int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len); + +/* 关闭 WS 连接 */ +void ha_ws_close(ha_ws_t *ws); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_WS_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c b/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c new file mode 100644 index 0000000..affd3f9 --- /dev/null +++ b/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c @@ -0,0 +1,1509 @@ +/** + * ha_remotedevice 全面测试 + * + * 覆盖:JSON 解析/构建、WS 帧编解码、工具函数、 + * 客户端完整生命周期、命令分发、二进制分块、语音数据、事件上报。 + * + * 编译 (POSIX/Linux/macOS): + * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -o test + * + * 编译 (Windows/MinGW): + * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -lws2_32 -o test + */ + +#include "ha_remotedevice.h" +#include "ha_json.h" +#include "ha_ws.h" + +#include +#include +#include +#include +#include + +/* 前向声明:ha_client_send_data_chunked 供测试调用 */ +void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, + const char *kind, const char *mime, + const uint8_t *data, int len); + +/* ================================================================== + * 平台适配 + * ================================================================== */ +#if defined(_WIN32) || defined(_WIN64) + #define _CRT_SECURE_NO_WARNINGS + #include + #include + #include + typedef int socklen_t; + #define sleep(t) Sleep((t)*1000) + #define usleep(t) Sleep((t)/1000) + typedef unsigned thread_func_ret; + #define THREAD_RETURN return 0 + #define SIGPIPE 13 + static void signal(int sig, void (*func)(int)) { (void)sig; (void)func; } + static void sock_close(int fd) { closesocket(fd); } +#else + #include + #include + #include + #include + #include + #include + #include + typedef void *thread_func_ret; + #define THREAD_RETURN return NULL + static void sock_close(int fd) { close(fd); } +#endif + +/* ================================================================== + * 简易测试框架 + * ================================================================== */ +static int tests_passed = 0; +static int tests_failed = 0; +static int tests_skipped = 0; + +#define TEST_BEGIN(name) do { \ + printf(" TEST: %s ... ", name); \ + fflush(stdout); \ + do { (void)0 + +#define TEST_END() } while(0); \ + printf("PASS\n"); \ + tests_passed++; \ +} while(0) + +#define TEST_FAIL(msg) do { \ + printf("FAIL: %s\n", msg); \ + tests_failed++; \ + return; \ +} while(0) + +#define TEST_ASSERT(cond, msg) do { \ + if (!(cond)) { TEST_FAIL(msg); } \ +} while(0) + +#define TEST_SKIP(reason) do { \ + printf("SKIP: %s\n", reason); \ + tests_skipped++; \ + return; \ +} while(0) + +/* ================================================================== + * 工具:在本地端口启动一个 TCP 服务器(用于 mock 网关) + * ================================================================== */ +typedef struct { + int listen_fd; + int client_fd; + int port; +#if defined(_WIN32) || defined(_WIN64) + uintptr_t thread; +#else + pthread_t thread; +#endif + volatile int running; +} mock_server_t; + +/* 发送 WS 文本帧(服务器端,无需掩码) */ +static void mock_send_text(int fd, const char *json) { + int len = (int)strlen(json); + uint8_t hdr[10]; + int hdr_len = 2; + hdr[0] = 0x80 | 0x1; + if (len < 126) { + hdr[1] = (uint8_t)len; + } else if (len < 65536) { + hdr[1] = 126; + hdr[2] = (uint8_t)(len >> 8); + hdr[3] = (uint8_t)(len & 0xFF); + hdr_len = 4; + } else { + hdr[1] = 127; + uint64_t l = (uint64_t)len; + for (int i = 8; i > 0; i--) { + hdr[1 + i] = (uint8_t)(l & 0xFF); + l >>= 8; + } + hdr_len = 10; + } + send(fd, (const char *)hdr, hdr_len, 0); + send(fd, json, len, 0); +} + +/* 发送 WS 二进制帧(服务器端,无需掩码) */ +static void mock_send_binary(int fd, const uint8_t *data, int len) { + uint8_t hdr[10]; + int hdr_len = 2; + hdr[0] = 0x80 | 0x2; + if (len < 126) { + hdr[1] = (uint8_t)len; + } else if (len < 65536) { + hdr[1] = 126; + hdr[2] = (uint8_t)(len >> 8); + hdr[3] = (uint8_t)(len & 0xFF); + hdr_len = 4; + } else { + hdr[1] = 127; + uint64_t l = (uint64_t)len; + for (int i = 8; i > 0; i--) { + hdr[1 + i] = (uint8_t)(l & 0xFF); + l >>= 8; + } + hdr_len = 10; + } + send(fd, (const char *)hdr, hdr_len, 0); + send(fd, (const char *)data, len, 0); +} + +/* 读取 WS 文本帧(服务器端,解析掩码) */ +static char *mock_read_text(int fd) { + uint8_t hdr[2]; + if (recv(fd, (char *)hdr, 2, 0) != 2) return NULL; + int masked = (hdr[1] & 0x80) ? 1 : 0; + uint64_t len = hdr[1] & 0x7F; + if (len == 126) { + uint8_t ext[2]; + if (recv(fd, (char *)ext, 2, 0) != 2) return NULL; + len = ((uint64_t)ext[0] << 8) | ext[1]; + } else if (len == 127) { + uint8_t ext[8]; + if (recv(fd, (char *)ext, 8, 0) != 8) return NULL; + len = 0; + for (int i = 0; i < 8; i++) len = (len << 8) | ext[i]; + } + uint8_t mask_key[4] = {0}; + if (masked) { + if (recv(fd, (char *)mask_key, 4, 0) != 4) return NULL; + } + char *buf = (char *)malloc((size_t)len + 1); + if (!buf) return NULL; + if (len > 0) { + if ((int)recv(fd, buf, (int)len, 0) != (int)len) { + free(buf); + return NULL; + } + if (masked) { + for (uint64_t i = 0; i < len; i++) + buf[i] ^= mask_key[i & 3]; + } + } + buf[len] = '\0'; + return buf; +} + +/* mock 服务器线程 */ +#if defined(_WIN32) || defined(_WIN64) +static unsigned __stdcall mock_server_thread(void *arg) { +#else +static void *mock_server_thread(void *arg) { +#endif + mock_server_t *ms = (mock_server_t *)arg; + struct sockaddr_in client_addr; + socklen_t addr_len = sizeof(client_addr); + int fd = accept(ms->listen_fd, (struct sockaddr *)&client_addr, &addr_len); + if (fd < 0) THREAD_RETURN; + ms->client_fd = fd; + + /* 读取 WS 升级请求 */ + char buf[4096] = {0}; + int n = 0; + while (n < (int)sizeof(buf) - 1) { + int r = (int)recv(fd, buf + n, 1, 0); + if (r <= 0) break; + n += r; + buf[n] = '\0'; + if (n >= 4 && strcmp(buf + n - 4, "\r\n\r\n") == 0) break; + } + + /* 发送 101 响应 */ + const char *resp = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" + "\r\n"; + send(fd, resp, (int)strlen(resp), 0); + + /* 协议循环 */ + char *msg; + while (ms->running) { + msg = mock_read_text(fd); + if (!msg) break; + + if (strstr(msg, "\"hello\"")) { + mock_send_text(fd, "{\"op\":\"hello_ack\",\"status\":\"ok\"}"); + } else if (strstr(msg, "\"bind\"")) { + mock_send_text(fd, "{\"op\":\"bind_ack\",\"status\":\"ok\",\"device_id\":\"test-dev\"}"); + } + /* cmd_result, event, status, cmd_data_start/end 可以忽略 */ + free(msg); + } + sock_close(fd); + ms->client_fd = -1; + THREAD_RETURN; +} + +static mock_server_t *mock_server_start(int port) { + mock_server_t *ms = (mock_server_t *)calloc(1, sizeof(mock_server_t)); + +#if defined(_WIN32) || defined(_WIN64) + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + + ms->listen_fd = (int)socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + setsockopt(ms->listen_fd, SOL_SOCKET, SO_REUSEADDR, + (const char *)&opt, sizeof(opt)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + if (bind(ms->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + free(ms); + return NULL; + } + listen(ms->listen_fd, 1); + ms->port = port; + ms->running = 1; + ms->client_fd = -1; + +#if defined(_WIN32) || defined(_WIN64) + ms->thread = _beginthreadex(NULL, 0, mock_server_thread, ms, 0, NULL); +#else + pthread_create(&ms->thread, NULL, mock_server_thread, ms); +#endif + usleep(200000); /* 等待服务器就绪 */ + return ms; +} + +static void mock_server_stop(mock_server_t *ms) { + if (!ms) return; + ms->running = 0; + if (ms->client_fd >= 0) sock_close(ms->client_fd); + sock_close(ms->listen_fd); +#if defined(_WIN32) || defined(_WIN64) + WaitForSingleObject((HANDLE)ms->thread, 3000); + WSACleanup(); +#else + pthread_join(ms->thread, NULL); +#endif + free(ms); +} + +/* ================================================================== + * 传输层实现(POSIX socket) + * ================================================================== */ +struct transport_ctx { + int sock; +}; + +static int transport_connect(void *ctx, const char *host, uint16_t port) { + struct transport_ctx *tc = (struct transport_ctx *)ctx; + struct hostent *he = gethostbyname(host); + if (!he) return -1; + tc->sock = (int)socket(AF_INET, SOCK_STREAM, 0); + if (tc->sock < 0) return -1; + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length); + if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + sock_close(tc->sock); + tc->sock = -1; + return -1; + } + /* 设置 100ms 接收超时,使 ha_client_process 不会永久阻塞 */ +#if defined(_WIN32) || defined(_WIN64) + DWORD timeout = 100; + setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); +#else + struct timeval tv = {0, 100000}; + setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif + return 0; +} + +static int transport_send(void *ctx, const uint8_t *data, int len) { + struct transport_ctx *tc = (struct transport_ctx *)ctx; + int sent = 0; + while (sent < len) { + int n = (int)send(tc->sock, (const char *)(data + sent), len - sent, 0); + if (n <= 0) return -1; + sent += n; + } + return sent; +} + +static int transport_recv(void *ctx, uint8_t *buf, int len) { + struct transport_ctx *tc = (struct transport_ctx *)ctx; + return (int)recv(tc->sock, (char *)buf, len, 0); +} + +static void transport_close(void *ctx) { + struct transport_ctx *tc = (struct transport_ctx *)ctx; + if (tc->sock >= 0) { + sock_close(tc->sock); + tc->sock = -1; + } +} + +/* ================================================================== + * mock 服务器:发送命令 + * ================================================================== */ +static void mock_send_cmd(mock_server_t *ms, const char *cmd_type, + const char *req_id, const char *cmd) { + char buf[4096]; + char escaped[2048]; + int ei = 0; + /* 转义 cmd 中的双引号和反斜杠 */ + if (cmd) { + for (int i = 0; cmd[i] && ei < (int)sizeof(escaped) - 6; i++) { + if (cmd[i] == '"' || cmd[i] == '\\') { + escaped[ei++] = '\\'; + if (ei >= (int)sizeof(escaped) - 1) break; + } + escaped[ei++] = cmd[i]; + } + } + escaped[ei] = '\0'; + int n = snprintf(buf, sizeof(buf), + "{\"op\":\"cmd\",\"req_id\":\"%s\",\"cmd_type\":\"%s\",\"command\":\"%s\"}", + req_id ? req_id : "", cmd_type ? cmd_type : "", escaped); + mock_send_text(ms->client_fd, buf); +} + +/* ================================================================== + * 测试用例 + * ================================================================== */ + +/* ---------- 1. JSON 解析器 ---------- */ +static void test_json_parser(void) { + TEST_BEGIN("JSON parser: parse object"); + + const char *json = "{\"op\":\"hello\",\"device\":{\"id\":\"test\",\"caps\":[\"a\",\"b\"]}}"; + ha_json_node_t *root = ha_json_parse(json); + TEST_ASSERT(root != NULL, "parse failed"); + TEST_ASSERT(root->type == HA_JSON_OBJECT, "not an object"); + + const char *op = ha_json_get_string(root, "op"); + TEST_ASSERT(op != NULL && strcmp(op, "hello") == 0, "op mismatch"); + + ha_json_node_t *dev = ha_json_get(root, "device"); + TEST_ASSERT(dev != NULL && dev->type == HA_JSON_OBJECT, "device not object"); + + const char *id = ha_json_get_string(dev, "id"); + TEST_ASSERT(id != NULL && strcmp(id, "test") == 0, "device id mismatch"); + + ha_json_node_t *caps = ha_json_get(dev, "caps"); + TEST_ASSERT(caps != NULL && caps->type == HA_JSON_ARRAY, "caps not array"); + TEST_ASSERT(ha_json_array_len(caps) == 2, "caps length wrong"); + + ha_json_node_t *c0 = ha_json_array_get(caps, 0); + TEST_ASSERT(c0 != NULL && c0->type == HA_JSON_STRING && + strcmp(c0->str_val, "a") == 0, "caps[0] mismatch"); + + ha_json_free(root); + TEST_END(); +} + +static void test_json_int(void) { + TEST_BEGIN("JSON parser: int fields"); + + const char *json = "{\"count\":42,\"total\":100500,\"neg\":-7}"; + ha_json_node_t *root = ha_json_parse(json); + TEST_ASSERT(root != NULL, "parse failed"); + + TEST_ASSERT(ha_json_get_int(root, "count", -1) == 42, "count mismatch"); + TEST_ASSERT(ha_json_get_int(root, "total", -1) == 100500, "total mismatch"); + TEST_ASSERT(ha_json_get_int(root, "neg", 0) == -7, "neg mismatch"); + TEST_ASSERT(ha_json_get_int(root, "nonexistent", -999) == -999, "default wrong"); + + ha_json_free(root); + TEST_END(); +} + +static void test_json_builder(void) { + TEST_BEGIN("JSON builder: object with string + int + bool"); + + char buf[256]; + ha_json_builder_t jb; + ha_json_builder_init(&jb, buf, sizeof(buf)); + ha_json_builder_begin_object(&jb); + ha_json_builder_string(&jb, "op", "hello"); + ha_json_builder_int(&jb, "seq", 1); + ha_json_builder_bool(&jb, "active", 1); + ha_json_builder_end_object(&jb); + + const char *r = ha_json_builder_str(&jb); + TEST_ASSERT(r != NULL, "builder returned NULL"); + TEST_ASSERT(strstr(r, "\"op\":\"hello\"") != NULL, "missing op"); + TEST_ASSERT(strstr(r, "\"seq\":1") != NULL, "missing seq"); + TEST_ASSERT(strstr(r, "\"active\":true") != NULL, "missing bool"); + + TEST_END(); +} + +static void test_json_array_builder(void) { + TEST_BEGIN("JSON builder: array of strings"); + + char buf[256]; + ha_json_builder_t jb; + ha_json_builder_init(&jb, buf, sizeof(buf)); + ha_json_builder_begin_object(&jb); + ha_json_builder_key(&jb, "caps"); + ha_json_builder_begin_array(&jb); + ha_json_builder_add_string(&jb, "camera"); + ha_json_builder_add_string(&jb, "screen"); + ha_json_builder_end_array(&jb); + ha_json_builder_end_object(&jb); + + const char *r = ha_json_builder_str(&jb); + TEST_ASSERT(r != NULL, "builder returned NULL"); + TEST_ASSERT(strstr(r, "\"camera\"") != NULL, "missing camera"); + TEST_ASSERT(strstr(r, "\"screen\"") != NULL, "missing screen"); + + TEST_END(); +} + +/* ---------- 2. 工具函数 ---------- */ +static void test_parse_homeagent(void) { + TEST_BEGIN("ha_cmd_parse_homeagent: basic"); + + const char *cap, *args; + + ha_cmd_parse_homeagent("camerasue 5", &cap, &args); + TEST_ASSERT(strcmp(cap, "camerasue") == 0, "cap mismatch"); + TEST_ASSERT(strcmp(args, "5") == 0, "args mismatch"); + + ha_cmd_parse_homeagent("screensee", &cap, &args); + TEST_ASSERT(strcmp(cap, "screensee") == 0, "cap mismatch (no args)"); + TEST_ASSERT(strcmp(args, "") == 0, "args should be empty"); + + ha_cmd_parse_homeagent("homeagent-camerasue 3", &cap, &args); + TEST_ASSERT(strcmp(cap, "camerasue") == 0, "prefix not stripped"); + TEST_ASSERT(strcmp(args, "3") == 0, "args after prefix"); + + ha_cmd_parse_homeagent("", &cap, &args); + TEST_ASSERT(strcmp(cap, "") == 0, "empty input"); + + TEST_END(); +} + +static void test_parse_json(void) { + TEST_BEGIN("ha_cmd_parse_json: action + json"); + + const char *action, *json_str; + + ha_cmd_parse_json("computeruse {\"action\":\"click\",\"x\":100}", + &action, &json_str); + TEST_ASSERT(strcmp(action, "computeruse") == 0, "action mismatch"); + TEST_ASSERT(strstr(json_str, "\"action\"") != NULL, "json missing"); + + ha_cmd_parse_json("screensee", &action, &json_str); + TEST_ASSERT(strcmp(action, "screensee") == 0, "action no json"); + TEST_ASSERT(strcmp(json_str, "") == 0, "json should be empty"); + + TEST_END(); +} + +static void test_base64(void) { + TEST_BEGIN("ha_base64_encode: basic"); + + const uint8_t data[] = "Hello, World!"; + char out[64]; + int n = ha_base64_encode(data, 13, out, sizeof(out)); + TEST_ASSERT(n > 0, "encode returned 0"); + TEST_ASSERT(strcmp(out, "SGVsbG8sIFdvcmxkIQ==") == 0, "base64 mismatch"); + + const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF}; + n = ha_base64_encode(jpeg, 3, out, sizeof(out)); + TEST_ASSERT(n > 0, "short encode failed"); + TEST_ASSERT(strcmp(out, "/9j/") == 0, "jpeg magic mismatch"); + + /* 缓冲区不足 */ + n = ha_base64_encode(data, 13, out, 5); + TEST_ASSERT(n > 5, "should return needed size"); + + TEST_END(); +} + +/* ---------- 3. WS 帧编解码 ---------- */ +static void test_ws_frame(void) { +#if defined(_WIN32) || defined(_WIN64) + printf(" TEST: WS frame: encode/decode with mask ... SKIP: socketpair not available on Windows\n"); + tests_skipped++; + return; +#else + TEST_BEGIN("WS frame: encode/decode with mask"); + + /* 使用 TCP 本地连接测试 */ + int sv[2]; + TEST_ASSERT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0, + "socketpair failed"); + + const char *test_payload = "hello world"; + int test_len = (int)strlen(test_payload); + + /* 发送端:手动构造 WS 帧 */ + uint8_t frame[1024]; + int off = 0; + frame[off++] = 0x80 | 0x1; + frame[off++] = 0x80 | (uint8_t)test_len; + uint8_t mask[4] = {0x01, 0x02, 0x03, 0x04}; + memcpy(frame + off, mask, 4); off += 4; + for (int i = 0; i < test_len; i++) + frame[off++] = test_payload[i] ^ mask[i & 3]; + send(sv[0], (const char *)frame, off, 0); + + /* 接收端 */ + ha_transport_t transport = { + .send = transport_send, .recv = transport_recv, + .close = transport_close, + }; + struct transport_ctx tctx_reader = { .sock = sv[1] }; + transport.ctx = &tctx_reader; + + ha_ws_t ws; + memset(&ws, 0, sizeof(ws)); + ws.transport = &transport; + ws.connected = 1; + + const uint8_t *payload; + int len; + int opcode = ha_ws_read_frame(&ws, &payload, &len); + TEST_ASSERT(opcode == 0x1, "expected text frame"); + TEST_ASSERT(len == test_len, "length mismatch"); + TEST_ASSERT(memcmp(payload, test_payload, len) == 0, "payload mismatch"); + + sock_close(sv[0]); + sock_close(sv[1]); + TEST_END(); +#endif +} + +/* ---------- 4. ha_version ---------- */ +static void test_version(void) { + TEST_BEGIN("ha_version: returns non-empty"); + + const char *v = ha_version(); + TEST_ASSERT(v != NULL && v[0] != '\0', "version empty"); + printf("(v=%s) ", v); + TEST_END(); +} + +/* ---------- 5. 完整客户端生命周期 ---------- */ +static volatile int lifecycle_state = 0; + +static void test_lifecycle_on_state(int connected, void *userdata) { + (void)userdata; + lifecycle_state = connected ? 1 : 0; +} + +static ha_status_t test_handler_camerasue(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] camerasue called, args=%s\n", args ? args : ""); + result->status = 0; + result->output = "data:image/jpeg;base64,test123"; + return HA_OK; +} + +static ha_status_t test_handler_shell(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] shell called: %s\n", args ? args : ""); + result->status = 0; + result->output = "shell output here"; + return HA_OK; +} + +static void test_client_lifecycle(void) { + TEST_BEGIN("Client lifecycle: connect -> hello -> bind -> ready"); + + mock_server_t *ms = mock_server_start(19890); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"camera", "status", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "shell", .handler = test_handler_shell}, + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19890", + .token = "test-token", + .device = { + .device_id = "test-dev", + .name = "Test Device", + .kind = "camera", + .caps = caps, + }, + .handlers = handlers, + .on_state = test_lifecycle_on_state, + .ping_interval = 30, + }; + + lifecycle_state = 0; + ha_client_t *client = ha_client_new(&config); + TEST_ASSERT(client != NULL, "client new failed"); + + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + TEST_ASSERT(lifecycle_state == 1, "should be connected"); + + /* 发送命令验证分发 */ + mock_send_cmd(ms, "homeagent", "req-1", "camerasue 3"); + usleep(200000); + ha_client_process(client); + + mock_send_cmd(ms, "shell", "req-2", "ls -la"); + usleep(200000); + ha_client_process(client); + + /* 未注册的命令 */ + mock_send_cmd(ms, "homeagent", "req-3", "unknowncmd"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 6. 命令分发 ---------- */ +static volatile int cmd_camerasue_fired = 0; +static volatile int cmd_shell_fired = 0; +static char cmd_req_id[128] = ""; + +static ha_status_t test_handler_camerasue2(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)userdata; + cmd_camerasue_fired = 1; + strncpy(cmd_req_id, req_id, sizeof(cmd_req_id) - 1); + if (strcmp(args, "5") != 0) { + printf(" [handler] args mismatch: expected '5', got '%s'\n", args); + result->status = 1; + result->error = "args mismatch"; + return HA_OK; + } + result->status = 0; + result->output = "snapshot taken"; + return HA_OK; +} + +static ha_status_t test_handler_shell2(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)userdata; + cmd_shell_fired = 1; + if (strstr(args, "ls") == NULL) { + printf(" [handler] args should contain 'ls', got '%s'\n", args); + result->status = 1; + result->error = "args mismatch"; + return HA_OK; + } + result->status = 0; + result->output = "file1.txt\nfile2.txt"; + return HA_OK; +} + +static void test_command_dispatch(void) { + TEST_BEGIN("Command dispatch: homeagent + shell"); + + mock_server_t *ms = mock_server_start(19891); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"camera", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "shell", .handler = test_handler_shell2}, + {.command = "camerasue", .handler = test_handler_camerasue2}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19891", + .token = "test-token", + .device = { + .device_id = "cmd-dev", + .name = "Cmd Test", + .kind = "camera", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + cmd_camerasue_fired = 0; + cmd_shell_fired = 0; + memset(cmd_req_id, 0, sizeof(cmd_req_id)); + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 homeagent 命令 */ + mock_send_cmd(ms, "homeagent", "req-cam", "camerasue 5"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(cmd_camerasue_fired == 1, "camerasue handler not called"); + TEST_ASSERT(strcmp(cmd_req_id, "req-cam") == 0, "req_id mismatch"); + + /* 发送 shell 命令 */ + mock_send_cmd(ms, "shell", "req-sh", "ls -la /tmp"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(cmd_shell_fired == 1, "shell handler not called"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 7. 事件上报 ---------- */ +static void test_event_report(void) { + TEST_BEGIN("Event report: client sends event to server"); + + mock_server_t *ms = mock_server_start(19892); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19892", + .token = "test-token", + .device = { + .device_id = "evt-dev", + .name = "Event Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = NULL, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + ha_client_send_event(client, "motion_detected", "{\"zone\":\"front_door\"}"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 8. 状态上报 ---------- */ +static void test_status_report(void) { + TEST_BEGIN("Status report: client sends status to server"); + + mock_server_t *ms = mock_server_start(19893); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19893", + .token = "test-token", + .device = { + .device_id = "st-dev", + .name = "Status Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = NULL, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + ha_client_send_status(client, "offline"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 9. 二进制分块回传 ---------- */ +static void test_binary_chunked(void) { + TEST_BEGIN("Binary chunked transfer: send video data to server"); + + mock_server_t *ms = mock_server_start(19894); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19894", + .token = "test-token", + .device = { + .device_id = "bin-dev", + .name = "Binary Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 模拟录像数据 */ + uint8_t video_data[20000]; + for (int i = 0; i < 20000; i++) video_data[i] = (uint8_t)(i % 251); + + ha_client_send_data_chunked(client, "req-video", "camera_video", + "video/mp4", video_data, 20000); + usleep(500000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 10. 未注册命令处理 ---------- */ +static void test_unsupported_command(void) { + TEST_BEGIN("Unsupported command: returns error gracefully"); + + mock_server_t *ms = mock_server_start(19895); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19895", + .token = "test-token", + .device = { + .device_id = "unsup-dev", + .name = "Unsupported Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送未注册命令 */ + mock_send_cmd(ms, "homeagent", "req-unsup", "screensee"); + usleep(100000); + ha_client_process(client); + + /* 发送已注册命令 */ + mock_send_cmd(ms, "homeagent", "req-ok", "camerasue"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 11. 语音数据接收 ---------- */ +static volatile int speech_received = 0; +static uint8_t speech_data[4096]; +static int speech_len = 0; + +static void test_on_binary(const char *req_id, const char *kind, + const char *mime, const uint8_t *data, + int len, void *userdata) { + (void)req_id; (void)kind; (void)mime; (void)userdata; + speech_received = 1; + speech_len = len < (int)sizeof(speech_data) ? len : (int)sizeof(speech_data); + memcpy(speech_data, data, (size_t)speech_len); +} + +static void test_speech_receive(void) { + TEST_BEGIN("Speech data receive: cmd_speech_start -> binary -> end"); + + mock_server_t *ms = mock_server_start(19896); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19896", + .token = "test-token", + .device = { + .device_id = "speech-dev", + .name = "Speech Test", + .kind = "speaker", + .caps = (const char *[]){"speaker", NULL}, + }, + .handlers = NULL, + .on_binary = test_on_binary, + .ping_interval = 30, + }; + + speech_received = 0; + speech_len = 0; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* mock 服务器发送语音数据 */ + const char *audio_data = "RIFF....fake-wav-data...."; + mock_send_text(ms->client_fd, + "{\"op\":\"cmd_speech_start\",\"req_id\":\"req-speech\"," + "\"kind\":\"speech\",\"mime\":\"audio/wav\",\"total\":25}"); + usleep(50000); + mock_send_binary(ms->client_fd, (const uint8_t *)audio_data, 25); + usleep(50000); + mock_send_text(ms->client_fd, + "{\"op\":\"cmd_speech_end\",\"req_id\":\"req-speech\"}"); + usleep(200000); + + /* 处理帧 */ + for (int i = 0; i < 50; i++) { + ha_client_process(client); + if (speech_received) break; + usleep(20000); + } + + TEST_ASSERT(speech_received == 1, "speech handler not called"); + TEST_ASSERT(speech_len == 25, "speech length mismatch"); + TEST_ASSERT(memcmp(speech_data, audio_data, 25) == 0, "speech data mismatch"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 12. 二进制分块 via handler ---------- */ +static ha_status_t test_handler_video(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] video record %s\n", args ? args : ""); + + static uint8_t video[5000]; + for (int i = 0; i < 5000; i++) video[i] = (uint8_t)(i & 0xFF); + + result->status = 0; + result->has_binary = 1; + result->binary_data = video; + result->binary_len = 5000; + result->binary_mime = "video/mp4"; + return HA_OK; +} + +static void test_binary_via_handler(void) { + TEST_BEGIN("Binary via handler: handler sets has_binary, SDK auto-chunks"); + + mock_server_t *ms = mock_server_start(19897); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_video}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19897", + .token = "test-token", + .device = { + .device_id = "bin2-dev", + .name = "Binary Via Handler", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + mock_send_cmd(ms, "homeagent", "req-video2", "camerasue 10"); + usleep(500000); + ha_client_process(client); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 13. computeruse:结构化 JSON 命令分发 ---------- */ +static volatile int computeruse_fired = 0; +static char computeruse_action[64] = ""; +static int computeruse_x = 0; +static int computeruse_y = 0; + +static ha_status_t test_handler_computeruse(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + computeruse_fired = 1; + /* 解析 JSON 参数 */ + if (args && args[0] == '{') { + ha_json_node_t *root = ha_json_parse(args); + if (root) { + const char *act = ha_json_get_string(root, "action"); + if (act) strncpy(computeruse_action, act, sizeof(computeruse_action) - 1); + computeruse_x = ha_json_get_int(root, "x", 0); + computeruse_y = ha_json_get_int(root, "y", 0); + ha_json_free(root); + } + } + result->status = 0; + result->output = "clicked at (100,200)"; + return HA_OK; +} + +static void test_computeruse_dispatch(void) { + TEST_BEGIN("Computeruse: structured JSON command dispatch"); + + mock_server_t *ms = mock_server_start(19898); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"computeruse", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "computeruse", .handler = test_handler_computeruse}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19898", + .token = "test-token", + .device = { + .device_id = "cu-dev", + .name = "ComputerUse Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + computeruse_fired = 0; + memset(computeruse_action, 0, sizeof(computeruse_action)); + computeruse_x = 0; + computeruse_y = 0; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 computeruse 命令(带结构化 JSON 参数) */ + mock_send_cmd(ms, "homeagent", "req-cu", + "computeruse {\"action\":\"click\",\"x\":100,\"y\":200}"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(computeruse_fired == 1, "computeruse handler not called"); + TEST_ASSERT(strcmp(computeruse_action, "click") == 0, + "action should be 'click'"); + TEST_ASSERT(computeruse_x == 100, "x should be 100"); + TEST_ASSERT(computeruse_y == 200, "y should be 200"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 14. clipboardsee/clipboardsue 命令分发 ---------- */ +static volatile int clipboardsee_fired = 0; +static volatile int clipboardsue_fired = 0; +static char clipboardsue_text[256] = ""; + +static ha_status_t test_handler_clipboardsee(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)args; (void)userdata; + clipboardsee_fired = 1; + result->status = 0; + result->output = "clipboard content here"; + return HA_OK; +} + +static ha_status_t test_handler_clipboardsue(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + clipboardsue_fired = 1; + if (args) strncpy(clipboardsue_text, args, sizeof(clipboardsue_text) - 1); + result->status = 0; + result->output = "clipboard set"; + return HA_OK; +} + +static void test_clipboard_dispatch(void) { + TEST_BEGIN("Clipboard: clipboardsee + clipboardsue dispatch"); + + mock_server_t *ms = mock_server_start(19899); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"clipboard", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "clipboardsee", .handler = test_handler_clipboardsee}, + {.command = "clipboardsue", .handler = test_handler_clipboardsue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19899", + .token = "test-token", + .device = { + .device_id = "clip-dev", + .name = "Clipboard Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + clipboardsee_fired = 0; + clipboardsue_fired = 0; + memset(clipboardsue_text, 0, sizeof(clipboardsue_text)); + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 clipboardsee 命令 */ + mock_send_cmd(ms, "homeagent", "req-cs", "clipboardsee"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(clipboardsee_fired == 1, "clipboardsee handler not called"); + + /* 发送 clipboardsue 命令 */ + mock_send_cmd(ms, "homeagent", "req-cw", "clipboardsue Hello World"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(clipboardsue_fired == 1, "clipboardsue handler not called"); + TEST_ASSERT(strcmp(clipboardsue_text, "Hello World") == 0, + "clipboardsue text mismatch"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 15. screensee:带 data URL 的结果回传 ---------- */ +static ha_status_t test_handler_screensee(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)args; (void)userdata; + result->status = 0; + /* 返回 data URL 格式的 base64 图像数据 */ + result->output = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + return HA_OK; +} + +static void test_screensee_data_url(void) { + TEST_BEGIN("Screensee: handler returns data URL result"); + + mock_server_t *ms = mock_server_start(19900); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"screen", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "screensee", .handler = test_handler_screensee}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19900", + .token = "test-token", + .device = { + .device_id = "see-dev", + .name = "Screensee Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 screensee 命令 */ + mock_send_cmd(ms, "homeagent", "req-see", "screensee"); + usleep(200000); + ha_client_process(client); + + /* handler 已被调用,且返回了 data URL(无法直接验证回执内容,但 handler 已执行) */ + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 16. info_json 设备信息 ---------- */ +static void test_info_json(void) { + TEST_BEGIN("Device info_json: included in hello message"); + + mock_server_t *ms = mock_server_start(19901); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + struct transport_ctx tctx; + tctx.sock = -1; + ha_transport_t transport = { + .connect = transport_connect, + .send = transport_send, + .recv = transport_recv, + .close = transport_close, + .ctx = &tctx, + }; + + const char *caps[] = {"camera", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19901", + .token = "test-token", + .device = { + .device_id = "info-dev", + .name = "Info Test", + .kind = "camera", + .caps = caps, + .info_json = "{\"chip\":\"ESP32-S3\",\"psram\":8}", + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 连接成功,info_json 已通过 hello 消息发送 */ + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ================================================================== + * 主函数 + * ================================================================== */ +int main(void) { +#if !defined(_WIN32) && !defined(_WIN64) + signal(SIGPIPE, SIG_IGN); +#endif + + printf("========================================\n"); + printf(" ha_remotedevice 全面测试\n"); + printf("========================================\n\n"); + + /* ---- JSON 解析/构建 ---- */ + printf("[JSON]\n"); + test_json_parser(); + test_json_int(); + test_json_builder(); + test_json_array_builder(); + + /* ---- 工具函数 ---- */ + printf("\n[Utilities]\n"); + test_parse_homeagent(); + test_parse_json(); + test_base64(); + + /* ---- WS 协议 ---- */ + printf("\n[WebSocket]\n"); + test_ws_frame(); + + /* ---- SDK 核心 ---- */ + printf("\n[SDK Core]\n"); + test_version(); + test_client_lifecycle(); + test_command_dispatch(); + + /* ---- 协议功能 ---- */ + printf("\n[Protocol]\n"); + test_event_report(); + test_status_report(); + test_binary_chunked(); + test_unsupported_command(); + test_speech_receive(); + test_binary_via_handler(); + + /* ---- 扩展命令 ---- */ + printf("\n[Extended Commands]\n"); + test_computeruse_dispatch(); + test_clipboard_dispatch(); + test_screensee_data_url(); + test_info_json(); + + /* ---- 汇总 ---- */ + printf("\n========================================\n"); + printf(" 结果: %d passed, %d failed, %d skipped\n", + tests_passed, tests_failed, tests_skipped); + printf("========================================\n"); + + return tests_failed > 0 ? 1 : 0; +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index f3cb0b1..4bc7fca 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -127,6 +127,11 @@ const ( EventReasoning EventType = "reasoning" EventStage EventType = "stage" EventSystem EventType = "system" + + // 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 + // 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。 + EventReasoningDelta EventType = "reasoning_delta" + EventContentDelta EventType = "content_delta" ) // Event represents a system event published by the kernel.