327 lines
8.8 KiB
Go
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
|
|
}
|