diff --git a/internal/capability/registry/registry.go b/internal/capability/registry/registry.go index 7ea2085..12d9f99 100644 --- a/internal/capability/registry/registry.go +++ b/internal/capability/registry/registry.go @@ -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 { diff --git a/internal/capability/registry/registry_test.go b/internal/capability/registry/registry_test.go index adb58d8..83b9ad6 100644 --- a/internal/capability/registry/registry_test.go +++ b/internal/capability/registry/registry_test.go @@ -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) + } +} diff --git a/internal/capability/restrict/restrict.go b/internal/capability/restrict/restrict.go new file mode 100644 index 0000000..af0be98 --- /dev/null +++ b/internal/capability/restrict/restrict.go @@ -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...) +} diff --git a/internal/capability/restrict/restrict_test.go b/internal/capability/restrict/restrict_test.go new file mode 100644 index 0000000..b423040 --- /dev/null +++ b/internal/capability/restrict/restrict_test.go @@ -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") + } +} diff --git a/internal/iot/device.go b/internal/iot/device.go new file mode 100644 index 0000000..23d37ed --- /dev/null +++ b/internal/iot/device.go @@ -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 +} diff --git a/internal/iot/iot_test.go b/internal/iot/iot_test.go new file mode 100644 index 0000000..0f96900 --- /dev/null +++ b/internal/iot/iot_test.go @@ -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) + } +} diff --git a/internal/iot/manager.go b/internal/iot/manager.go new file mode 100644 index 0000000..d634391 --- /dev/null +++ b/internal/iot/manager.go @@ -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)) +} diff --git a/internal/iot/mock.go b/internal/iot/mock.go new file mode 100644 index 0000000..f9aa004 --- /dev/null +++ b/internal/iot/mock.go @@ -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 +} diff --git a/internal/iot/provider.go b/internal/iot/provider.go new file mode 100644 index 0000000..1390351 --- /dev/null +++ b/internal/iot/provider.go @@ -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) +}