feat: add device hotplug event notifications (USB on Linux)

This commit is contained in:
easyzoom
2026-02-14 13:56:08 +08:00
parent 5872e0f55e
commit 378045510d
8 changed files with 470 additions and 0 deletions

View File

@@ -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()

View File

@@ -113,6 +113,10 @@
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false,
"monitor_usb": true
},
"gateway": {
"host": "0.0.0.0",
"port": 18790

View File

@@ -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,
},
}
}

View File

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

152
pkg/devices/service.go Normal file
View File

@@ -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]
}

5
pkg/devices/source.go Normal file
View File

@@ -0,0 +1,5 @@
package devices
import "github.com/sipeed/picoclaw/pkg/devices/events"
type EventSource = events.EventSource

View File

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

View File

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