diff --git a/backend/internal/plugin/.gitkeep b/backend/internal/plugin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/internal/plugin/iot.go b/backend/internal/plugin/iot.go new file mode 100644 index 0000000..7acf86e --- /dev/null +++ b/backend/internal/plugin/iot.go @@ -0,0 +1,25 @@ +package plugin + +import "context" + +// IoTProvider is implemented by plugins that manage IoT devices. +// Only one IoTProvider can be active at a time. +type IoTProvider interface { + Plugin + + // ListDevices returns all managed devices. + ListDevices(ctx context.Context) ([]Device, error) + + // GetDevice returns a single device by ID. + GetDevice(ctx context.Context, id string) (*Device, error) + + // SetDevice sets a property on a device (e.g., brightness=80). + SetDevice(ctx context.Context, id, field string, value any) error + + // ToggleDevice toggles a device's power state. + ToggleDevice(ctx context.Context, id string) error + + // SubscribeChanges returns a channel that emits device state changes. + // The channel is closed when the context is cancelled. + SubscribeChanges(ctx context.Context) (<-chan DeviceEvent, error) +} diff --git a/backend/internal/plugin/plugin.go b/backend/internal/plugin/plugin.go new file mode 100644 index 0000000..0693443 --- /dev/null +++ b/backend/internal/plugin/plugin.go @@ -0,0 +1,109 @@ +// Package plugin defines the core interfaces and types for Cyrene's plugin system. +// Plugins extend Cyrene with IoT device control, LLM tools, and RAG capabilities. +// Each plugin type implements one or more of the Provider interfaces. +package plugin + +import "context" + +// Plugin is the base interface that all plugins must implement. +type Plugin interface { + // Name returns the unique plugin identifier (e.g., "iot-mock", "calculator"). + Name() string + + // Version returns the semantic version string (e.g., "1.0.0"). + Version() string + + // Description returns a human-readable description of the plugin. + Description() string + + // Init initializes the plugin. Called once at startup. + // Return an error to skip this plugin (other plugins are unaffected). + Init(ctx context.Context, cfg PluginConfig) error + + // Close cleans up plugin resources. Called on shutdown. + Close() error + + // Enabled returns whether the plugin is currently available. + // Used for conditional plugins (e.g., web-search disabled when SearXNG is down). + Enabled() bool +} + +// PluginConfig provides shared configuration to all plugins during Init. +type PluginConfig struct { + // DataDir is the base directory for plugin data storage. + DataDir string + + // NATSURL is the NATS connection address. + NATSURL string + + // DatabaseURL is the PostgreSQL connection string. + DatabaseURL string + + // LogLevel is the current log level string. + LogLevel string + + // Extra holds plugin-specific config from environment variables + // (keys prefixed with PLUGIN_{NAME}_). + Extra map[string]string +} + +// --- Device types shared across IoT plugins --- + +// DeviceType categorizes an IoT device. +type DeviceType string + +const ( + DeviceLight DeviceType = "light" + DeviceAC DeviceType = "ac" + DeviceCurtain DeviceType = "curtain" + DeviceSensor DeviceType = "sensor" + DeviceLock DeviceType = "lock" + DeviceSwitch DeviceType = "switch" + DeviceHumidifier DeviceType = "humidifier" + DeviceTV DeviceType = "tv" +) + +// Device represents an IoT device. +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + Type DeviceType `json:"type"` + State map[string]any `json:"state"` // Dynamic state fields + Online bool `json:"online"` + History []DeviceEvent `json:"history"` // Recent events +} + +// DeviceEvent represents a single state change on a device. +type DeviceEvent struct { + DeviceID string `json:"device_id"` + Field string `json:"field"` + OldValue any `json:"old_value"` + NewValue any `json:"new_value"` + Timestamp int64 `json:"timestamp"` // Unix timestamp +} + +// --- Tool types --- + +// ToolDefinition describes an LLM-callable tool. +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + // Parameters is a JSON Schema object describing the tool's input parameters. + Parameters map[string]any `json:"parameters"` +} + +// RAGDocument represents a document in the RAG knowledge base. +type RAGDocument struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Metadata map[string]string `json:"metadata"` + Source string `json:"source"` +} + +// RAGSearchResult represents a single search result from RAG. +type RAGSearchResult struct { + Document RAGDocument `json:"document"` + Score float64 `json:"score"` + Chunk string `json:"chunk"` // The relevant chunk from the document +} diff --git a/backend/internal/plugin/registry.go b/backend/internal/plugin/registry.go new file mode 100644 index 0000000..6340674 --- /dev/null +++ b/backend/internal/plugin/registry.go @@ -0,0 +1,192 @@ +package plugin + +import ( + "context" + "fmt" + "log" + "sync" +) + +// Registry manages all loaded plugins and provides type-safe access to providers. +type Registry struct { + mu sync.RWMutex + plugins map[string]Plugin + iotProviders []IoTProvider + toolProviders []ToolProvider + ragProviders []RAGProvider +} + +// NewRegistry creates a new empty plugin registry. +func NewRegistry() *Registry { + return &Registry{ + plugins: make(map[string]Plugin), + iotProviders: make([]IoTProvider, 0), + toolProviders: make([]ToolProvider, 0), + ragProviders: make([]RAGProvider, 0), + } +} + +// Register adds a plugin to the registry after calling Init. +// If Init fails or the plugin is disabled, it is not added. +func (r *Registry) Register(p Plugin, cfg PluginConfig) error { + ctx := context.Background() + + if err := p.Init(ctx, cfg); err != nil { + return fmt.Errorf("plugin %s init failed: %w", p.Name(), err) + } + + if !p.Enabled() { + log.Printf("[plugin] %s is disabled, skipping", p.Name()) + return nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Check for duplicate + if _, exists := r.plugins[p.Name()]; exists { + return fmt.Errorf("plugin %s already registered", p.Name()) + } + + r.plugins[p.Name()] = p + + // Auto-detect provider interfaces + if iot, ok := p.(IoTProvider); ok { + r.iotProviders = append(r.iotProviders, iot) + log.Printf("[plugin] registered IoT provider: %s v%s", p.Name(), p.Version()) + } + if tool, ok := p.(ToolProvider); ok { + r.toolProviders = append(r.toolProviders, tool) + log.Printf("[plugin] registered tool provider: %s v%s (%d tools)", p.Name(), p.Version(), len(tool.Tools())) + } + if rag, ok := p.(RAGProvider); ok { + r.ragProviders = append(r.ragProviders, rag) + log.Printf("[plugin] registered RAG provider: %s v%s", p.Name(), p.Version()) + } + + log.Printf("[plugin] %s v%s loaded successfully", p.Name(), p.Version()) + return nil +} + +// Get returns a plugin by name, or nil if not found. +func (r *Registry) Get(name string) Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + return r.plugins[name] +} + +// ListNames returns the names of all loaded plugins. +func (r *Registry) ListNames() []string { + r.mu.RLock() + defer r.mu.RUnlock() + names := make([]string, 0, len(r.plugins)) + for name := range r.plugins { + names = append(names, name) + } + return names +} + +// GetIoTProvider returns the first registered IoT provider, or nil. +// When multiple IoT providers are registered, the first one wins. +func (r *Registry) GetIoTProvider() IoTProvider { + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.iotProviders) > 0 { + return r.iotProviders[0] + } + return nil +} + +// GetAllToolProviders returns all registered tool providers. +func (r *Registry) GetAllToolProviders() []ToolProvider { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]ToolProvider, len(r.toolProviders)) + copy(result, r.toolProviders) + return result +} + +// GetAllToolDefinitions returns a flat list of all tool definitions from all tool providers. +func (r *Registry) GetAllToolDefinitions() []ToolDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + defs := make([]ToolDefinition, 0) + for _, tp := range r.toolProviders { + defs = append(defs, tp.Tools()...) + } + return defs +} + +// ExecuteTool finds a tool by name across all tool providers and executes it. +func (r *Registry) ExecuteTool(ctx context.Context, toolName string, params map[string]any) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, tp := range r.toolProviders { + for _, def := range tp.Tools() { + if def.Name == toolName { + return tp.Execute(ctx, toolName, params) + } + } + } + return "", fmt.Errorf("tool %s not found in any provider", toolName) +} + +// GetRAGProviders returns all registered RAG providers. +func (r *Registry) GetRAGProviders() []RAGProvider { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]RAGProvider, len(r.ragProviders)) + copy(result, r.ragProviders) + return result +} + +// Count returns the total number of loaded plugins. +func (r *Registry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.plugins) +} + +// Shutdown calls Close on all plugins in reverse registration order. +func (r *Registry) Shutdown() { + r.mu.Lock() + defer r.mu.Unlock() + + // Close base plugins that are not specialized providers + for _, p := range r.plugins { + // Check if it was already closed via a provider list + if _, ok := p.(IoTProvider); ok { + continue + } + if _, ok := p.(ToolProvider); ok { + continue + } + if _, ok := p.(RAGProvider); ok { + continue + } + if err := p.Close(); err != nil { + log.Printf("[plugin] error closing %s: %v", p.Name(), err) + } + } + + // Close IoT providers + for _, p := range r.iotProviders { + if err := p.Close(); err != nil { + log.Printf("[plugin] error closing %s: %v", p.Name(), err) + } + } + // Close tool providers + for _, p := range r.toolProviders { + if err := p.Close(); err != nil { + log.Printf("[plugin] error closing %s: %v", p.Name(), err) + } + } + // Close RAG providers + for _, p := range r.ragProviders { + if err := p.Close(); err != nil { + log.Printf("[plugin] error closing %s: %v", p.Name(), err) + } + } + log.Printf("[plugin] all plugins shut down") +} diff --git a/backend/internal/plugin/registry_test.go b/backend/internal/plugin/registry_test.go new file mode 100644 index 0000000..1e635f3 --- /dev/null +++ b/backend/internal/plugin/registry_test.go @@ -0,0 +1,301 @@ +package plugin + +import ( + "context" + "fmt" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" +) + +// --- Test helpers: mock plugins --- + +type mockBasePlugin struct { + name string + version string + desc string + enabled bool + initErr error + closeCalled bool +} + +func (m *mockBasePlugin) Name() string { return m.name } +func (m *mockBasePlugin) Version() string { return m.version } +func (m *mockBasePlugin) Description() string { return m.desc } +func (m *mockBasePlugin) Init(_ context.Context, _ PluginConfig) error { + return m.initErr +} +func (m *mockBasePlugin) Close() error { + m.closeCalled = true + return nil +} +func (m *mockBasePlugin) Enabled() bool { return m.enabled } + +type mockIoTPlugin struct { + mockBasePlugin + devices []Device + mu sync.Mutex + eventCh chan DeviceEvent +} + +func (m *mockIoTPlugin) ListDevices(_ context.Context) ([]Device, error) { + return m.devices, nil +} +func (m *mockIoTPlugin) GetDevice(_ context.Context, id string) (*Device, error) { + for _, d := range m.devices { + if d.ID == id { + return &d, nil + } + } + return nil, errors.NewNotFound("device " + id) +} +func (m *mockIoTPlugin) SetDevice(_ context.Context, id, field string, value any) error { + m.mu.Lock() + defer m.mu.Unlock() + for i, d := range m.devices { + if d.ID == id { + if m.devices[i].State == nil { + m.devices[i].State = make(map[string]any) + } + m.devices[i].State[field] = value + if m.eventCh != nil { + m.eventCh <- DeviceEvent{DeviceID: id, Field: field, NewValue: value} + } + return nil + } + } + return nil +} +func (m *mockIoTPlugin) ToggleDevice(_ context.Context, id string) error { + return m.SetDevice(context.Background(), id, "power", true) +} +func (m *mockIoTPlugin) SubscribeChanges(_ context.Context) (<-chan DeviceEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.eventCh = make(chan DeviceEvent, 10) + return m.eventCh, nil +} + +type mockToolPlugin struct { + mockBasePlugin + tools []ToolDefinition + execFunc func(ctx context.Context, toolName string, params map[string]any) (string, error) +} + +func (m *mockToolPlugin) Tools() []ToolDefinition { return m.tools } +func (m *mockToolPlugin) Execute(ctx context.Context, toolName string, params map[string]any) (string, error) { + if m.execFunc != nil { + return m.execFunc(ctx, toolName, params) + } + return "ok", nil +} + +// --- Tests --- + +func TestRegistryRegister(t *testing.T) { + reg := NewRegistry() + p := &mockBasePlugin{name: "test", version: "1.0", enabled: true} + + err := reg.Register(p, PluginConfig{}) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + if reg.Count() != 1 { + t.Errorf("expected 1 plugin, got %d", reg.Count()) + } + + got := reg.Get("test") + if got == nil { + t.Error("expected to find plugin 'test'") + } +} + +func TestRegistryDuplicateRegister(t *testing.T) { + reg := NewRegistry() + p1 := &mockBasePlugin{name: "test", version: "1.0", enabled: true} + p2 := &mockBasePlugin{name: "test", version: "2.0", enabled: true} + + _ = reg.Register(p1, PluginConfig{}) + err := reg.Register(p2, PluginConfig{}) + + if err == nil { + t.Error("expected error for duplicate plugin name") + } +} + +func TestRegistryInitError(t *testing.T) { + reg := NewRegistry() + p := &mockBasePlugin{name: "bad", version: "1.0", enabled: true, initErr: fmt.Errorf("init error")} + + err := reg.Register(p, PluginConfig{}) + if err == nil { + t.Error("expected error for init failure") + } + if reg.Count() != 0 { + t.Errorf("plugin should not be registered on init error, got %d", reg.Count()) + } +} + +func TestRegistryDisabledPlugin(t *testing.T) { + reg := NewRegistry() + p := &mockBasePlugin{name: "disabled", version: "1.0", enabled: false} + + err := reg.Register(p, PluginConfig{}) + if err != nil { + t.Fatalf("Register should not error for disabled plugin: %v", err) + } + if reg.Count() != 0 { + t.Errorf("disabled plugin should not be counted, got %d", reg.Count()) + } +} + +func TestRegistryListNames(t *testing.T) { + reg := NewRegistry() + p1 := &mockBasePlugin{name: "plugin-a", version: "1.0", enabled: true} + p2 := &mockBasePlugin{name: "plugin-b", version: "1.0", enabled: true} + + reg.Register(p1, PluginConfig{}) + reg.Register(p2, PluginConfig{}) + + names := reg.ListNames() + if len(names) != 2 { + t.Errorf("expected 2 names, got %d", len(names)) + } +} + +func TestRegistryIoTProvider(t *testing.T) { + reg := NewRegistry() + p := &mockIoTPlugin{ + mockBasePlugin: mockBasePlugin{name: "iot-mock", version: "1.0", enabled: true}, + devices: []Device{ + {ID: "light-1", Name: "客厅灯", Type: DeviceLight, State: map[string]any{"power": false}}, + }, + } + + err := reg.Register(p, PluginConfig{}) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + iot := reg.GetIoTProvider() + if iot == nil { + t.Fatal("expected IoT provider to be available") + } + + devices, err := iot.ListDevices(context.Background()) + if err != nil { + t.Fatalf("ListDevices failed: %v", err) + } + if len(devices) != 1 { + t.Errorf("expected 1 device, got %d", len(devices)) + } + if devices[0].Name != "客厅灯" { + t.Errorf("expected device name 客厅灯, got %s", devices[0].Name) + } +} + +func TestRegistrySetDevice(t *testing.T) { + reg := NewRegistry() + p := &mockIoTPlugin{ + mockBasePlugin: mockBasePlugin{name: "iot-mock", version: "1.0", enabled: true}, + devices: []Device{ + {ID: "light-1", Name: "客厅灯", Type: DeviceLight, State: map[string]any{"power": false}}, + }, + } + reg.Register(p, PluginConfig{}) + + iot := reg.GetIoTProvider() + err := iot.SetDevice(context.Background(), "light-1", "power", true) + if err != nil { + t.Fatalf("SetDevice failed: %v", err) + } + + d, err := iot.GetDevice(context.Background(), "light-1") + if err != nil { + t.Fatalf("GetDevice failed: %v", err) + } + if d.State["power"] != true { + t.Errorf("expected power=true, got %v", d.State["power"]) + } +} + +func TestRegistryToolProvider(t *testing.T) { + reg := NewRegistry() + p := &mockToolPlugin{ + mockBasePlugin: mockBasePlugin{name: "calculator", version: "1.0", enabled: true}, + tools: []ToolDefinition{ + {Name: "calc", Description: "Calculate math expressions"}, + }, + } + + err := reg.Register(p, PluginConfig{}) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + providers := reg.GetAllToolProviders() + if len(providers) != 1 { + t.Errorf("expected 1 tool provider, got %d", len(providers)) + } + + defs := reg.GetAllToolDefinitions() + if len(defs) != 1 { + t.Errorf("expected 1 tool definition, got %d", len(defs)) + } + if defs[0].Name != "calc" { + t.Errorf("expected tool name 'calc', got %s", defs[0].Name) + } +} + +func TestRegistryExecuteTool(t *testing.T) { + reg := NewRegistry() + p := &mockToolPlugin{ + mockBasePlugin: mockBasePlugin{name: "calc", version: "1.0", enabled: true}, + tools: []ToolDefinition{ + {Name: "add", Description: "Add two numbers"}, + }, + execFunc: func(_ context.Context, name string, params map[string]any) (string, error) { + return "42", nil + }, + } + reg.Register(p, PluginConfig{}) + + result, err := reg.ExecuteTool(context.Background(), "add", nil) + if err != nil { + t.Fatalf("ExecuteTool failed: %v", err) + } + if result != "42" { + t.Errorf("expected '42', got %s", result) + } +} + +func TestRegistryExecuteToolNotFound(t *testing.T) { + reg := NewRegistry() + + _, err := reg.ExecuteTool(context.Background(), "nonexistent", nil) + if err == nil { + t.Error("expected error for unknown tool") + } +} + +func TestRegistryShutdown(t *testing.T) { + reg := NewRegistry() + p1 := &mockBasePlugin{name: "p1", version: "1.0", enabled: true} + p2 := &mockToolPlugin{ + mockBasePlugin: mockBasePlugin{name: "p2", version: "1.0", enabled: true}, + } + + reg.Register(p1, PluginConfig{}) + reg.Register(p2, PluginConfig{}) + + reg.Shutdown() + + if !p1.closeCalled { + t.Error("p1.Close should have been called") + } + if !p2.closeCalled { + t.Error("p2.Close should have been called") + } +} diff --git a/backend/internal/plugin/tool.go b/backend/internal/plugin/tool.go new file mode 100644 index 0000000..bb6bc87 --- /dev/null +++ b/backend/internal/plugin/tool.go @@ -0,0 +1,27 @@ +package plugin + +import "context" + +// ToolProvider is implemented by plugins that provide LLM-callable tools. +// Multiple ToolProviders can be active simultaneously. +type ToolProvider interface { + Plugin + + // Tools returns the list of tool definitions this plugin provides. + Tools() []ToolDefinition + + // Execute invokes a tool by name with the given parameters. + // Returns the result string or an error. + Execute(ctx context.Context, toolName string, params map[string]any) (string, error) +} + +// RAGProvider is implemented by plugins that provide RAG (Retrieval-Augmented Generation). +type RAGProvider interface { + Plugin + + // Ingest adds documents to the knowledge base. + Ingest(ctx context.Context, docs []RAGDocument) error + + // Search performs a semantic search over the knowledge base. + Search(ctx context.Context, query string, topK int) ([]RAGSearchResult, error) +} diff --git a/backend/plugins/iot-mock/plugin.go b/backend/plugins/iot-mock/plugin.go new file mode 100644 index 0000000..f72ae95 --- /dev/null +++ b/backend/plugins/iot-mock/plugin.go @@ -0,0 +1,255 @@ +// Package iotmock implements a simulated IoT device provider for development and testing. +// It provides 8 virtual smart home devices with realistic behavior including +// sensor fluctuation and operation history tracking. +package iotmock + +import ( + "context" + "fmt" + "math/rand" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" +) + +// Plugin implements the IoTProvider interface with 8 simulated devices. +type Plugin struct { + devices map[string]*plugin.Device + subs []chan plugin.DeviceEvent + mu sync.RWMutex + stopCh chan struct{} + stopped bool +} + +// New creates a new iot-mock plugin with 8 pre-configured devices. +func New() *Plugin { + return &Plugin{ + devices: map[string]*plugin.Device{ + "light-livingroom": { + ID: "light-livingroom", Name: "客厅灯", Type: plugin.DeviceLight, + State: map[string]any{"power": false, "brightness": 80, "color": "warm_white"}, + Online: true, + }, + "light-bedroom": { + ID: "light-bedroom", Name: "卧室灯", Type: plugin.DeviceLight, + State: map[string]any{"power": false, "brightness": 60, "color": "warm_white"}, + Online: true, + }, + "ac-livingroom": { + ID: "ac-livingroom", Name: "客厅空调", Type: plugin.DeviceAC, + State: map[string]any{"power": false, "temperature": 26, "mode": "cool"}, + Online: true, + }, + "ac-bedroom": { + ID: "ac-bedroom", Name: "卧室空调", Type: plugin.DeviceAC, + State: map[string]any{"power": false, "temperature": 25, "mode": "cool"}, + Online: true, + }, + "curtain-livingroom": { + ID: "curtain-livingroom", Name: "客厅窗帘", Type: plugin.DeviceCurtain, + State: map[string]any{"power": false, "position": 0}, + Online: true, + }, + "sensor-temperature": { + ID: "sensor-temperature", Name: "温度传感器", Type: plugin.DeviceSensor, + State: map[string]any{"temperature": 25.0, "unit": "celsius"}, + Online: true, + }, + "sensor-humidity": { + ID: "sensor-humidity", Name: "湿度传感器", Type: plugin.DeviceSensor, + State: map[string]any{"humidity": 55.0, "unit": "percent"}, + Online: true, + }, + "lock-door": { + ID: "lock-door", Name: "智能门锁", Type: plugin.DeviceLock, + State: map[string]any{"locked": true, "battery": 85}, + Online: true, + }, + }, + subs: make([]chan plugin.DeviceEvent, 0), + stopCh: make(chan struct{}), + } +} + +// --- Plugin interface --- + +func (p *Plugin) Name() string { return "iot-mock" } +func (p *Plugin) Version() string { return "1.0.0" } +func (p *Plugin) Description() string { return "模拟8个智能家居设备,仅供开发测试使用" } +func (p *Plugin) Enabled() bool { return true } + +func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { + // Start sensor fluctuation goroutine + go p.fluctuateSensors() + return nil +} + +func (p *Plugin) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.stopped { + return nil + } + p.stopped = true + close(p.stopCh) + for _, ch := range p.subs { + close(ch) + } + p.subs = nil + return nil +} + +// --- IoTProvider interface --- + +func (p *Plugin) ListDevices(_ context.Context) ([]plugin.Device, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + result := make([]plugin.Device, 0, len(p.devices)) + for _, d := range p.devices { + // Return a copy to prevent external mutation + clone := *d + clone.State = make(map[string]any, len(d.State)) + for k, v := range d.State { + clone.State[k] = v + } + result = append(result, clone) + } + return result, nil +} + +func (p *Plugin) GetDevice(_ context.Context, id string) (*plugin.Device, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + d, ok := p.devices[id] + if !ok { + return nil, fmt.Errorf("device %s not found", id) + } + clone := *d + clone.State = make(map[string]any, len(d.State)) + for k, v := range d.State { + clone.State[k] = v + } + return &clone, nil +} + +func (p *Plugin) SetDevice(_ context.Context, id, field string, value any) error { + p.mu.Lock() + defer p.mu.Unlock() + + d, ok := p.devices[id] + if !ok { + return fmt.Errorf("device %s not found", id) + } + + oldValue := d.State[field] + d.State[field] = value + + event := plugin.DeviceEvent{ + DeviceID: id, + Field: field, + OldValue: oldValue, + NewValue: value, + Timestamp: time.Now().Unix(), + } + d.History = append(d.History, event) + // Keep only last 20 history events + if len(d.History) > 20 { + d.History = d.History[len(d.History)-20:] + } + + // Broadcast to subscribers + p.broadcast(event) + + return nil +} + +func (p *Plugin) ToggleDevice(_ context.Context, id string) error { + p.mu.RLock() + d, ok := p.devices[id] + p.mu.RUnlock() + if !ok { + return fmt.Errorf("device %s not found", id) + } + + currentPower, _ := d.State["power"].(bool) + return p.SetDevice(context.Background(), id, "power", !currentPower) +} + +func (p *Plugin) SubscribeChanges(_ context.Context) (<-chan plugin.DeviceEvent, error) { + p.mu.Lock() + defer p.mu.Unlock() + + ch := make(chan plugin.DeviceEvent, 32) + p.subs = append(p.subs, ch) + return ch, nil +} + +// --- Internal --- + +func (p *Plugin) broadcast(event plugin.DeviceEvent) { + for _, ch := range p.subs { + select { + case ch <- event: + default: + // Subscriber is too slow, drop event + } + } +} + +// fluctuateSensors simulates temperature and humidity changes every 30 seconds. +func (p *Plugin) fluctuateSensors() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + // Fluctuate temperature sensor: ±0.2°C + p.fluctuateDeviceField("sensor-temperature", "temperature", 0.2) + + // Fluctuate humidity sensor: ±1% + p.fluctuateDeviceField("sensor-humidity", "humidity", 1.0) + } + } +} + +func (p *Plugin) fluctuateDeviceField(deviceID, field string, maxDelta float64) { + p.mu.Lock() + d, ok := p.devices[deviceID] + if !ok { + p.mu.Unlock() + return + } + + current, ok := d.State[field].(float64) + if !ok { + p.mu.Unlock() + return + } + + delta := (rand.Float64()*2 - 1) * maxDelta + newVal := current + delta + // Round to 1 decimal place + newVal = float64(int(newVal*10)) / 10 + + oldVal := d.State[field] + d.State[field] = newVal + p.mu.Unlock() + + p.mu.RLock() + event := plugin.DeviceEvent{ + DeviceID: deviceID, + Field: field, + OldValue: oldVal, + NewValue: newVal, + Timestamp: time.Now().Unix(), + } + p.mu.RUnlock() + + p.broadcast(event) +} diff --git a/backend/plugins/iot-mock/plugin_test.go b/backend/plugins/iot-mock/plugin_test.go new file mode 100644 index 0000000..9c7f6ff --- /dev/null +++ b/backend/plugins/iot-mock/plugin_test.go @@ -0,0 +1,200 @@ +package iotmock + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" +) + +func TestNewPlugin(t *testing.T) { + p := New() + + if p.Name() != "iot-mock" { + t.Errorf("expected name iot-mock, got %s", p.Name()) + } + if p.Version() != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", p.Version()) + } + if !p.Enabled() { + t.Error("plugin should be enabled by default") + } +} + +func TestListDevices(t *testing.T) { + p := New() + + devices, err := p.ListDevices(context.Background()) + if err != nil { + t.Fatalf("ListDevices failed: %v", err) + } + if len(devices) != 8 { + t.Errorf("expected 8 devices, got %d", len(devices)) + } + + // Verify devices are clones (modifying returned device doesn't affect internal state) + devices[0].State["power"] = true + d, _ := p.GetDevice(context.Background(), "light-livingroom") + if d.State["power"] != false { + t.Error("returned devices should be clones, internal state modified") + } +} + +func TestGetDevice(t *testing.T) { + p := New() + + d, err := p.GetDevice(context.Background(), "light-livingroom") + if err != nil { + t.Fatalf("GetDevice failed: %v", err) + } + if d.Name != "客厅灯" { + t.Errorf("expected name 客厅灯, got %s", d.Name) + } + if d.Type != plugin.DeviceLight { + t.Errorf("expected type light, got %s", d.Type) + } + + // Unknown device + _, err = p.GetDevice(context.Background(), "nonexistent") + if err == nil { + t.Error("expected error for unknown device") + } +} + +func TestToggleDevice(t *testing.T) { + p := New() + + // Initially off + d, _ := p.GetDevice(context.Background(), "light-livingroom") + if d.State["power"] != false { + t.Error("expected light to be off initially") + } + + // Toggle on + err := p.ToggleDevice(context.Background(), "light-livingroom") + if err != nil { + t.Fatalf("ToggleDevice failed: %v", err) + } + + d, _ = p.GetDevice(context.Background(), "light-livingroom") + if d.State["power"] != true { + t.Error("expected light to be on after toggle") + } + + // Toggle off + p.ToggleDevice(context.Background(), "light-livingroom") + d, _ = p.GetDevice(context.Background(), "light-livingroom") + if d.State["power"] != false { + t.Error("expected light to be off after second toggle") + } +} + +func TestSetDevice(t *testing.T) { + p := New() + + err := p.SetDevice(context.Background(), "light-livingroom", "brightness", 50) + if err != nil { + t.Fatalf("SetDevice failed: %v", err) + } + + d, _ := p.GetDevice(context.Background(), "light-livingroom") + if d.State["brightness"] != 50 { + t.Errorf("expected brightness=50, got %v", d.State["brightness"]) + } + + // History should have an entry + if len(d.History) != 1 { + t.Errorf("expected 1 history entry, got %d", len(d.History)) + } +} + +func TestSetDeviceNotFound(t *testing.T) { + p := New() + err := p.SetDevice(context.Background(), "nonexistent", "power", true) + if err == nil { + t.Error("expected error for unknown device") + } +} + +func TestSubscribeChanges(t *testing.T) { + p := New() + + ch, err := p.SubscribeChanges(context.Background()) + if err != nil { + t.Fatalf("SubscribeChanges failed: %v", err) + } + + // Toggle should emit an event + go p.ToggleDevice(context.Background(), "light-livingroom") + + event := <-ch + if event.DeviceID != "light-livingroom" { + t.Errorf("expected event for light-livingroom, got %s", event.DeviceID) + } + if event.Field != "power" { + t.Errorf("expected event field power, got %s", event.Field) + } + if event.NewValue != true { + t.Errorf("expected new value true, got %v", event.NewValue) + } +} + +func TestInitAndClose(t *testing.T) { + p := New() + + err := p.Init(context.Background(), plugin.PluginConfig{}) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + err = p.Close() + if err != nil { + t.Fatalf("Close failed: %v", err) + } + + // Second close should be safe + err = p.Close() + if err != nil { + t.Errorf("second Close should not error: %v", err) + } +} + +func TestDeviceHistoryLimit(t *testing.T) { + p := New() + + // Generate 25 events (limit is 20) + for i := range 25 { + p.SetDevice(context.Background(), "light-livingroom", "brightness", i) + } + + d, _ := p.GetDevice(context.Background(), "light-livingroom") + if len(d.History) > 20 { + t.Errorf("history should be capped at 20, got %d", len(d.History)) + } +} + +func TestAllDevicesExist(t *testing.T) { + p := New() + expectedIDs := []string{ + "light-livingroom", "light-bedroom", + "ac-livingroom", "ac-bedroom", + "curtain-livingroom", + "sensor-temperature", "sensor-humidity", + "lock-door", + } + + for _, id := range expectedIDs { + _, err := p.GetDevice(context.Background(), id) + if err != nil { + t.Errorf("expected device %s to exist", id) + } + } +} + +func TestPluginInterface(t *testing.T) { + p := New() + // Verify it implements the IoTProvider interface + var _ plugin.IoTProvider = p + // Verify it implements the base Plugin interface + var _ plugin.Plugin = p +}