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)) }