Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand All @@ -763,6 +777,7 @@ func gatewayCmd() {

fmt.Println("\nShutting down...")
cancel()
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
Expand Down
4 changes: 4 additions & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false,
"monitor_usb": true
},
"gateway": {
"host": "0.0.0.0",
"port": 18790
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -299,6 +305,10 @@ func DefaultConfig() *Config {
Enabled: true,
Interval: 30, // default 30 minutes
},
Devices: DevicesConfig{
Enabled: false,
MonitorUSB: true,
},
}
}

Expand Down
57 changes: 57 additions & 0 deletions pkg/devices/events/events.go
Original file line number Diff line number Diff line change
@@ -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 changes: 152 additions & 0 deletions pkg/devices/service.go
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions pkg/devices/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package devices

import "github.com/sipeed/picoclaw/pkg/devices/events"

type EventSource = events.EventSource
Loading