diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 21246cf..86463c6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -25,11 +25,13 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/migrate" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -751,6 +753,18 @@ func gatewayCmd() { } fmt.Println("✓ Heartbeat service started") + stateManager := state.NewManager(cfg.WorkspacePath()) + deviceService := devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + deviceService.SetBus(msgBus) + if err := deviceService.Start(ctx); err != nil { + fmt.Printf("Error starting device service: %v\n", err) + } else if cfg.Devices.Enabled { + fmt.Println("✓ Device event service started") + } + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } @@ -763,6 +777,7 @@ func gatewayCmd() { fmt.Println("\nShutting down...") cancel() + deviceService.Stop() heartbeatService.Stop() cronService.Stop() agentLoop.Stop() diff --git a/config/config.example.json b/config/config.example.json index ee3ac97..288e16c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -113,6 +113,10 @@ "enabled": true, "interval": 30 }, + "devices": { + "enabled": false, + "monitor_usb": true + }, "gateway": { "host": "0.0.0.0", "port": 18790 diff --git a/pkg/config/config.go b/pkg/config/config.go index 6af9438..ce350f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,6 +50,7 @@ type Config struct { Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` mu sync.RWMutex } @@ -150,6 +151,11 @@ type HeartbeatConfig struct { Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } +type DevicesConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` +} + type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI ProviderConfig `json:"openai"` @@ -299,6 +305,10 @@ func DefaultConfig() *Config { Enabled: true, Interval: 30, // default 30 minutes }, + Devices: DevicesConfig{ + Enabled: false, + MonitorUSB: true, + }, } } diff --git a/pkg/devices/events/events.go b/pkg/devices/events/events.go new file mode 100644 index 0000000..0122617 --- /dev/null +++ b/pkg/devices/events/events.go @@ -0,0 +1,57 @@ +package events + +import "context" + +type EventSource interface { + Kind() Kind + Start(ctx context.Context) (<-chan *DeviceEvent, error) + Stop() error +} + +type Action string + +const ( + ActionAdd Action = "add" + ActionRemove Action = "remove" + ActionChange Action = "change" +) + +type Kind string + +const ( + KindUSB Kind = "usb" + KindBluetooth Kind = "bluetooth" + KindPCI Kind = "pci" + KindGeneric Kind = "generic" +) + +type DeviceEvent struct { + Action Action + Kind Kind + DeviceID string // e.g. "1-2" for USB bus 1 dev 2 + Vendor string // Vendor name or ID + Product string // Product name or ID + Serial string // Serial number if available + Capabilities string // Human-readable capability description + Raw map[string]string // Raw properties for extensibility +} + +func (e *DeviceEvent) FormatMessage() string { + actionEmoji := "🔌" + actionText := "Connected" + if e.Action == ActionRemove { + actionEmoji = "🔌" + actionText = "Disconnected" + } + + msg := actionEmoji + " Device " + actionText + "\n\n" + msg += "Type: " + string(e.Kind) + "\n" + msg += "Device: " + e.Vendor + " " + e.Product + "\n" + if e.Capabilities != "" { + msg += "Capabilities: " + e.Capabilities + "\n" + } + if e.Serial != "" { + msg += "Serial: " + e.Serial + "\n" + } + return msg +} diff --git a/pkg/devices/service.go b/pkg/devices/service.go new file mode 100644 index 0000000..05a2547 --- /dev/null +++ b/pkg/devices/service.go @@ -0,0 +1,152 @@ +package devices + +import ( + "context" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/devices/sources" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/state" +) + +type Service struct { + bus *bus.MessageBus + state *state.Manager + sources []events.EventSource + enabled bool + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex +} + +type Config struct { + Enabled bool + MonitorUSB bool // When true, monitor USB hotplug (Linux only) + // Future: MonitorBluetooth, MonitorPCI, etc. +} + +func NewService(cfg Config, stateMgr *state.Manager) *Service { + s := &Service{ + state: stateMgr, + enabled: cfg.Enabled, + sources: make([]EventSource, 0), + } + + if cfg.Enabled && cfg.MonitorUSB { + s.sources = append(s.sources, sources.NewUSBMonitor()) + } + + return s +} + +func (s *Service) SetBus(msgBus *bus.MessageBus) { + s.mu.Lock() + defer s.mu.Unlock() + s.bus = msgBus +} + +func (s *Service) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.enabled || len(s.sources) == 0 { + logger.InfoC("devices", "Device event service disabled or no sources") + return nil + } + + s.ctx, s.cancel = context.WithCancel(ctx) + + for _, src := range s.sources { + eventCh, err := src.Start(s.ctx) + if err != nil { + logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{ + "kind": src.Kind(), + "error": err.Error(), + }) + continue + } + go s.handleEvents(src.Kind(), eventCh) + logger.InfoCF("devices", "Device source started", map[string]interface{}{ + "kind": src.Kind(), + }) + } + + logger.InfoC("devices", "Device event service started") + return nil +} + +func (s *Service) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + + for _, src := range s.sources { + src.Stop() + } + + logger.InfoC("devices", "Device event service stopped") +} + +func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) { + for ev := range eventCh { + if ev == nil { + continue + } + s.sendNotification(ev) + } +} + +func (s *Service) sendNotification(ev *events.DeviceEvent) { + s.mu.RLock() + msgBus := s.bus + s.mu.RUnlock() + + if msgBus == nil { + return + } + + lastChannel := s.state.GetLastChannel() + if lastChannel == "" { + logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{ + "event": ev.FormatMessage(), + }) + return + } + + platform, userID := parseLastChannel(lastChannel) + if platform == "" || userID == "" || constants.IsInternalChannel(platform) { + return + } + + msg := ev.FormatMessage() + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: platform, + ChatID: userID, + Content: msg, + }) + + logger.InfoCF("devices", "Device notification sent", map[string]interface{}{ + "kind": ev.Kind, + "action": ev.Action, + "to": platform, + }) +} + +func parseLastChannel(lastChannel string) (platform, userID string) { + if lastChannel == "" { + return "", "" + } + parts := strings.SplitN(lastChannel, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] +} diff --git a/pkg/devices/source.go b/pkg/devices/source.go new file mode 100644 index 0000000..cbf0a7d --- /dev/null +++ b/pkg/devices/source.go @@ -0,0 +1,5 @@ +package devices + +import "github.com/sipeed/picoclaw/pkg/devices/events" + +type EventSource = events.EventSource diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go new file mode 100644 index 0000000..1f6c068 --- /dev/null +++ b/pkg/devices/sources/usb_linux.go @@ -0,0 +1,198 @@ +//go:build linux + +package sources + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +var usbClassToCapability = map[string]string{ + "00": "Interface Definition (by interface)", + "01": "Audio", + "02": "CDC Communication (Network Card/Modem)", + "03": "HID (Keyboard/Mouse/Gamepad)", + "05": "Physical Interface", + "06": "Image (Scanner/Camera)", + "07": "Printer", + "08": "Mass Storage (USB Flash Drive/Hard Disk)", + "09": "USB Hub", + "0a": "CDC Data", + "0b": "Smart Card", + "0e": "Video (Camera)", + "dc": "Diagnostic Device", + "e0": "Wireless Controller (Bluetooth)", + "ef": "Miscellaneous", + "fe": "Application Specific", + "ff": "Vendor Specific", +} + +type USBMonitor struct { + cmd *exec.Cmd + cancel context.CancelFunc + mu sync.Mutex +} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem) + // Followed by KEY=value lines, empty line separates events + // Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev) + cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("udevadm stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err) + } + + m.cmd = cmd + eventCh := make(chan *events.DeviceEvent, 16) + + go func() { + defer close(eventCh) + scanner := bufio.NewScanner(stdout) + var props map[string]string + var action string + isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + // End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications) + if isUdev && props != nil && (action == "add" || action == "remove") { + if ev := parseUSBEvent(action, props); ev != nil { + select { + case eventCh <- ev: + case <-ctx.Done(): + return + } + } + } + props = nil + action = "" + isUdev = false + continue + } + + idx := strings.Index(line, "=") + // First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value + if idx <= 0 { + isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV") + continue + } + + // Parse KEY=value + key := line[:idx] + val := line[idx+1:] + if props == nil { + props = make(map[string]string) + } + props[key] = val + + if key == "ACTION" { + action = val + } + } + + if err := scanner.Err(); err != nil { + logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()}) + } + cmd.Wait() + }() + + return eventCh, nil +} + +func (m *USBMonitor) Stop() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil && m.cmd.Process != nil { + m.cmd.Process.Kill() + m.cmd = nil + } + return nil +} + +func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent { + // Only care about add/remove for physical devices (not interfaces) + subsystem := props["SUBSYSTEM"] + if subsystem != "usb" { + return nil + } + // Skip interface events - we want device-level only to avoid duplicates + devType := props["DEVTYPE"] + if devType == "usb_interface" { + return nil + } + // Prefer usb_device, but accept if DEVTYPE not set (varies by udev version) + if devType != "" && devType != "usb_device" { + return nil + } + + ev := &events.DeviceEvent{ + Raw: props, + } + switch action { + case "add": + ev.Action = events.ActionAdd + case "remove": + ev.Action = events.ActionRemove + default: + return nil + } + ev.Kind = events.KindUSB + + ev.Vendor = props["ID_VENDOR"] + if ev.Vendor == "" { + ev.Vendor = props["ID_VENDOR_ID"] + } + if ev.Vendor == "" { + ev.Vendor = "Unknown Vendor" + } + + ev.Product = props["ID_MODEL"] + if ev.Product == "" { + ev.Product = props["ID_MODEL_ID"] + } + if ev.Product == "" { + ev.Product = "Unknown Device" + } + + ev.Serial = props["ID_SERIAL_SHORT"] + ev.DeviceID = props["DEVPATH"] + if bus := props["BUSNUM"]; bus != "" { + if dev := props["DEVNUM"]; dev != "" { + ev.DeviceID = bus + ":" + dev + } + } + + // Map USB class to capability + if class := props["ID_USB_CLASS"]; class != "" { + ev.Capabilities = usbClassToCapability[strings.ToLower(class)] + } + if ev.Capabilities == "" { + ev.Capabilities = "USB Device" + } + + return ev +} diff --git a/pkg/devices/sources/usb_stub.go b/pkg/devices/sources/usb_stub.go new file mode 100644 index 0000000..f08c2d4 --- /dev/null +++ b/pkg/devices/sources/usb_stub.go @@ -0,0 +1,29 @@ +//go:build !linux + +package sources + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/devices/events" +) + +type USBMonitor struct{} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + ch := make(chan *events.DeviceEvent) + close(ch) // Immediately close, no events + return ch, nil +} + +func (m *USBMonitor) Stop() error { + return nil +}