Files
Cyrene_v2/internal/iot/mock.go
T

327 lines
8.8 KiB
Go

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
}