feat(iot): IoT 插件化(Provider/Manager/mock 8设备/能力约束/bus 事件);feat(capability/restrict): 工具级布尔标记(群聊/陌生人可用,默认关,策略引擎雏形)+ registry ToolPolicy
This commit is contained in:
1 parent
915aa8a100
commit
b3c61aa1ff
9 files changed
+1358
No files matched your search
@@ -34,6 +34,18 @@ const (
|
||||
KindIoT Kind = "iot"
|
||||
)
|
||||
|
||||
// ToolPolicy holds the editable Boolean markers that gate a tool/capability in
|
||||
// non-owner environments (DevDocs/design/11 §3). Both fields default to false,
|
||||
// so a capability is owner-only until a marker is explicitly enabled. Policies
|
||||
// are per-capability and editable at runtime (the admin panel, M10).
|
||||
type ToolPolicy struct {
|
||||
// AllowInGroup permits invocation from a group chat environment.
|
||||
AllowInGroup bool
|
||||
// AllowInStranger permits invocation from a stranger's private chat
|
||||
// environment.
|
||||
AllowInStranger bool
|
||||
}
|
||||
|
||||
// Capability is a globally shared, always-available capability registered in
|
||||
// the registry.
|
||||
type Capability struct {
|
||||
@@ -43,6 +55,9 @@ type Capability struct {
|
||||
Kind Kind
|
||||
// Description is a human-readable summary used for selection and display.
|
||||
Description string
|
||||
// Policy is the editable reachability markers for this capability. It
|
||||
// defaults to zero, which the policy engine reads as owner-only.
|
||||
Policy ToolPolicy
|
||||
// Value is the underlying capability object. For KindTool/KindSkill/
|
||||
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
|
||||
// KindIoT it is owned by the relevant subsystem.
|
||||
@@ -103,6 +118,18 @@ func (r *Registry) Get(name string) (Capability, error) {
|
||||
return cap, nil
|
||||
}
|
||||
|
||||
// Remove deletes the capability registered under name. It returns ErrNotFound
|
||||
// when no such capability is registered.
|
||||
func (r *Registry) Remove(name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.caps[name]; !ok {
|
||||
return fmt.Errorf("registry: remove %q: %w", name, ErrNotFound)
|
||||
}
|
||||
delete(r.caps, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns a copy of the capabilities whose Kind matches kind. An empty
|
||||
// kind returns every registered capability. The returned slice is owned by the
|
||||
// caller and safe to mutate.
|
||||
@@ -119,6 +146,60 @@ func (r *Registry) List(kind Kind) []Capability {
|
||||
return out
|
||||
}
|
||||
|
||||
// Policy returns the editable reachability markers for the capability named
|
||||
// name. It returns ErrNotFound when no such capability is registered.
|
||||
func (r *Registry) Policy(name string) (ToolPolicy, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return ToolPolicy{}, fmt.Errorf("registry: policy %q: %w", name, ErrNotFound)
|
||||
}
|
||||
return cap.Policy, nil
|
||||
}
|
||||
|
||||
// SetPolicy replaces the reachability markers for the capability named name. It
|
||||
// returns ErrNotFound when no such capability is registered.
|
||||
func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: set policy %q: %w", name, ErrNotFound)
|
||||
}
|
||||
cap.Policy = p
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAllowInGroup toggles the group-chat marker for the capability named name.
|
||||
// It returns ErrNotFound when no such capability is registered.
|
||||
func (r *Registry) SetAllowInGroup(name string, allow bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: set allow_in_group %q: %w", name, ErrNotFound)
|
||||
}
|
||||
cap.Policy.AllowInGroup = allow
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAllowInStranger toggles the stranger-chat marker for the capability named
|
||||
// name. It returns ErrNotFound when no such capability is registered.
|
||||
func (r *Registry) SetAllowInStranger(name string, allow bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: set allow_in_stranger %q: %w", name, ErrNotFound)
|
||||
}
|
||||
cap.Policy.AllowInStranger = allow
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterTool registers t as a KindTool capability.
|
||||
func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||||
if t == nil {
|
||||
|
||||
@@ -246,3 +246,70 @@ func TestRegistry_ConcurrentSafety(t *testing.T) {
|
||||
func kn(w, i int) string {
|
||||
return "cap" + strconv.Itoa(w) + "-" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func TestRegistry_ToolPolicyDefaultsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if err := r.RegisterTool(regTool{name: "calc"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
pol, err := r.Policy("calc")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("default policy = %+v, want both markers false", pol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_SetPolicyAndSetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if err := r.RegisterTool(regTool{name: "calc"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
|
||||
if err := r.SetPolicy("calc", ToolPolicy{AllowInGroup: true, AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy: %v", err)
|
||||
}
|
||||
pol, _ := r.Policy("calc")
|
||||
if !pol.AllowInGroup || !pol.AllowInStranger {
|
||||
t.Fatalf("after SetPolicy = %+v, want both true", pol)
|
||||
}
|
||||
|
||||
if err := r.SetAllowInGroup("calc", false); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
pol, _ = r.Policy("calc")
|
||||
if pol.AllowInGroup {
|
||||
t.Fatalf("SetAllowInGroup(false) did not clear group marker: %+v", pol)
|
||||
}
|
||||
if !pol.AllowInStranger {
|
||||
t.Fatalf("SetAllowInGroup unexpectedly cleared stranger marker: %+v", pol)
|
||||
}
|
||||
|
||||
if err := r.SetAllowInStranger("calc", false); err != nil {
|
||||
t.Fatalf("SetAllowInStranger: %v", err)
|
||||
}
|
||||
pol, _ = r.Policy("calc")
|
||||
if pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("after clearing both = %+v, want both false", pol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Policy_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if _, err := r.Policy("missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Policy(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetPolicy("missing", ToolPolicy{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetPolicy(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetAllowInGroup("missing", true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetAllowInGroup(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetAllowInStranger("missing", true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetAllowInStranger(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package restrict provides the tool-level security policy engine prototype
|
||||
// (DevDocs/design/11 §3). It decides whether a tool/capability may be invoked in
|
||||
// a given actor environment by reading the capability's editable Boolean markers
|
||||
// AllowInGroup / AllowInStranger, which default to false so a capability is
|
||||
// owner-only until explicitly allowed.
|
||||
//
|
||||
// M5 delivers the marker data structure (the ToolPolicy on
|
||||
// internal/capability/registry.Capability) and the decision function. The
|
||||
// authoritative actor tagging happens in the channel/session layer in M6 — here
|
||||
// an ActorEnv is supplied directly, and the engine reads the registry's markers
|
||||
// to allow or deny. The engine is fail-closed: an unknown tool, an unknown
|
||||
// environment, or a nil registry all deny.
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// ActorEnv is the environment in which a tool may be invoked, derived by the
|
||||
// channel/session layer from protocol-confirmed sender identity plus the owner's
|
||||
// whitelist — never from prompt inference.
|
||||
type ActorEnv string
|
||||
|
||||
const (
|
||||
// ActorOwner is the primary owner's private session.
|
||||
ActorOwner ActorEnv = "owner"
|
||||
// ActorGroupMember is a chat group where the owner is present.
|
||||
ActorGroupMember ActorEnv = "group_member"
|
||||
// ActorStranger is a direct message from an unrecognized sender.
|
||||
ActorStranger ActorEnv = "stranger"
|
||||
)
|
||||
|
||||
// Valid reports whether e is one of the known actor environments.
|
||||
func (e ActorEnv) Valid() bool {
|
||||
switch e {
|
||||
case ActorOwner, ActorGroupMember, ActorStranger:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Matcher is the subset of a capability registry the policy engine reads. It is
|
||||
// implemented by *registry.Registry.
|
||||
type Matcher interface {
|
||||
// Policy returns the editable reachability markers for the named capability.
|
||||
Policy(name string) (registry.ToolPolicy, error)
|
||||
}
|
||||
|
||||
// Compile-time assertion that the registry satisfies Matcher, expressed here so
|
||||
// a breaking registry change fails at the restrict build.
|
||||
var _ Matcher = (*registry.Registry)(nil)
|
||||
|
||||
// Policy is an actor-environment decision engine. It is immutable after it is
|
||||
// built, so it is safe for concurrent use.
|
||||
type Policy struct {
|
||||
reg Matcher
|
||||
env ActorEnv
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// SetContext builds a Policy scoped to env, reading tool/capability markers from
|
||||
// reg. The runtime (M6) binds an actor environment to an engine through this
|
||||
// entry point. A nil reg yields a fail-closed policy that denies every tool.
|
||||
func SetContext(reg Matcher, env ActorEnv) *Policy {
|
||||
return &Policy{
|
||||
reg: reg,
|
||||
env: env,
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger returns a copy of p that logs denials through l with the
|
||||
// capability/restrict source tag. It does not mutate p, so it is safe to call
|
||||
// alongside concurrent Allow calls on p.
|
||||
func (p *Policy) WithLogger(l *slog.Logger) *Policy {
|
||||
if p == nil || l == nil {
|
||||
return p
|
||||
}
|
||||
cp := *p
|
||||
cp.log = logger.WithSource(l, "capability/restrict")
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Env returns the actor environment the policy was built for.
|
||||
func (p *Policy) Env() ActorEnv {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.env
|
||||
}
|
||||
|
||||
// Allow reports whether the tool/capability named name may be invoked in p's
|
||||
// environment. It reads the capability's editable markers. The owner environment
|
||||
// always allows; a nil registry, an unknown tool, an unknown environment, or a
|
||||
// false marker all deny (fail closed).
|
||||
func (p *Policy) Allow(name string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p.env == ActorOwner {
|
||||
return true
|
||||
}
|
||||
if p.reg == nil {
|
||||
p.logDeny(name, "nil registry", "")
|
||||
return false
|
||||
}
|
||||
pol, err := p.reg.Policy(name)
|
||||
if err != nil {
|
||||
p.logDeny(name, "policy error", err.Error())
|
||||
return false
|
||||
}
|
||||
switch p.env {
|
||||
case ActorGroupMember:
|
||||
if !pol.AllowInGroup {
|
||||
p.logDeny(name, "denied in group", "allow_in_group=false")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case ActorStranger:
|
||||
if !pol.AllowInStranger {
|
||||
p.logDeny(name, "denied to stranger", "allow_in_stranger=false")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
p.logDeny(name, "unknown env", string(p.env))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Deny is the inverse of Allow.
|
||||
func (p *Policy) Deny(name string) bool { return !p.Allow(name) }
|
||||
|
||||
func (p *Policy) logDeny(name, reason, detail string) {
|
||||
if p == nil || p.log == nil {
|
||||
return
|
||||
}
|
||||
args := []any{"tool", name, "reason", reason}
|
||||
if detail != "" {
|
||||
args = append(args, "detail", detail)
|
||||
}
|
||||
p.log.Warn("tool invocation denied by policy", args...)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
)
|
||||
|
||||
// toolStub is a mock Tool used only to register a capability by name.
|
||||
type toolStub struct{ name string }
|
||||
|
||||
func (t toolStub) Name() string { return t.name }
|
||||
func (t toolStub) Description() string { return "desc " + t.name }
|
||||
func (t toolStub) Parameters() (map[string]any, error) { return map[string]any{}, nil }
|
||||
func (t toolStub) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
|
||||
|
||||
func mustRegTool(t *testing.T, r *registry.Registry, name string) {
|
||||
t.Helper()
|
||||
if err := r.RegisterTool(toolStub{name: name}); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetContext_OwnerAllowsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "calc")
|
||||
p := SetContext(r, ActorOwner)
|
||||
if !p.Allow("calc") {
|
||||
t.Fatal("owner should allow an owner-only tool")
|
||||
}
|
||||
if !p.Allow("missing") {
|
||||
t.Fatal("owner should allow a tool that is not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllow_GroupAndStrangerMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "groupOnly")
|
||||
mustRegTool(t, r, "strangerOnly")
|
||||
mustRegTool(t, r, "both")
|
||||
mustRegTool(t, r, "ownerOnly")
|
||||
if err := r.SetPolicy("groupOnly", registry.ToolPolicy{AllowInGroup: true}); err != nil {
|
||||
t.Fatalf("SetPolicy groupOnly: %v", err)
|
||||
}
|
||||
if err := r.SetPolicy("strangerOnly", registry.ToolPolicy{AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy strangerOnly: %v", err)
|
||||
}
|
||||
if err := r.SetPolicy("both", registry.ToolPolicy{AllowInGroup: true, AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy both: %v", err)
|
||||
}
|
||||
|
||||
group := SetContext(r, ActorGroupMember)
|
||||
stranger := SetContext(r, ActorStranger)
|
||||
|
||||
if !group.Allow("groupOnly") || !group.Allow("both") {
|
||||
t.Fatal("group member should be allowed groupOnly and both")
|
||||
}
|
||||
if group.Allow("strangerOnly") {
|
||||
t.Fatal("group member should be denied strangerOnly")
|
||||
}
|
||||
if group.Allow("ownerOnly") {
|
||||
t.Fatal("group member should be denied ownerOnly")
|
||||
}
|
||||
|
||||
if !stranger.Allow("strangerOnly") || !stranger.Allow("both") {
|
||||
t.Fatal("stranger should be allowed strangerOnly and both")
|
||||
}
|
||||
if stranger.Allow("groupOnly") {
|
||||
t.Fatal("stranger should be denied groupOnly")
|
||||
}
|
||||
if stranger.Allow("ownerOnly") {
|
||||
t.Fatal("stranger should be denied ownerOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllow_FailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A nil registry denies every tool in a non-owner environment.
|
||||
if p := SetContext(nil, ActorStranger); p.Allow("x") {
|
||||
t.Fatal("nil reg should deny in stranger env")
|
||||
}
|
||||
if p := SetContext(nil, ActorGroupMember); p.Allow("x") {
|
||||
t.Fatal("nil reg should deny in group env")
|
||||
}
|
||||
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "t")
|
||||
|
||||
// An unknown environment denies even a registered tool.
|
||||
if p := SetContext(r, ActorEnv("bogus")); p.Allow("t") {
|
||||
t.Fatal("unknown env should deny")
|
||||
}
|
||||
// An unregistered tool denies.
|
||||
if p := SetContext(r, ActorGroupMember); p.Allow("unknown") {
|
||||
t.Fatal("unknown tool should deny")
|
||||
}
|
||||
// A nil Policy receiver denies.
|
||||
var nilPolicy *Policy
|
||||
if nilPolicy.Allow("t") {
|
||||
t.Fatal("nil Policy receiver should deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeny_IsInverse(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "yes")
|
||||
if err := r.SetAllowInGroup("yes", true); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
mustRegTool(t, r, "no")
|
||||
|
||||
p := SetContext(r, ActorGroupMember)
|
||||
if p.Allow("yes") != !p.Deny("yes") {
|
||||
t.Fatal("Allow and Deny are not inverses for an allowed tool")
|
||||
}
|
||||
if !p.Deny("no") {
|
||||
t.Fatal("Deny(no) should be true because no is owner-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorEnv_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, env := range []ActorEnv{ActorOwner, ActorGroupMember, ActorStranger} {
|
||||
if !env.Valid() {
|
||||
t.Fatalf("%q should be valid", env)
|
||||
}
|
||||
}
|
||||
if ActorEnv("bogus").Valid() {
|
||||
t.Fatal("bogus env should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_EnvAndWithLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "t")
|
||||
|
||||
p := SetContext(r, ActorStranger)
|
||||
if p.Env() != ActorStranger {
|
||||
t.Fatalf("Env() = %q, want stranger", p.Env())
|
||||
}
|
||||
// WithLogger returns a copy and leaves the original unflagged, so the
|
||||
// original's discarding logger still admits no output.
|
||||
cp := p.WithLogger(nil)
|
||||
if cp == nil {
|
||||
t.Fatal("WithLogger(nil) returned nil")
|
||||
}
|
||||
if cp.Env() != ActorStranger {
|
||||
t.Fatalf("copy Env() = %q, want stranger", cp.Env())
|
||||
}
|
||||
// The copy still denies and reports the same decision.
|
||||
if cp.Allow("t") {
|
||||
t.Fatal("stranger t should be denied without allow_in_stranger")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user