feat(iot): IoT 插件化(Provider/Manager/mock 8设备/能力约束/bus 事件);feat(capability/restrict): 工具级布尔标记(群聊/陌生人可用,默认关,策略引擎雏形)+ registry ToolPolicy

This commit is contained in:
Aska committed 2026-09-06 06:26:21 +08:00
1 parent 915aa8a100
commit b3c61aa1ff
9 files changed
+1358

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
// Package iot provides the IoT device source abstraction for Cyrene v2.
//
// It models smart-home devices (light, AC, curtain, sensor, lock) as typed
// devices managed by pluggable providers. A Provider is a device source; the
// Manager selects a provider from configuration, exposes List/Control/Status,
// and publishes device state transitions as events on the in-process bus so
// other subsystems (frontend, memory, push) observe changes without polling
// (DevDocs/02-development-constraints.md "IoT 设备状态 bus 事件驱动").
//
// Milestone M5 delivers the pluggable provider skeleton plus a mock provider
// with eight simulated devices and the control_rules that bound what each device
// type supports. Real provider integrations (MQTT, Home Assistant) plug in later
// through the same Provider interface.
package iot
import "errors"
// DeviceUpdateSubject is the bus subject on which device state transitions are
// published. Subscribers use this subject (or "iot.>") to observe device
// changes.
const DeviceUpdateSubject = "iot.device_update"
// Sentinel errors returned by the iot subsystem. Use errors.Is to test for them.
var (
// ErrDeviceNotFound is returned when a device ID is unknown to every
// registered provider.
ErrDeviceNotFound = errors.New("iot: device not found")
// ErrUnsupported is returned when an action is not supported for a device's
// type (the device type's capability constraint), or when an action
// parameter value is outside the type's rule set.
ErrUnsupported = errors.New("iot: unsupported action")
// ErrOffline is returned when a Control call targets an offline device.
ErrOffline = errors.New("iot: device offline")
// ErrInvalidParam is returned when an action parameter is missing or has the
// wrong type.
ErrInvalidParam = errors.New("iot: invalid parameter")
// ErrUnknownProvider is returned when configuration names an unknown
// provider.
ErrUnknownProvider = errors.New("iot: unknown provider")
// ErrProviderNotRegistered is returned when a device operation is attempted
// but no provider is registered (for example the "none" provider).
ErrProviderNotRegistered = errors.New("iot: no provider registered")
// ErrInvalidArgument is returned when an argument is nil or empty.
ErrInvalidArgument = errors.New("iot: invalid argument")
// ErrDuplicate is returned when a provider with the same name is registered
// twice.
ErrDuplicate = errors.New("iot: provider already registered")
)
// Device is a single smart-home device exposed by a Provider.
type Device struct {
// ID is the globally unique device identifier used to target Control and
// Status calls.
ID string `json:"id"`
// Name is the human-readable device label.
Name string `json:"name"`
// Type is the device kind, for example "light", "ac", "curtain", "sensor" or
// "lock". The type determines the control_rules a Provider enforces.
Type string `json:"type"`
// State holds the device's current key/value state; the keys depend on the
// device Type.
State map[string]any `json:"state"`
// Online reports whether the device is currently reachable.
Online bool `json:"online"`
}
// Clone returns a copy of d with an independent State map, so callers can mutate
// the returned device without affecting the provider's internal state. Values in
// State are copied by reference; the mock provider stores only primitives.
func (d Device) Clone() Device {
cp := d
if d.State != nil {
cp.State = make(map[string]any, len(d.State))
for k, v := range d.State {
cp.State[k] = v
}
}
return cp
}
// DeviceUpdate is the payload published to DeviceUpdateSubject when a device
// state transitions, typically as the result of a Control call.
type DeviceUpdate struct {
// Provider is the name of the provider that owns the device.
Provider string
// Device is the post-transition device snapshot.
Device Device
// Action is the control action that produced the transition.
Action string
// Params are the optional control parameters.
Params map[string]any
}
+297
View File
@@ -0,0 +1,297 @@
package iot
import (
"context"
"errors"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// fakeProvider is a stub Provider used to exercise Manager.Register and the
// device-operation routing without touching the concrete mockProvider.
type fakeProvider struct {
name string
device Device
ctrl func(*Device)
}
func (f fakeProvider) Name() string { return f.name }
func (f fakeProvider) List(context.Context) ([]Device, error) {
return []Device{f.device}, nil
}
func (f fakeProvider) Control(_ context.Context, id, _ string, _ map[string]any) (*Device, error) {
if id != f.device.ID {
return nil, ErrDeviceNotFound
}
if f.ctrl != nil {
f.ctrl(&f.device)
}
cp := f.device.Clone()
return &cp, nil
}
func (f fakeProvider) Status(_ context.Context, id string) (*Device, error) {
if id != f.device.ID {
return nil, ErrDeviceNotFound
}
cp := f.device.Clone()
return &cp, nil
}
// recvIoT blocks until a DeviceUpdate is delivered or the deadline passes.
func recvIoT(t *testing.T, ch <-chan DeviceUpdate, d time.Duration) DeviceUpdate {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(d):
t.Fatalf("timed out waiting for a DeviceUpdate after %v", d)
return DeviceUpdate{}
}
}
func TestNew_ProviderSelection(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
wantLen int
wantErr error
}{
{name: "mock default", provider: "", wantLen: 8},
{name: "mock explicit", provider: "mock", wantLen: 8},
{name: "none disabled", provider: "none", wantLen: 0},
{name: "unknown", provider: "bogus", wantErr: ErrUnknownProvider},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: tt.provider}, nil)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("New error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("New: %v", err)
}
devices, err := m.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(devices) != tt.wantLen {
t.Fatalf("List returned %d devices, want %d", len(devices), tt.wantLen)
}
})
}
}
func TestList_MockCoversAllTypes(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
devices, err := m.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(devices) != 8 {
t.Fatalf("List returned %d devices, want 8", len(devices))
}
seenID := map[string]bool{}
seenType := map[string]int{}
var anyOnline, anyOffline bool
for _, d := range devices {
if seenID[d.ID] {
t.Fatalf("duplicate device ID %q", d.ID)
}
seenID[d.ID] = true
seenType[d.Type]++
if d.Online {
anyOnline = true
} else {
anyOffline = true
}
}
for _, want := range []string{"light", "ac", "curtain", "sensor", "lock"} {
if seenType[want] == 0 {
t.Fatalf("List has no device of type %q (types: %v)", want, seenType)
}
}
if !anyOnline || !anyOffline {
t.Fatalf("List should contain both online and offline devices (online=%v offline=%v)", anyOnline, anyOffline)
}
}
func TestStatus_ExistingAndMissing(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
d, err := m.Status(context.Background(), "ac.bedroom")
if err != nil {
t.Fatalf("Status: %v", err)
}
if d.Online {
t.Fatalf("ac.bedroom should be offline, got online=%v", d.Online)
}
if _, err := m.Status(context.Background(), "nope"); !errors.Is(err, ErrDeviceNotFound) {
t.Fatalf("Status(missing) error = %v, want ErrDeviceNotFound", err)
}
}
func TestControl_Actions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
deviceID string
action string
params map[string]any
wantKey string
wantVal any
}{
{name: "light on", deviceID: "light.living", action: "on", wantKey: "on", wantVal: true},
{name: "light off", deviceID: "light.living", action: "off", wantKey: "on", wantVal: false},
{name: "light toggle", deviceID: "light.living", action: "toggle", wantKey: "on", wantVal: true},
{name: "light brightness", deviceID: "light.living", action: "set_brightness", params: map[string]any{"brightness": 80}, wantKey: "brightness", wantVal: 80},
{name: "ac temperature", deviceID: "ac.living", action: "set_temperature", params: map[string]any{"temperature": 24}, wantKey: "temperature", wantVal: 24},
{name: "ac mode", deviceID: "ac.living", action: "set_mode", params: map[string]any{"mode": "heat"}, wantKey: "mode", wantVal: "heat"},
{name: "curtain open", deviceID: "curtain.living", action: "open", wantKey: "position", wantVal: 100},
{name: "curtain close", deviceID: "curtain.living", action: "close", wantKey: "position", wantVal: 0},
{name: "lock lock", deviceID: "lock.front", action: "lock", wantKey: "locked", wantVal: true},
{name: "lock unlock", deviceID: "lock.front", action: "unlock", wantKey: "locked", wantVal: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
d, err := m.Control(context.Background(), tt.deviceID, tt.action, tt.params)
if err != nil {
t.Fatalf("Control: %v", err)
}
if got := d.State[tt.wantKey]; got != tt.wantVal {
t.Fatalf("state[%s] = %v, want %v", tt.wantKey, got, tt.wantVal)
}
})
}
}
func TestControl_CapabilityConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
deviceID string
action string
params map[string]any
wantErr error
}{
{name: "sensor read-only", deviceID: "sensor.env", action: "read", wantErr: ErrUnsupported},
{name: "sensor any action", deviceID: "sensor.env", action: "on", wantErr: ErrUnsupported},
{name: "unknown action", deviceID: "light.living", action: "explode", wantErr: ErrUnsupported},
{name: "missing device", deviceID: "nope", action: "on", wantErr: ErrDeviceNotFound},
{name: "offline device", deviceID: "ac.bedroom", action: "on", wantErr: ErrOffline},
{name: "non-numeric brightness", deviceID: "light.living", action: "set_brightness", params: map[string]any{"brightness": "x"}, wantErr: ErrInvalidParam},
{name: "missing brightness param", deviceID: "light.living", action: "set_brightness", wantErr: ErrInvalidParam},
{name: "ac invalid mode", deviceID: "ac.living", action: "set_mode", params: map[string]any{"mode": "freeze"}, wantErr: ErrUnsupported},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := m.Control(context.Background(), tt.deviceID, tt.action, tt.params); !errors.Is(err, tt.wantErr) {
t.Fatalf("Control error = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestControl_PublishesDeviceUpdate(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := make(chan DeviceUpdate, 4)
unsub, err := m.Bus().Subscribe(context.Background(), DeviceUpdateSubject, func(_ context.Context, e bus.Event) error {
updates <- e.Payload.(DeviceUpdate)
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
if _, err := m.Control(context.Background(), "light.living", "on", nil); err != nil {
t.Fatalf("Control: %v", err)
}
u := recvIoT(t, updates, 2*time.Second)
if u.Provider != "mock" {
t.Fatalf("event provider = %q, want mock", u.Provider)
}
if u.Action != "on" {
t.Fatalf("event action = %q, want on", u.Action)
}
if u.Device.ID != "light.living" {
t.Fatalf("event device ID = %q, want light.living", u.Device.ID)
}
if got := u.Device.State["on"]; got != true {
t.Fatalf("event state on = %v, want true", got)
}
}
func TestRegister_ProviderSource(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "none"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
dev := Device{ID: "fake.dev", Name: "fake", Type: "light", Online: true, State: map[string]any{"on": false}}
if err := m.Register(fakeProvider{name: "fake", device: dev}); err != nil {
t.Fatalf("Register: %v", err)
}
devices, err := m.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(devices) != 1 || devices[0].ID != "fake.dev" {
t.Fatalf("List = %+v, want one fake.dev device", devices)
}
if err := m.Register(fakeProvider{name: "fake"}); !errors.Is(err, ErrDuplicate) {
t.Fatalf("duplicate Register error = %v, want ErrDuplicate", err)
}
if err := m.Register(nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("nil Register error = %v, want ErrInvalidArgument", err)
}
}
func TestNone_Disabled(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "none"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
devices, err := m.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(devices) != 0 {
t.Fatalf("List returned %d devices, want 0", len(devices))
}
if _, err := m.Control(context.Background(), "x", "on", nil); !errors.Is(err, ErrProviderNotRegistered) {
t.Fatalf("Control error = %v, want ErrProviderNotRegistered", err)
}
}
+164
View File
@@ -0,0 +1,164 @@
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
}
// 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) (*Manager, error) {
m := &Manager{
bus: bus.New(),
log: logger.WithSource(discardLogger(log), "iot/manager"),
}
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) {
if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider,
Device: d,
Action: action,
Params: params,
}); 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))
}
+326
View File
@@ -0,0 +1,326 @@
package iot
import (
"context"
"fmt"
"log/slog"
"sort"
"strconv"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// mockProvider is the built-in deterministic Provider with eight simulated
// devices spread across every supported device type. It enforces the
// control_rules (capability constraints) that bound what each device type
// supports.
type mockProvider struct {
mu sync.RWMutex
log *slog.Logger
devices map[string]*Device
rules map[string]typeRules
}
// typeRules holds the control_rules for one device type: the set of supported
// actions and how each mutates the device state.
type typeRules struct {
supported map[string]applyFunc
}
// applyFunc mutates d.State according to the action. It returns an error when
// the parameters are invalid.
type applyFunc func(d *Device, params map[string]any) error
const mockProviderName = "mock"
// Name returns the provider identifier.
func (m *mockProvider) Name() string { return mockProviderName }
func newMockProvider(log *slog.Logger) (*mockProvider, error) {
m := &mockProvider{
log: logger.WithSource(discardLogger(log), "iot/mock"),
devices: make(map[string]*Device, len(mockDevices())),
rules: buildRules(),
}
for _, d := range mockDevices() {
m.devices[d.ID] = d
}
return m, nil
}
// List returns every device, sorted by ID for deterministic output.
func (m *mockProvider) List(ctx context.Context) ([]Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]Device, 0, len(m.devices))
for _, d := range m.devices {
out = append(out, d.Clone())
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// Status returns a clone of the named device.
func (m *mockProvider) Status(ctx context.Context, deviceID string) (*Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
d, ok := m.devices[deviceID]
if !ok {
return nil, fmt.Errorf("iot: status %q: %w", deviceID, ErrDeviceNotFound)
}
cp := d.Clone()
return &cp, nil
}
// Control applies action to the named device, enforcing the type's control_rules.
func (m *mockProvider) Control(ctx context.Context, deviceID, action string, params map[string]any) (*Device, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.devices[deviceID]
if !ok {
return nil, fmt.Errorf("iot: control %q: %w", deviceID, ErrDeviceNotFound)
}
if !d.Online {
return nil, fmt.Errorf("iot: control %q: %w", deviceID, ErrOffline)
}
tr, ok := m.rules[d.Type]
if !ok {
return nil, fmt.Errorf("iot: control %q: %w", deviceID, ErrUnsupported)
}
apply, ok := tr.supported[action]
if !ok {
return nil, fmt.Errorf("iot: control %q action %q: %w", deviceID, action, ErrUnsupported)
}
if err := apply(d, params); err != nil {
return nil, err
}
m.log.Debug("iot mock device controlled", "device", deviceID, "action", action)
cp := d.Clone()
return &cp, nil
}
// buildRules returns the control_rules for every supported device type.
func buildRules() map[string]typeRules {
return map[string]typeRules{
"light": lightRules(),
"ac": acRules(),
"curtain": curtainRules(),
"sensor": sensorRules(),
"lock": lockRules(),
}
}
// lightRules returns the control_rules for light devices.
func lightRules() typeRules {
return typeRules{supported: map[string]applyFunc{
"on": func(d *Device, _ map[string]any) error {
d.State["on"] = true
if brightness(d) == 0 {
d.State["brightness"] = 100
}
return nil
},
"off": func(d *Device, _ map[string]any) error {
d.State["on"] = false
return nil
},
"toggle": func(d *Device, _ map[string]any) error {
d.State["on"] = !boolOf(d.State["on"])
return nil
},
"set_brightness": func(d *Device, p map[string]any) error {
b, err := intParam(p, "brightness")
if err != nil {
return err
}
d.State["brightness"] = clamp(b, 0, 100)
d.State["on"] = b > 0
return nil
},
}}
}
// acRules returns the control_rules for AC devices.
func acRules() typeRules {
return typeRules{supported: map[string]applyFunc{
"on": func(d *Device, _ map[string]any) error {
d.State["on"] = true
return nil
},
"off": func(d *Device, _ map[string]any) error {
d.State["on"] = false
return nil
},
"set_temperature": func(d *Device, p map[string]any) error {
t, err := intParam(p, "temperature")
if err != nil {
return err
}
d.State["temperature"] = clamp(t, 16, 30)
return nil
},
"set_mode": func(d *Device, p map[string]any) error {
mode, err := strParam(p, "mode")
if err != nil {
return err
}
switch mode {
case "cool", "heat", "fan", "dry":
d.State["mode"] = mode
return nil
default:
return fmt.Errorf("iot: control mode %q: %w", mode, ErrUnsupported)
}
},
}}
}
// curtainRules returns the control_rules for curtain devices.
func curtainRules() typeRules {
return typeRules{supported: map[string]applyFunc{
"open": func(d *Device, _ map[string]any) error {
d.State["position"] = 100
return nil
},
"close": func(d *Device, _ map[string]any) error {
d.State["position"] = 0
return nil
},
"stop": func(d *Device, _ map[string]any) error { return nil },
"set_position": func(d *Device, p map[string]any) error {
pos, err := intParam(p, "position")
if err != nil {
return err
}
d.State["position"] = clamp(pos, 0, 100)
return nil
},
}}
}
// sensorRules returns the control_rules for sensor devices. Sensors are
// read-only: no action is supported, so any Control attempt returns
// ErrUnsupported.
func sensorRules() typeRules {
return typeRules{supported: map[string]applyFunc{}}
}
// lockRules returns the control_rules for lock devices.
func lockRules() typeRules {
return typeRules{supported: map[string]applyFunc{
"lock": func(d *Device, _ map[string]any) error {
d.State["locked"] = true
return nil
},
"unlock": func(d *Device, _ map[string]any) error {
d.State["locked"] = false
return nil
},
"toggle": func(d *Device, _ map[string]any) error {
d.State["locked"] = !boolOf(d.State["locked"])
return nil
},
}}
}
// mockDevices returns the eight seeded mock devices. It covers all five types;
// ac.bedroom is seeded offline to exercise the Online field and the offline
// control guard.
func mockDevices() []*Device {
return []*Device{
{ID: "ac.bedroom", Name: "卧室空调", Type: "ac", Online: false, State: map[string]any{"on": false, "temperature": 26, "mode": "cool"}},
{ID: "ac.living", Name: "客厅空调", Type: "ac", Online: true, State: map[string]any{"on": false, "temperature": 26, "mode": "cool"}},
{ID: "curtain.living", Name: "客厅窗帘", Type: "curtain", Online: true, State: map[string]any{"position": 0}},
{ID: "light.bedroom", Name: "卧室灯", Type: "light", Online: true, State: map[string]any{"on": false, "brightness": 0}},
{ID: "light.living", Name: "客厅灯", Type: "light", Online: true, State: map[string]any{"on": false, "brightness": 0}},
{ID: "lock.back", Name: "后门锁", Type: "lock", Online: true, State: map[string]any{"locked": true}},
{ID: "lock.front", Name: "前门锁", Type: "lock", Online: true, State: map[string]any{"locked": true}},
{ID: "sensor.env", Name: "环境传感器", Type: "sensor", Online: true, State: map[string]any{"temperature": 24.5, "humidity": 60.0}},
}
}
func brightness(d *Device) int { return intOf(d.State["brightness"]) }
func intOf(v any) int {
f, ok := toFloat64(v)
if !ok {
return 0
}
return int(f)
}
func boolOf(v any) bool {
b, _ := v.(bool)
return b
}
func clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}
// intParam reads an integer action parameter, accepting int/int64/float64/string
// shapes so JSON-decoded params work the same as literal ones.
func intParam(p map[string]any, key string) (int, error) {
v, ok := p[key]
if !ok {
return 0, fmt.Errorf("iot: missing %q parameter: %w", key, ErrInvalidParam)
}
f, ok := toFloat64(v)
if !ok {
return 0, fmt.Errorf("iot: %q must be a number: %w", key, ErrInvalidParam)
}
return int(f), nil
}
func strParam(p map[string]any, key string) (string, error) {
v, ok := p[key]
if !ok {
return "", fmt.Errorf("iot: missing %q parameter: %w", key, ErrInvalidParam)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("iot: %q must be a string: %w", key, ErrInvalidParam)
}
return s, nil
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case string:
f, err := strconv.ParseFloat(n, 64)
if err == nil {
return f, true
}
}
return 0, false
}
+24
View File
@@ -0,0 +1,24 @@
package iot
import "context"
// Provider is a pluggable source of IoT devices. Implementations own the device
// state and enforce the control_rules (capability constraints) for each device
// type, so a type's set of supported actions is its capability.
//
// Implementations must be safe for concurrent use, because the Manager may serve
// List/Control/Status from multiple goroutines.
type Provider interface {
// Name returns the stable provider identifier, used for logging and to
// disambiguate providers registered on one Manager.
Name() string
// List returns every device currently known to the provider.
List(ctx context.Context) ([]Device, error)
// Control applies action with params to the named device and returns the
// post-transition device. It returns ErrDeviceNotFound for an unknown device
// and ErrUnsupported for an action the device's type does not support.
Control(ctx context.Context, deviceID, action string, params map[string]any) (*Device, error)
// Status returns the current state of the named device. It returns
// ErrDeviceNotFound when the device is unknown.
Status(ctx context.Context, deviceID string) (*Device, error)
}