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
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ require (
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
Expand All @@ -66,11 +68,16 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tinygo-org/cbgo v0.0.4 // indirect
github.com/tinygo-org/pio v0.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
Expand All @@ -88,4 +95,5 @@ require (
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
tinygo.org/x/bluetooth v0.14.0 // indirect
)
19 changes: 19 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -179,9 +183,12 @@ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtIS
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik=
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA=
github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
Expand All @@ -190,6 +197,10 @@ github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGB
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af h1:ZfFq94aH/BCSWWKd9RPUgdHOdgGKCnfl2VdvU9UksTA=
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af/go.mod h1:MUaGO5m6X7xrkHrPDmnaxCEcuCCFN/0ZFh9oie+exbU=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
Expand All @@ -210,6 +221,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk=
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
Expand Down Expand Up @@ -245,6 +260,8 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
Expand All @@ -264,3 +281,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
tinygo.org/x/bluetooth v0.14.0 h1:rrUaT+Fu6O0phGm4Y5UZULL8F7UahOq/JwGAPjJm+V4=
tinygo.org/x/bluetooth v0.14.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA=
210 changes: 210 additions & 0 deletions internal/channel/ble/ble.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Package ble provides a channel.Notifier that sends short status messages
// to a JCODE-* BLE IoT device using the Nordic UART Service (NUS).
//
// The notifier auto-discovers nearby JCODE-* devices on first use and
// reconnects on failure. All sends are best-effort and non-blocking.
package ble

import (
"encoding/json"
"strings"
"sync"
"time"

"tinygo.org/x/bluetooth"

"github.com/cnjack/jcode/internal/channel"
"github.com/cnjack/jcode/internal/config"
)

// Nordic UART Service UUIDs.
var (
nusServiceUUID = bluetooth.NewUUID([16]byte{
0x6E, 0x40, 0x00, 0x01, 0xB5, 0xA3, 0xF3, 0x93,
0xE0, 0xA9, 0xE5, 0x0E, 0x24, 0xDC, 0xCA, 0x9E,
})
nusRXCharUUID = bluetooth.NewUUID([16]byte{
0x6E, 0x40, 0x00, 0x02, 0xB5, 0xA3, 0xF3, 0x93,
0xE0, 0xA9, 0xE5, 0x0E, 0x24, 0xDC, 0xCA, 0x9E,
})
)

// command is the NDJSON protocol message sent to the device.
type command struct {
Cmd string `json:"cmd"`
Val string `json:"val,omitempty"`
}

// Notifier implements channel.Notifier for BLE IoT devices.
type Notifier struct {
mu sync.Mutex
adapter *bluetooth.Adapter
device bluetooth.Device
rxChar bluetooth.DeviceCharacteristic
ready bool
closed bool

// connectOnce ensures we only attempt one background connect at a time.
connecting bool
}

// New creates a BLE notifier. It does NOT block — device discovery happens
// lazily on the first Notify call (in a background goroutine).
func New() *Notifier {
return &Notifier{
adapter: bluetooth.DefaultAdapter,
}
}

func (n *Notifier) Name() string { return "ble" }

func (n *Notifier) Available() bool {
n.mu.Lock()
defer n.mu.Unlock()
return n.ready && !n.closed
}

// Notify sends a command to the BLE device based on the event type.
// If the device is not connected yet, a background connection is triggered.
func (n *Notifier) Notify(event channel.NotifyEvent) {
n.mu.Lock()
if n.closed {
n.mu.Unlock()
return
}

if !n.ready {
if !n.connecting {
n.connecting = true
go n.connect()
}
n.mu.Unlock()
return
}

rxChar := n.rxChar
n.mu.Unlock()

cmd, val := channel.FormatBLE(event)
n.send(rxChar, cmd, val)
}

func (n *Notifier) Close() {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return
}
n.closed = true
if n.ready {
n.ready = false
go n.device.Disconnect()
}
}

