Files
Cyrene_v2/internal/iot/manager.go
T
Aska 9b207a31e8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 地基③ 打通实时信号 producer→前端WS + loop 真流式 + 上行路由补全
打通前端 ws.ts 里那批死消费者事件(thinking/mood/tool_used/subagent_event/
task_output/device_update/reminder/backthink),并给 loop 接真 token 流式。

- wire.go:loop 建时传 OnStream(streamChunk 闭包把 delta 发 bus ws.stream_chunk);
  gateway.Deps 注入 Scheduler/Tasks/Streaming;iot 接共享总线 iot.WithBus(a.bus)。
- ws_bridge.go:hub 额外订阅 iot.>/task.>/automation.>/reminder.>/cyrene.agent.*.event,
  normalizeEvent 归一为 ws.*(device_update/task_output/task_done/automation_result/
  reminder/subagent_event);不订阅 cyrene.agent.> 以免 ask/reply 内部事件进 relay。
- ws_route.go:voice_input→ws.voice_input;subagent_ask→Scheduler.AuditContext(真实
  上下文审计,替换 pending stub);task_output_query→Manager.TaskOutput(真实输出,
  替换空 {})。
- server.go:新增 SubagentAuditor/TaskOutputStore 接口 + Deps.Scheduler/Tasks/Streaming。
- orchestration/auditcontext.go:Scheduler.AuditContext(full/tail/keyword)。
- task/manager.go:Manager.TaskOutput(id)。
- backthink:record() 补发 ws.backthink 事件。
- json tag 修正(device_update/reminder/task output 结构体加小写 tag + DeviceUpdate
  补 message),使前端分支能真正渲染。

实机验证:WS 握手 101、task manager ready、iot manager initialized(WithBus 生效)。
go build/vet/test 全绿;TestHubBridgeDeviceUpdate/TestHubBridgeSubagentProgress 覆盖
映射。dev 推至后续版本。
2026-09-06 15:53:48 +08:00

181 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package iot
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Manager is the IoT subsystem facade. It selects a Provider from
// configuration, serves List/Control/Status, and publishes device state
// transitions on the in-process bus as DeviceUpdate events.
type Manager struct {
providers []Provider
bus bus.Bus
log *slog.Logger
}
// Option customizes a Manager built by New.
type Option func(*Manager)
// WithBus injects an existing bus instead of letting the Manager create one. It
// mirrors the other subsystems so iot.device_update flows on the shared bus and
// reaches the gateway hub / background-trigger subscribers rather than staying
// on an isolated manager-owned bus.
func WithBus(b bus.Bus) Option {
return func(m *Manager) { m.bus = b }
}
// New builds a Manager from cfg, selecting the device Provider by cfg.Provider.
//
// Supported providers: "" or "mock" registers the built-in mock provider with
// eight simulated devices; "none" disables the subsystem (no provider is
// registered, List returns an empty slice and Control/Status fail with
// ErrDeviceNotFound). Any other value returns ErrUnknownProvider.
//
// log may be nil; it is replaced with a discard logger. The Manager owns a
// fresh, isolated bus exposed via Bus for subscribers to attach to.
func New(cfg config.IoTConfig, log *slog.Logger, opts ...Option) (*Manager, error) {
m := &Manager{
bus: bus.New(),
log: logger.WithSource(discardLogger(log), "iot/manager"),
}
for _, opt := range opts {
opt(m)
}
switch strings.TrimSpace(cfg.Provider) {
case "", "mock":
mp, err := newMockProvider(log)
if err != nil {
return nil, err
}
m.providers = append(m.providers, mp)
case "none":
// No provider is registered: the subsystem is disabled.
default:
return nil, fmt.Errorf("iot: new: provider %q: %w", cfg.Provider, ErrUnknownProvider)
}
m.log.Info("iot manager initialized", "provider", cfg.Provider, "providers", len(m.providers))
return m, nil
}
// Register adds p as a device source. Registering a provider whose Name already
// exists returns ErrDuplicate; a nil provider returns ErrInvalidArgument.
func (m *Manager) Register(p Provider) error {
if p == nil {
return fmt.Errorf("iot: register: %w", ErrInvalidArgument)
}
for _, existing := range m.providers {
if existing.Name() == p.Name() {
return fmt.Errorf("iot: register %q: %w", p.Name(), ErrDuplicate)
}
}
m.providers = append(m.providers, p)
m.log.Info("iot provider registered", "provider", p.Name())
return nil
}
// Bus returns the Manager's in-process event bus. Subscribers attach to
// DeviceUpdateSubject (or "iot.>") to observe device state transitions.
func (m *Manager) Bus() bus.Bus {
return m.bus
}
// List returns every device from every registered provider.
func (m *Manager) List(ctx context.Context) ([]Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var out []Device
for _, p := range m.providers {
devices, err := p.List(ctx)
if err != nil {
return nil, fmt.Errorf("iot: list %q: %w", p.Name(), err)
}
out = append(out, devices...)
}
m.log.Debug("iot devices listed", "count", len(out))
return out, nil
}
// Control applies action to the owning provider's device and, on success,
// publishes a DeviceUpdate on the bus. It returns ErrDeviceNotFound when no
// provider owns the device.
func (m *Manager) Control(ctx context.Context, deviceID, action string, params map[string]any) (*Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if len(m.providers) == 0 {
return nil, fmt.Errorf("iot: control: %w", ErrProviderNotRegistered)
}
for _, p := range m.providers {
d, err := p.Control(ctx, deviceID, action, params)
if err != nil {
if errors.Is(err, ErrDeviceNotFound) {
continue
}
return nil, err
}
m.publishUpdate(ctx, p.Name(), *d, action, params)
return d, nil
}
return nil, fmt.Errorf("iot: control %q: %w", deviceID, ErrDeviceNotFound)
}
// Status returns the current state of the device owned by the matching provider.
// It returns ErrDeviceNotFound when no provider owns the device.
func (m *Manager) Status(ctx context.Context, deviceID string) (*Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if len(m.providers) == 0 {
return nil, fmt.Errorf("iot: status: %w", ErrProviderNotRegistered)
}
for _, p := range m.providers {
d, err := p.Status(ctx, deviceID)
if err != nil {
if errors.Is(err, ErrDeviceNotFound) {
continue
}
return nil, err
}
return d, nil
}
return nil, fmt.Errorf("iot: status %q: %w", deviceID, ErrDeviceNotFound)
}
// publishUpdate fans a DeviceUpdate out on the bus. A publish failure (for
// example a canceled context) is logged, not returned: the device state already
// changed, and control should not be reported as failed because an observer did
// not receive the event.
func (m *Manager) publishUpdate(ctx context.Context, provider string, d Device, action string, params map[string]any) {
msg := fmt.Sprintf("设备 %s · %s(%s)", d.Name, action, provider)
if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider,
Device: d,
Action: action,
Params: params,
Message: msg,
}); err != nil {
m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err)
return
}
m.log.Debug("iot device update published", "device", d.ID, "action", action, "provider", provider)
}
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere,
// so New always has a safe logger to use.
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}