feat(plugin): add plugin interface system and iot-mock implementation
- internal/plugin: Plugin/IoTProvider/ToolProvider/RAGProvider interfaces - internal/plugin: Registry with type-safe auto-detection, 11 tests - plugins/iot-mock: 8 simulated smart home devices, 11 tests - 63 total tests, build + vet clean
This commit is contained in:
1 parent
7943e290ec
commit
750d594055
8 files changed
+1109
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user