// connect performs BLE adapter enable, scan, connect, and characteristic discovery.
func (n *Notifier) connect() {
logger := config.Logger()

if err := n.adapter.Enable(); err != nil {
logger.Printf("[ble] failed to enable adapter: %v", err)
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}

var found bluetooth.ScanResult
foundCh := make(chan struct{})

logger.Printf("[ble] scanning for JCODE-* devices...")
err := n.adapter.Scan(func(adapter *bluetooth.Adapter, result bluetooth.ScanResult) {
name := result.LocalName()
if strings.HasPrefix(name, "JCODE-") {
logger.Printf("[ble] found device: %s (RSSI: %d)", name, result.RSSI)
found = result
adapter.StopScan()
close(foundCh)
}
})
if err != nil {
select {
case <-foundCh:
default:
logger.Printf("[ble] scan error: %v", err)
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}
}

// Wait for device with timeout
select {
case <-foundCh:
case <-time.After(10 * time.Second):
logger.Printf("[ble] no JCODE device found within timeout")
n.adapter.StopScan()
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}

logger.Printf("[ble] connecting to %s...", found.LocalName())
device, err := n.adapter.Connect(found.Address, bluetooth.ConnectionParams{})
if err != nil {
logger.Printf("[ble] connect failed: %v", err)
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}

services, err := device.DiscoverServices([]bluetooth.UUID{nusServiceUUID})
if err != nil || len(services) == 0 {
logger.Printf("[ble] NUS service not found")
device.Disconnect()
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}

chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{nusRXCharUUID})
if err != nil || len(chars) == 0 {
logger.Printf("[ble] RX characteristic not found")
device.Disconnect()
n.mu.Lock()
n.connecting = false
n.mu.Unlock()
return
}

n.mu.Lock()
n.device = device
n.rxChar = chars[0]
n.ready = true
n.connecting = false
n.mu.Unlock()

logger.Printf("[ble] connected and ready")
}

func (n *Notifier) send(rxChar bluetooth.DeviceCharacteristic, cmd, val string) {
msg := command{Cmd: cmd, Val: val}
data, err := json.Marshal(msg)
if err != nil {
config.Logger().Printf("[ble] marshal error: %v", err)
return
}
data = append(data, '\n')

if _, err := rxChar.WriteWithoutResponse(data); err != nil {
config.Logger().Printf("[ble] write error: %v", err)
// Mark as disconnected so next Notify triggers reconnect
n.mu.Lock()
n.ready = false
n.mu.Unlock()
}
}
34 changes: 34 additions & 0 deletions internal/channel/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ func (s State) String() string {
}
}

// EventType identifies the kind of lifecycle event sent to notifiers.
type EventType int

const (
EventIdle EventType = iota // agent is idle, waiting for user input
EventWorking // agent is actively processing
EventApproval // agent is blocked, waiting for tool approval
EventDone // agent finished a task
)

// NotifyEvent is the structured event passed to Notifier.Notify.
type NotifyEvent struct {
Type EventType
Tool string // tool name (for EventApproval)
Err error // non-nil on failure (for EventDone)
}

// Notifier is a lightweight one-way notification sender.
// Unlike Channel, it requires no login/configuration flow — it just sends
// short text messages to an external device or service.
// Implementations must be safe for concurrent use.
type Notifier interface {
// Name returns a human-readable identifier (e.g. "ble", "wechat").
Name() string
// Available reports whether the notifier is ready to send.
Available() bool
// Notify pushes a lifecycle event. Implementations format the event
// for their own display (short text for BLE, rich text for WeChat, etc.).
// Must be best-effort and never block the caller for long.
Notify(event NotifyEvent)
// Close releases resources. Safe to call multiple times.
Close()
}

// Channel is the interface that all messaging channel implementations must satisfy.
type Channel interface {
// ID returns the channel identifier (e.g. "wechat").
Expand Down
Loading