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:
Aska committed 2026-08-09 20:38:37 +08:00
1 parent 7943e290ec
commit 750d594055
8 files changed
+1109

No files matched your search

+255
View File
@@ -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)
}
+200
View File
@@ -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
}