feat: add device hotplug event notifications (USB on Linux)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
},
|
||||
"devices": {
|
||||
"enabled": false,
|
||||
"monitor_usb": true
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
pkg/devices/events/events.go
Normal file
57
pkg/devices/events/events.go
Normal 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
152
pkg/devices/service.go
Normal 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
5
pkg/devices/source.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package devices
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
|
||||
type EventSource = events.EventSource
|
||||
198
pkg/devices/sources/usb_linux.go
Normal file
198
pkg/devices/sources/usb_linux.go
Normal 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
|
||||
}
|
||||
29
pkg/devices/sources/usb_stub.go
Normal file
29
pkg/devices/sources/usb_stub.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user