mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-10 07:25:37 -05:00
switch hto monorepo structure
This commit is contained in:
341
backend/internal/server/bluez/agent.go
Normal file
341
backend/internal/server/bluez/agent.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
bluezService = "org.bluez"
|
||||
agentManagerPath = "/org/bluez"
|
||||
agentManagerIface = "org.bluez.AgentManager1"
|
||||
agent1Iface = "org.bluez.Agent1"
|
||||
device1Iface = "org.bluez.Device1"
|
||||
agentPath = "/com/danklinux/bluez/agent"
|
||||
agentCapability = "KeyboardDisplay"
|
||||
)
|
||||
|
||||
const introspectXML = `
|
||||
<node>
|
||||
<interface name="org.bluez.Agent1">
|
||||
<method name="Release"/>
|
||||
<method name="RequestPinCode">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="out" type="s" name="pincode"/>
|
||||
</method>
|
||||
<method name="RequestPasskey">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="out" type="u" name="passkey"/>
|
||||
</method>
|
||||
<method name="DisplayPinCode">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="in" type="s" name="pincode"/>
|
||||
</method>
|
||||
<method name="DisplayPasskey">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="in" type="u" name="passkey"/>
|
||||
<arg direction="in" type="q" name="entered"/>
|
||||
</method>
|
||||
<method name="RequestConfirmation">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="in" type="u" name="passkey"/>
|
||||
</method>
|
||||
<method name="RequestAuthorization">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
</method>
|
||||
<method name="AuthorizeService">
|
||||
<arg direction="in" type="o" name="device"/>
|
||||
<arg direction="in" type="s" name="uuid"/>
|
||||
</method>
|
||||
<method name="Cancel"/>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Introspectable">
|
||||
<method name="Introspect">
|
||||
<arg direction="out" type="s" name="data"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>`
|
||||
|
||||
type BluezAgent struct {
|
||||
conn *dbus.Conn
|
||||
broker PromptBroker
|
||||
}
|
||||
|
||||
func NewBluezAgent(broker PromptBroker) (*BluezAgent, error) {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("system bus connection failed: %w", err)
|
||||
}
|
||||
|
||||
agent := &BluezAgent{
|
||||
conn: conn,
|
||||
broker: broker,
|
||||
}
|
||||
|
||||
if err := conn.Export(agent, dbus.ObjectPath(agentPath), agent1Iface); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("agent export failed: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Export(agent, dbus.ObjectPath(agentPath), "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("introspection export failed: %w", err)
|
||||
}
|
||||
|
||||
mgr := conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
|
||||
if err := mgr.Call(agentManagerIface+".RegisterAgent", 0, dbus.ObjectPath(agentPath), agentCapability).Err; err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("agent registration failed: %w", err)
|
||||
}
|
||||
|
||||
if err := mgr.Call(agentManagerIface+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath)).Err; err != nil {
|
||||
log.Debugf("[BluezAgent] not default agent: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] registered at %s with capability %s", agentPath, agentCapability)
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) Close() {
|
||||
if a.conn == nil {
|
||||
return
|
||||
}
|
||||
mgr := a.conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
|
||||
mgr.Call(agentManagerIface+".UnregisterAgent", 0, dbus.ObjectPath(agentPath))
|
||||
a.conn.Close()
|
||||
}
|
||||
|
||||
func (a *BluezAgent) Release() *dbus.Error {
|
||||
log.Infof("[BluezAgent] Release called")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
|
||||
log.Infof("[BluezAgent] RequestPinCode: device=%s", device)
|
||||
|
||||
secrets, err := a.promptFor(device, "pin", []string{"pin"}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] RequestPinCode failed: %v", err)
|
||||
return "", a.errorFrom(err)
|
||||
}
|
||||
|
||||
pin := secrets["pin"]
|
||||
log.Infof("[BluezAgent] RequestPinCode returning PIN (len=%d)", len(pin))
|
||||
return pin, nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
|
||||
log.Infof("[BluezAgent] RequestPasskey: device=%s", device)
|
||||
|
||||
secrets, err := a.promptFor(device, "passkey", []string{"passkey"}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] RequestPasskey failed: %v", err)
|
||||
return 0, a.errorFrom(err)
|
||||
}
|
||||
|
||||
passkey, err := strconv.ParseUint(secrets["passkey"], 10, 32)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] invalid passkey format: %v", err)
|
||||
return 0, dbus.MakeFailedError(fmt.Errorf("invalid passkey: %w", err))
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] RequestPasskey returning: %d", passkey)
|
||||
return uint32(passkey), nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
|
||||
log.Infof("[BluezAgent] DisplayPinCode: device=%s, pin=%s", device, pincode)
|
||||
|
||||
_, err := a.promptFor(device, "display-pin", []string{}, &pincode)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] DisplayPinCode acknowledgment failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
|
||||
log.Infof("[BluezAgent] DisplayPasskey: device=%s, passkey=%06d, entered=%d", device, passkey, entered)
|
||||
|
||||
if entered == 0 {
|
||||
pk := passkey
|
||||
_, err := a.promptFor(device, "display-passkey", []string{}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] DisplayPasskey acknowledgment failed: %v", err)
|
||||
}
|
||||
_ = pk
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
|
||||
log.Infof("[BluezAgent] RequestConfirmation: device=%s, passkey=%06d", device, passkey)
|
||||
|
||||
secrets, err := a.promptFor(device, "confirm", []string{"decision"}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] RequestConfirmation failed: %v", err)
|
||||
return a.errorFrom(err)
|
||||
}
|
||||
|
||||
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
|
||||
log.Debugf("[BluezAgent] RequestConfirmation rejected by user")
|
||||
return dbus.NewError("org.bluez.Error.Rejected", nil)
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] RequestConfirmation accepted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
|
||||
log.Infof("[BluezAgent] RequestAuthorization: device=%s", device)
|
||||
|
||||
secrets, err := a.promptFor(device, "authorize", []string{"decision"}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] RequestAuthorization failed: %v", err)
|
||||
return a.errorFrom(err)
|
||||
}
|
||||
|
||||
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
|
||||
log.Debugf("[BluezAgent] RequestAuthorization rejected by user")
|
||||
return dbus.NewError("org.bluez.Error.Rejected", nil)
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] RequestAuthorization accepted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
|
||||
log.Infof("[BluezAgent] AuthorizeService: device=%s, uuid=%s", device, uuid)
|
||||
|
||||
secrets, err := a.promptFor(device, "authorize-service:"+uuid, []string{"decision"}, nil)
|
||||
if err != nil {
|
||||
log.Warnf("[BluezAgent] AuthorizeService failed: %v", err)
|
||||
return a.errorFrom(err)
|
||||
}
|
||||
|
||||
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
|
||||
log.Debugf("[BluezAgent] AuthorizeService rejected by user")
|
||||
return dbus.NewError("org.bluez.Error.Rejected", nil)
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] AuthorizeService accepted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) Cancel() *dbus.Error {
|
||||
log.Infof("[BluezAgent] Cancel called")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) Introspect() (string, *dbus.Error) {
|
||||
return introspectXML, nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) promptFor(device dbus.ObjectPath, requestType string, fields []string, displayValue *string) (map[string]string, error) {
|
||||
if a.broker == nil {
|
||||
return nil, fmt.Errorf("broker not initialized")
|
||||
}
|
||||
|
||||
deviceName, deviceAddr := a.getDeviceInfo(device)
|
||||
hints := []string{}
|
||||
if displayValue != nil {
|
||||
hints = append(hints, *displayValue)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var passkey *uint32
|
||||
if requestType == "confirm" || requestType == "display-passkey" {
|
||||
if displayValue != nil {
|
||||
if pk, err := strconv.ParseUint(*displayValue, 10, 32); err == nil {
|
||||
pk32 := uint32(pk)
|
||||
passkey = &pk32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token, err := a.broker.Ask(ctx, PromptRequest{
|
||||
DevicePath: string(device),
|
||||
DeviceName: deviceName,
|
||||
DeviceAddr: deviceAddr,
|
||||
RequestType: requestType,
|
||||
Fields: fields,
|
||||
Hints: hints,
|
||||
Passkey: passkey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prompt creation failed: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("[BluezAgent] waiting for user response (token=%s)", token)
|
||||
reply, err := a.broker.Wait(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
|
||||
return nil, err
|
||||
}
|
||||
if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
|
||||
return nil, errdefs.ErrSecretPromptCancelled
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !reply.Accept && len(fields) > 0 {
|
||||
return nil, errdefs.ErrSecretPromptCancelled
|
||||
}
|
||||
|
||||
return reply.Secrets, nil
|
||||
}
|
||||
|
||||
func (a *BluezAgent) getDeviceInfo(device dbus.ObjectPath) (string, string) {
|
||||
obj := a.conn.Object(bluezService, device)
|
||||
|
||||
var name, alias, addr string
|
||||
|
||||
nameVar, err := obj.GetProperty(device1Iface + ".Name")
|
||||
if err == nil {
|
||||
if n, ok := nameVar.Value().(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
aliasVar, err := obj.GetProperty(device1Iface + ".Alias")
|
||||
if err == nil {
|
||||
if a, ok := aliasVar.Value().(string); ok {
|
||||
alias = a
|
||||
}
|
||||
}
|
||||
|
||||
addrVar, err := obj.GetProperty(device1Iface + ".Address")
|
||||
if err == nil {
|
||||
if a, ok := addrVar.Value().(string); ok {
|
||||
addr = a
|
||||
}
|
||||
}
|
||||
|
||||
if alias != "" {
|
||||
return alias, addr
|
||||
}
|
||||
if name != "" {
|
||||
return name, addr
|
||||
}
|
||||
return addr, addr
|
||||
}
|
||||
|
||||
func (a *BluezAgent) errorFrom(err error) *dbus.Error {
|
||||
if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
|
||||
return dbus.NewError("org.bluez.Error.Canceled", nil)
|
||||
}
|
||||
if errors.Is(err, errdefs.ErrSecretPromptCancelled) {
|
||||
return dbus.NewError("org.bluez.Error.Canceled", nil)
|
||||
}
|
||||
return dbus.MakeFailedError(err)
|
||||
}
|
||||
21
backend/internal/server/bluez/broker.go
Normal file
21
backend/internal/server/bluez/broker.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
type PromptBroker interface {
|
||||
Ask(ctx context.Context, req PromptRequest) (token string, err error)
|
||||
Wait(ctx context.Context, token string) (PromptReply, error)
|
||||
Resolve(token string, reply PromptReply) error
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
220
backend/internal/server/bluez/broker_test.go
Normal file
220
backend/internal/server/bluez/broker_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubscriptionBrokerAskWait(t *testing.T) {
|
||||
promptReceived := false
|
||||
broker := NewSubscriptionBroker(func(p PairingPrompt) {
|
||||
promptReceived = true
|
||||
if p.Token == "" {
|
||||
t.Error("expected token to be non-empty")
|
||||
}
|
||||
if p.DeviceName != "TestDevice" {
|
||||
t.Errorf("expected DeviceName=TestDevice, got %s", p.DeviceName)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
req := PromptRequest{
|
||||
DevicePath: "/org/bluez/test",
|
||||
DeviceName: "TestDevice",
|
||||
DeviceAddr: "AA:BB:CC:DD:EE:FF",
|
||||
RequestType: "pin",
|
||||
Fields: []string{"pin"},
|
||||
}
|
||||
|
||||
token, err := broker.Ask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask failed: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
|
||||
if !promptReceived {
|
||||
t.Fatal("expected prompt broadcast to be called")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
broker.Resolve(token, PromptReply{
|
||||
Secrets: map[string]string{"pin": "1234"},
|
||||
Accept: true,
|
||||
})
|
||||
}()
|
||||
|
||||
reply, err := broker.Wait(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait failed: %v", err)
|
||||
}
|
||||
|
||||
if reply.Secrets["pin"] != "1234" {
|
||||
t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
|
||||
}
|
||||
|
||||
if !reply.Accept {
|
||||
t.Error("expected Accept=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionBrokerTimeout(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req := PromptRequest{
|
||||
DevicePath: "/org/bluez/test",
|
||||
DeviceName: "TestDevice",
|
||||
RequestType: "passkey",
|
||||
Fields: []string{"passkey"},
|
||||
}
|
||||
|
||||
token, err := broker.Ask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = broker.Wait(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionBrokerCancel(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
req := PromptRequest{
|
||||
DevicePath: "/org/bluez/test",
|
||||
DeviceName: "TestDevice",
|
||||
RequestType: "confirm",
|
||||
Fields: []string{"decision"},
|
||||
}
|
||||
|
||||
token, err := broker.Ask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask failed: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
broker.Resolve(token, PromptReply{
|
||||
Cancel: true,
|
||||
})
|
||||
}()
|
||||
|
||||
_, err = broker.Wait(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected cancelled error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionBrokerUnknownToken(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := broker.Wait(ctx, "invalid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
token1, err := generateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generateToken failed: %v", err)
|
||||
}
|
||||
|
||||
token2, err := generateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generateToken failed: %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("expected unique tokens")
|
||||
}
|
||||
|
||||
if len(token1) != 32 {
|
||||
t.Errorf("expected token length 32, got %d", len(token1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionBrokerResolveUnknownToken(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
|
||||
err := broker.Resolve("unknown-token", PromptReply{
|
||||
Secrets: map[string]string{"test": "value"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionBrokerMultipleRequests(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
req1 := PromptRequest{
|
||||
DevicePath: "/org/bluez/test1",
|
||||
DeviceName: "Device1",
|
||||
RequestType: "pin",
|
||||
Fields: []string{"pin"},
|
||||
}
|
||||
|
||||
req2 := PromptRequest{
|
||||
DevicePath: "/org/bluez/test2",
|
||||
DeviceName: "Device2",
|
||||
RequestType: "passkey",
|
||||
Fields: []string{"passkey"},
|
||||
}
|
||||
|
||||
token1, err := broker.Ask(ctx, req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask1 failed: %v", err)
|
||||
}
|
||||
|
||||
token2, err := broker.Ask(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask2 failed: %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("expected different tokens")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
broker.Resolve(token1, PromptReply{
|
||||
Secrets: map[string]string{"pin": "1234"},
|
||||
Accept: true,
|
||||
})
|
||||
broker.Resolve(token2, PromptReply{
|
||||
Secrets: map[string]string{"passkey": "567890"},
|
||||
Accept: true,
|
||||
})
|
||||
}()
|
||||
|
||||
reply1, err := broker.Wait(ctx, token1)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait1 failed: %v", err)
|
||||
}
|
||||
|
||||
reply2, err := broker.Wait(ctx, token2)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait2 failed: %v", err)
|
||||
}
|
||||
|
||||
if reply1.Secrets["pin"] != "1234" {
|
||||
t.Errorf("expected pin=1234, got %s", reply1.Secrets["pin"])
|
||||
}
|
||||
|
||||
if reply2.Secrets["passkey"] != "567890" {
|
||||
t.Errorf("expected passkey=567890, got %s", reply2.Secrets["passkey"])
|
||||
}
|
||||
}
|
||||
260
backend/internal/server/bluez/handlers.go
Normal file
260
backend/internal/server/bluez/handlers.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type SuccessResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type BluetoothEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data BluetoothState `json:"data"`
|
||||
}
|
||||
|
||||
func HandleRequest(conn net.Conn, req Request, manager *Manager) {
|
||||
switch req.Method {
|
||||
case "bluetooth.getState":
|
||||
handleGetState(conn, req, manager)
|
||||
case "bluetooth.startDiscovery":
|
||||
handleStartDiscovery(conn, req, manager)
|
||||
case "bluetooth.stopDiscovery":
|
||||
handleStopDiscovery(conn, req, manager)
|
||||
case "bluetooth.setPowered":
|
||||
handleSetPowered(conn, req, manager)
|
||||
case "bluetooth.pair":
|
||||
handlePairDevice(conn, req, manager)
|
||||
case "bluetooth.connect":
|
||||
handleConnectDevice(conn, req, manager)
|
||||
case "bluetooth.disconnect":
|
||||
handleDisconnectDevice(conn, req, manager)
|
||||
case "bluetooth.remove":
|
||||
handleRemoveDevice(conn, req, manager)
|
||||
case "bluetooth.trust":
|
||||
handleTrustDevice(conn, req, manager)
|
||||
case "bluetooth.untrust":
|
||||
handleUntrustDevice(conn, req, manager)
|
||||
case "bluetooth.subscribe":
|
||||
handleSubscribe(conn, req, manager)
|
||||
case "bluetooth.pairing.submit":
|
||||
handlePairingSubmit(conn, req, manager)
|
||||
case "bluetooth.pairing.cancel":
|
||||
handlePairingCancel(conn, req, manager)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetState(conn net.Conn, req Request, manager *Manager) {
|
||||
state := manager.GetState()
|
||||
models.Respond(conn, req.ID, state)
|
||||
}
|
||||
|
||||
func handleStartDiscovery(conn net.Conn, req Request, manager *Manager) {
|
||||
if err := manager.StartDiscovery(); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery started"})
|
||||
}
|
||||
|
||||
func handleStopDiscovery(conn net.Conn, req Request, manager *Manager) {
|
||||
if err := manager.StopDiscovery(); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery stopped"})
|
||||
}
|
||||
|
||||
func handleSetPowered(conn net.Conn, req Request, manager *Manager) {
|
||||
powered, ok := req.Params["powered"].(bool)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'powered' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.SetPowered(powered); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "powered state updated"})
|
||||
}
|
||||
|
||||
func handlePairDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.PairDevice(devicePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing initiated"})
|
||||
}
|
||||
|
||||
func handleConnectDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.ConnectDevice(devicePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
|
||||
}
|
||||
|
||||
func handleDisconnectDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.DisconnectDevice(devicePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "disconnected"})
|
||||
}
|
||||
|
||||
func handleRemoveDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.RemoveDevice(devicePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device removed"})
|
||||
}
|
||||
|
||||
func handleTrustDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.TrustDevice(devicePath, true); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device trusted"})
|
||||
}
|
||||
|
||||
func handleUntrustDevice(conn net.Conn, req Request, manager *Manager) {
|
||||
devicePath, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.TrustDevice(devicePath, false); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device untrusted"})
|
||||
}
|
||||
|
||||
func handlePairingSubmit(conn net.Conn, req Request, manager *Manager) {
|
||||
token, ok := req.Params["token"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
secretsRaw, ok := req.Params["secrets"].(map[string]interface{})
|
||||
secrets := make(map[string]string)
|
||||
if ok {
|
||||
for k, v := range secretsRaw {
|
||||
if str, ok := v.(string); ok {
|
||||
secrets[k] = str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accept := false
|
||||
if acceptParam, ok := req.Params["accept"].(bool); ok {
|
||||
accept = acceptParam
|
||||
}
|
||||
|
||||
if err := manager.SubmitPairing(token, secrets, accept); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing response submitted"})
|
||||
}
|
||||
|
||||
func handlePairingCancel(conn net.Conn, req Request, manager *Manager) {
|
||||
token, ok := req.Params["token"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.CancelPairing(token); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing cancelled"})
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
|
||||
clientID := fmt.Sprintf("client-%p", conn)
|
||||
stateChan := manager.Subscribe(clientID)
|
||||
defer manager.Unsubscribe(clientID)
|
||||
|
||||
initialState := manager.GetState()
|
||||
event := BluetoothEvent{
|
||||
Type: "state_changed",
|
||||
Data: initialState,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
|
||||
ID: req.ID,
|
||||
Result: &event,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for state := range stateChan {
|
||||
event := BluetoothEvent{
|
||||
Type: "state_changed",
|
||||
Data: state,
|
||||
}
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
|
||||
Result: &event,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/internal/server/bluez/handlers_test.go
Normal file
41
backend/internal/server/bluez/handlers_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBrokerIntegration(t *testing.T) {
|
||||
broker := NewSubscriptionBroker(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
req := PromptRequest{
|
||||
DevicePath: "/org/bluez/test",
|
||||
DeviceName: "TestDevice",
|
||||
RequestType: "pin",
|
||||
Fields: []string{"pin"},
|
||||
}
|
||||
|
||||
token, err := broker.Ask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask failed: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
broker.Resolve(token, PromptReply{
|
||||
Secrets: map[string]string{"pin": "1234"},
|
||||
Accept: true,
|
||||
})
|
||||
}()
|
||||
|
||||
reply, err := broker.Wait(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait failed: %v", err)
|
||||
}
|
||||
|
||||
if reply.Secrets["pin"] != "1234" {
|
||||
t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
|
||||
}
|
||||
}
|
||||
668
backend/internal/server/bluez/manager.go
Normal file
668
backend/internal/server/bluez/manager.go
Normal file
@@ -0,0 +1,668 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
adapter1Iface = "org.bluez.Adapter1"
|
||||
objectMgrIface = "org.freedesktop.DBus.ObjectManager"
|
||||
propertiesIface = "org.freedesktop.DBus.Properties"
|
||||
)
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("system bus connection failed: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
state: &BluetoothState{
|
||||
Powered: false,
|
||||
Discovering: false,
|
||||
Devices: []Device{},
|
||||
PairedDevices: []Device{},
|
||||
ConnectedDevices: []Device{},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
subscribers: make(map[string]chan BluetoothState),
|
||||
subMutex: sync.RWMutex{},
|
||||
stopChan: make(chan struct{}),
|
||||
dbusConn: conn,
|
||||
signals: make(chan *dbus.Signal, 256),
|
||||
pairingSubscribers: make(map[string]chan PairingPrompt),
|
||||
pairingSubMutex: sync.RWMutex{},
|
||||
dirty: make(chan struct{}, 1),
|
||||
pendingPairings: make(map[string]bool),
|
||||
eventQueue: make(chan func(), 32),
|
||||
}
|
||||
|
||||
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
||||
m.promptBroker = broker
|
||||
|
||||
adapter, err := m.findAdapter()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
|
||||
}
|
||||
m.adapterPath = adapter
|
||||
|
||||
if err := m.initialize(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := m.startAgent(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("agent start failed: %w", err)
|
||||
}
|
||||
|
||||
if err := m.startSignalPump(); err != nil {
|
||||
m.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifierWg.Add(1)
|
||||
go m.notifier()
|
||||
|
||||
m.eventWg.Add(1)
|
||||
go m.eventWorker()
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
|
||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for path, interfaces := range objects {
|
||||
if _, ok := interfaces[adapter1Iface]; ok {
|
||||
log.Infof("[BluezManager] found adapter: %s", path)
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no adapter found")
|
||||
}
|
||||
|
||||
func (m *Manager) initialize() error {
|
||||
if err := m.updateAdapterState(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateDevices(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) updateAdapterState() error {
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
|
||||
poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
powered, _ := poweredVar.Value().(bool)
|
||||
|
||||
discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discovering, _ := discoveringVar.Value().(bool)
|
||||
|
||||
m.stateMutex.Lock()
|
||||
m.state.Powered = powered
|
||||
m.state.Discovering = discovering
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) updateDevices() error {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
|
||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
devices := []Device{}
|
||||
paired := []Device{}
|
||||
connected := []Device{}
|
||||
|
||||
for path, interfaces := range objects {
|
||||
devProps, ok := interfaces[device1Iface]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") {
|
||||
continue
|
||||
}
|
||||
|
||||
dev := m.deviceFromProps(string(path), devProps)
|
||||
devices = append(devices, dev)
|
||||
|
||||
if dev.Paired {
|
||||
paired = append(paired, dev)
|
||||
}
|
||||
if dev.Connected {
|
||||
connected = append(connected, dev)
|
||||
}
|
||||
}
|
||||
|
||||
m.stateMutex.Lock()
|
||||
m.state.Devices = devices
|
||||
m.state.PairedDevices = paired
|
||||
m.state.ConnectedDevices = connected
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
|
||||
dev := Device{Path: path}
|
||||
|
||||
if v, ok := props["Address"]; ok {
|
||||
if addr, ok := v.Value().(string); ok {
|
||||
dev.Address = addr
|
||||
}
|
||||
}
|
||||
if v, ok := props["Name"]; ok {
|
||||
if name, ok := v.Value().(string); ok {
|
||||
dev.Name = name
|
||||
}
|
||||
}
|
||||
if v, ok := props["Alias"]; ok {
|
||||
if alias, ok := v.Value().(string); ok {
|
||||
dev.Alias = alias
|
||||
}
|
||||
}
|
||||
if v, ok := props["Paired"]; ok {
|
||||
if paired, ok := v.Value().(bool); ok {
|
||||
dev.Paired = paired
|
||||
}
|
||||
}
|
||||
if v, ok := props["Trusted"]; ok {
|
||||
if trusted, ok := v.Value().(bool); ok {
|
||||
dev.Trusted = trusted
|
||||
}
|
||||
}
|
||||
if v, ok := props["Blocked"]; ok {
|
||||
if blocked, ok := v.Value().(bool); ok {
|
||||
dev.Blocked = blocked
|
||||
}
|
||||
}
|
||||
if v, ok := props["Connected"]; ok {
|
||||
if connected, ok := v.Value().(bool); ok {
|
||||
dev.Connected = connected
|
||||
}
|
||||
}
|
||||
if v, ok := props["Class"]; ok {
|
||||
if class, ok := v.Value().(uint32); ok {
|
||||
dev.Class = class
|
||||
}
|
||||
}
|
||||
if v, ok := props["Icon"]; ok {
|
||||
if icon, ok := v.Value().(string); ok {
|
||||
dev.Icon = icon
|
||||
}
|
||||
}
|
||||
if v, ok := props["RSSI"]; ok {
|
||||
if rssi, ok := v.Value().(int16); ok {
|
||||
dev.RSSI = rssi
|
||||
}
|
||||
}
|
||||
if v, ok := props["LegacyPairing"]; ok {
|
||||
if legacy, ok := v.Value().(bool); ok {
|
||||
dev.LegacyPairing = legacy
|
||||
}
|
||||
}
|
||||
|
||||
return dev
|
||||
}
|
||||
|
||||
func (m *Manager) startAgent() error {
|
||||
if m.promptBroker == nil {
|
||||
return fmt.Errorf("prompt broker not initialized")
|
||||
}
|
||||
|
||||
agent, err := NewBluezAgent(m.promptBroker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.agent = agent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startSignalPump() error {
|
||||
m.dbusConn.Signal(m.signals)
|
||||
|
||||
if err := m.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(propertiesIface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(objectMgrIface),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(objectMgrIface),
|
||||
dbus.WithMatchMember("InterfacesRemoved"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.sigWG.Add(1)
|
||||
go func() {
|
||||
defer m.sigWG.Done()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case sig, ok := <-m.signals:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
m.handleSignal(sig)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleSignal(sig *dbus.Signal) {
|
||||
switch sig.Name {
|
||||
case propertiesIface + ".PropertiesChanged":
|
||||
if len(sig.Body) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
iface, ok := sig.Body[0].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
changed, ok := sig.Body[1].(map[string]dbus.Variant)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch iface {
|
||||
case adapter1Iface:
|
||||
if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) {
|
||||
m.handleAdapterPropertiesChanged(changed)
|
||||
}
|
||||
case device1Iface:
|
||||
m.handleDevicePropertiesChanged(sig.Path, changed)
|
||||
}
|
||||
|
||||
case objectMgrIface + ".InterfacesAdded":
|
||||
m.notifySubscribers()
|
||||
|
||||
case objectMgrIface + ".InterfacesRemoved":
|
||||
m.notifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) {
|
||||
m.stateMutex.Lock()
|
||||
dirty := false
|
||||
|
||||
if v, ok := changed["Powered"]; ok {
|
||||
if powered, ok := v.Value().(bool); ok {
|
||||
m.state.Powered = powered
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if v, ok := changed["Discovering"]; ok {
|
||||
if discovering, ok := v.Value().(bool); ok {
|
||||
m.state.Discovering = discovering
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
if dirty {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
|
||||
pairedVar, hasPaired := changed["Paired"]
|
||||
_, hasConnected := changed["Connected"]
|
||||
_, hasTrusted := changed["Trusted"]
|
||||
|
||||
if hasPaired {
|
||||
if paired, ok := pairedVar.Value().(bool); ok && paired {
|
||||
devicePath := string(path)
|
||||
m.pendingPairingsMux.Lock()
|
||||
wasPending := m.pendingPairings[devicePath]
|
||||
if wasPending {
|
||||
delete(m.pendingPairings, devicePath)
|
||||
}
|
||||
m.pendingPairingsMux.Unlock()
|
||||
|
||||
if wasPending {
|
||||
select {
|
||||
case m.eventQueue <- func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
|
||||
if err := m.ConnectDevice(devicePath); err != nil {
|
||||
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
|
||||
}
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasPaired || hasConnected || hasTrusted {
|
||||
select {
|
||||
case m.eventQueue <- func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
m.updateDevices()
|
||||
m.notifySubscribers()
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) eventWorker() {
|
||||
defer m.eventWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case event := <-m.eventQueue:
|
||||
event()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) notifier() {
|
||||
defer m.notifierWg.Done()
|
||||
const minGap = 200 * time.Millisecond
|
||||
timer := time.NewTimer(minGap)
|
||||
timer.Stop()
|
||||
var pending bool
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.dirty:
|
||||
if pending {
|
||||
continue
|
||||
}
|
||||
pending = true
|
||||
timer.Reset(minGap)
|
||||
case <-timer.C:
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
m.updateDevices()
|
||||
|
||||
m.subMutex.RLock()
|
||||
if len(m.subscribers) == 0 {
|
||||
m.subMutex.RUnlock()
|
||||
pending = false
|
||||
continue
|
||||
}
|
||||
|
||||
currentState := m.snapshotState()
|
||||
|
||||
if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, ¤tState) {
|
||||
m.subMutex.RUnlock()
|
||||
pending = false
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ch := range m.subscribers {
|
||||
select {
|
||||
case ch <- currentState:
|
||||
default:
|
||||
}
|
||||
}
|
||||
m.subMutex.RUnlock()
|
||||
|
||||
stateCopy := currentState
|
||||
m.lastNotifiedState = &stateCopy
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) notifySubscribers() {
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() BluetoothState {
|
||||
return m.snapshotState()
|
||||
}
|
||||
|
||||
func (m *Manager) snapshotState() BluetoothState {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
|
||||
s := *m.state
|
||||
s.Devices = append([]Device(nil), m.state.Devices...)
|
||||
s.PairedDevices = append([]Device(nil), m.state.PairedDevices...)
|
||||
s.ConnectedDevices = append([]Device(nil), m.state.ConnectedDevices...)
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan BluetoothState {
|
||||
ch := make(chan BluetoothState, 64)
|
||||
m.subMutex.Lock()
|
||||
m.subscribers[id] = ch
|
||||
m.subMutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subMutex.Lock()
|
||||
if ch, ok := m.subscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(m.subscribers, id)
|
||||
}
|
||||
m.subMutex.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) SubscribePairing(id string) chan PairingPrompt {
|
||||
ch := make(chan PairingPrompt, 16)
|
||||
m.pairingSubMutex.Lock()
|
||||
m.pairingSubscribers[id] = ch
|
||||
m.pairingSubMutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) UnsubscribePairing(id string) {
|
||||
m.pairingSubMutex.Lock()
|
||||
if ch, ok := m.pairingSubscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(m.pairingSubscribers, id)
|
||||
}
|
||||
m.pairingSubMutex.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastPairingPrompt(prompt PairingPrompt) {
|
||||
m.pairingSubMutex.RLock()
|
||||
defer m.pairingSubMutex.RUnlock()
|
||||
|
||||
for _, ch := range m.pairingSubscribers {
|
||||
select {
|
||||
case ch <- prompt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SubmitPairing(token string, secrets map[string]string, accept bool) error {
|
||||
if m.promptBroker == nil {
|
||||
return fmt.Errorf("prompt broker not initialized")
|
||||
}
|
||||
|
||||
return m.promptBroker.Resolve(token, PromptReply{
|
||||
Secrets: secrets,
|
||||
Accept: accept,
|
||||
Cancel: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) CancelPairing(token string) error {
|
||||
if m.promptBroker == nil {
|
||||
return fmt.Errorf("prompt broker not initialized")
|
||||
}
|
||||
|
||||
return m.promptBroker.Resolve(token, PromptReply{
|
||||
Cancel: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) StartDiscovery() error {
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(adapter1Iface+".StartDiscovery", 0).Err
|
||||
}
|
||||
|
||||
func (m *Manager) StopDiscovery() error {
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(adapter1Iface+".StopDiscovery", 0).Err
|
||||
}
|
||||
|
||||
func (m *Manager) SetPowered(powered bool) error {
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
||||
}
|
||||
|
||||
func (m *Manager) PairDevice(devicePath string) error {
|
||||
m.pendingPairingsMux.Lock()
|
||||
m.pendingPairings[devicePath] = true
|
||||
m.pendingPairingsMux.Unlock()
|
||||
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
|
||||
err := obj.Call(device1Iface+".Pair", 0).Err
|
||||
|
||||
if err != nil {
|
||||
m.pendingPairingsMux.Lock()
|
||||
delete(m.pendingPairings, devicePath)
|
||||
m.pendingPairingsMux.Unlock()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) ConnectDevice(devicePath string) error {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
|
||||
return obj.Call(device1Iface+".Connect", 0).Err
|
||||
}
|
||||
|
||||
func (m *Manager) DisconnectDevice(devicePath string) error {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
|
||||
return obj.Call(device1Iface+".Disconnect", 0).Err
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveDevice(devicePath string) error {
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err
|
||||
}
|
||||
|
||||
func (m *Manager) TrustDevice(devicePath string, trusted bool) error {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
|
||||
return obj.Call(propertiesIface+".Set", 0, device1Iface, "Trusted", dbus.MakeVariant(trusted)).Err
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
m.notifierWg.Wait()
|
||||
m.eventWg.Wait()
|
||||
|
||||
m.sigWG.Wait()
|
||||
|
||||
if m.signals != nil {
|
||||
m.dbusConn.RemoveSignal(m.signals)
|
||||
close(m.signals)
|
||||
}
|
||||
|
||||
if m.agent != nil {
|
||||
m.agent.Close()
|
||||
}
|
||||
|
||||
m.subMutex.Lock()
|
||||
for _, ch := range m.subscribers {
|
||||
close(ch)
|
||||
}
|
||||
m.subscribers = make(map[string]chan BluetoothState)
|
||||
m.subMutex.Unlock()
|
||||
|
||||
m.pairingSubMutex.Lock()
|
||||
for _, ch := range m.pairingSubscribers {
|
||||
close(ch)
|
||||
}
|
||||
m.pairingSubscribers = make(map[string]chan PairingPrompt)
|
||||
m.pairingSubMutex.Unlock()
|
||||
|
||||
if m.dbusConn != nil {
|
||||
m.dbusConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func stateChanged(old, new *BluetoothState) bool {
|
||||
if old.Powered != new.Powered {
|
||||
return true
|
||||
}
|
||||
if old.Discovering != new.Discovering {
|
||||
return true
|
||||
}
|
||||
if len(old.Devices) != len(new.Devices) {
|
||||
return true
|
||||
}
|
||||
if len(old.PairedDevices) != len(new.PairedDevices) {
|
||||
return true
|
||||
}
|
||||
if len(old.ConnectedDevices) != len(new.ConnectedDevices) {
|
||||
return true
|
||||
}
|
||||
for i := range old.Devices {
|
||||
if old.Devices[i].Path != new.Devices[i].Path {
|
||||
return true
|
||||
}
|
||||
if old.Devices[i].Paired != new.Devices[i].Paired {
|
||||
return true
|
||||
}
|
||||
if old.Devices[i].Connected != new.Devices[i].Connected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
99
backend/internal/server/bluez/subscription_broker.go
Normal file
99
backend/internal/server/bluez/subscription_broker.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
|
||||
)
|
||||
|
||||
type SubscriptionBroker struct {
|
||||
mu sync.RWMutex
|
||||
pending map[string]chan PromptReply
|
||||
requests map[string]PromptRequest
|
||||
broadcastPrompt func(PairingPrompt)
|
||||
}
|
||||
|
||||
func NewSubscriptionBroker(broadcastPrompt func(PairingPrompt)) PromptBroker {
|
||||
return &SubscriptionBroker{
|
||||
pending: make(map[string]chan PromptReply),
|
||||
requests: make(map[string]PromptRequest),
|
||||
broadcastPrompt: broadcastPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
replyChan := make(chan PromptReply, 1)
|
||||
b.mu.Lock()
|
||||
b.pending[token] = replyChan
|
||||
b.requests[token] = req
|
||||
b.mu.Unlock()
|
||||
|
||||
if b.broadcastPrompt != nil {
|
||||
prompt := PairingPrompt{
|
||||
Token: token,
|
||||
DevicePath: req.DevicePath,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceAddr: req.DeviceAddr,
|
||||
RequestType: req.RequestType,
|
||||
Fields: req.Fields,
|
||||
Hints: req.Hints,
|
||||
Passkey: req.Passkey,
|
||||
}
|
||||
b.broadcastPrompt(prompt)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
|
||||
b.mu.RLock()
|
||||
replyChan, exists := b.pending[token]
|
||||
b.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return PromptReply{}, fmt.Errorf("unknown token: %s", token)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.cleanup(token)
|
||||
return PromptReply{}, errdefs.ErrSecretPromptTimeout
|
||||
case reply := <-replyChan:
|
||||
b.cleanup(token)
|
||||
if reply.Cancel {
|
||||
return reply, errdefs.ErrSecretPromptCancelled
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
|
||||
b.mu.RLock()
|
||||
replyChan, exists := b.pending[token]
|
||||
b.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("unknown or expired token: %s", token)
|
||||
}
|
||||
|
||||
select {
|
||||
case replyChan <- reply:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("failed to deliver reply for token: %s", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SubscriptionBroker) cleanup(token string) {
|
||||
b.mu.Lock()
|
||||
delete(b.pending, token)
|
||||
delete(b.requests, token)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
80
backend/internal/server/bluez/types.go
Normal file
80
backend/internal/server/bluez/types.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
type BluetoothState struct {
|
||||
Powered bool `json:"powered"`
|
||||
Discovering bool `json:"discovering"`
|
||||
Devices []Device `json:"devices"`
|
||||
PairedDevices []Device `json:"pairedDevices"`
|
||||
ConnectedDevices []Device `json:"connectedDevices"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Path string `json:"path"`
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias"`
|
||||
Paired bool `json:"paired"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Blocked bool `json:"blocked"`
|
||||
Connected bool `json:"connected"`
|
||||
Class uint32 `json:"class"`
|
||||
Icon string `json:"icon"`
|
||||
RSSI int16 `json:"rssi"`
|
||||
LegacyPairing bool `json:"legacyPairing"`
|
||||
}
|
||||
|
||||
type PromptRequest struct {
|
||||
DevicePath string `json:"devicePath"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceAddr string `json:"deviceAddr"`
|
||||
RequestType string `json:"requestType"`
|
||||
Fields []string `json:"fields"`
|
||||
Hints []string `json:"hints"`
|
||||
Passkey *uint32 `json:"passkey,omitempty"`
|
||||
}
|
||||
|
||||
type PromptReply struct {
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Accept bool `json:"accept"`
|
||||
Cancel bool `json:"cancel"`
|
||||
}
|
||||
|
||||
type PairingPrompt struct {
|
||||
Token string `json:"token"`
|
||||
DevicePath string `json:"devicePath"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceAddr string `json:"deviceAddr"`
|
||||
RequestType string `json:"requestType"`
|
||||
Fields []string `json:"fields"`
|
||||
Hints []string `json:"hints"`
|
||||
Passkey *uint32 `json:"passkey,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
state *BluetoothState
|
||||
stateMutex sync.RWMutex
|
||||
subscribers map[string]chan BluetoothState
|
||||
subMutex sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
dbusConn *dbus.Conn
|
||||
signals chan *dbus.Signal
|
||||
sigWG sync.WaitGroup
|
||||
agent *BluezAgent
|
||||
promptBroker PromptBroker
|
||||
pairingSubscribers map[string]chan PairingPrompt
|
||||
pairingSubMutex sync.RWMutex
|
||||
dirty chan struct{}
|
||||
notifierWg sync.WaitGroup
|
||||
lastNotifiedState *BluetoothState
|
||||
adapterPath dbus.ObjectPath
|
||||
pendingPairings map[string]bool
|
||||
pendingPairingsMux sync.Mutex
|
||||
eventQueue chan func()
|
||||
eventWg sync.WaitGroup
|
||||
}
|
||||
210
backend/internal/server/bluez/types_test.go
Normal file
210
backend/internal/server/bluez/types_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBluetoothStateJSON(t *testing.T) {
|
||||
state := BluetoothState{
|
||||
Powered: true,
|
||||
Discovering: false,
|
||||
Devices: []Device{
|
||||
{
|
||||
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
Address: "AA:BB:CC:DD:EE:FF",
|
||||
Name: "TestDevice",
|
||||
Alias: "My Device",
|
||||
Paired: true,
|
||||
Trusted: false,
|
||||
Connected: true,
|
||||
Class: 0x240418,
|
||||
Icon: "audio-headset",
|
||||
RSSI: -50,
|
||||
},
|
||||
},
|
||||
PairedDevices: []Device{
|
||||
{
|
||||
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
Address: "AA:BB:CC:DD:EE:FF",
|
||||
Paired: true,
|
||||
},
|
||||
},
|
||||
ConnectedDevices: []Device{
|
||||
{
|
||||
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
Address: "AA:BB:CC:DD:EE:FF",
|
||||
Connected: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal state: %v", err)
|
||||
}
|
||||
|
||||
var decoded BluetoothState
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal state: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Powered != state.Powered {
|
||||
t.Errorf("expected Powered=%v, got %v", state.Powered, decoded.Powered)
|
||||
}
|
||||
|
||||
if len(decoded.Devices) != 1 {
|
||||
t.Fatalf("expected 1 device, got %d", len(decoded.Devices))
|
||||
}
|
||||
|
||||
if decoded.Devices[0].Address != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Errorf("expected address AA:BB:CC:DD:EE:FF, got %s", decoded.Devices[0].Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceJSON(t *testing.T) {
|
||||
device := Device{
|
||||
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
Address: "AA:BB:CC:DD:EE:FF",
|
||||
Name: "TestDevice",
|
||||
Alias: "My Device",
|
||||
Paired: true,
|
||||
Trusted: true,
|
||||
Blocked: false,
|
||||
Connected: true,
|
||||
Class: 0x240418,
|
||||
Icon: "audio-headset",
|
||||
RSSI: -50,
|
||||
LegacyPairing: false,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(device)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal device: %v", err)
|
||||
}
|
||||
|
||||
var decoded Device
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal device: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Address != device.Address {
|
||||
t.Errorf("expected Address=%s, got %s", device.Address, decoded.Address)
|
||||
}
|
||||
|
||||
if decoded.Name != device.Name {
|
||||
t.Errorf("expected Name=%s, got %s", device.Name, decoded.Name)
|
||||
}
|
||||
|
||||
if decoded.Paired != device.Paired {
|
||||
t.Errorf("expected Paired=%v, got %v", device.Paired, decoded.Paired)
|
||||
}
|
||||
|
||||
if decoded.RSSI != device.RSSI {
|
||||
t.Errorf("expected RSSI=%d, got %d", device.RSSI, decoded.RSSI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingPromptJSON(t *testing.T) {
|
||||
passkey := uint32(123456)
|
||||
prompt := PairingPrompt{
|
||||
Token: "test-token",
|
||||
DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
DeviceName: "TestDevice",
|
||||
DeviceAddr: "AA:BB:CC:DD:EE:FF",
|
||||
RequestType: "confirm",
|
||||
Fields: []string{"decision"},
|
||||
Hints: []string{},
|
||||
Passkey: &passkey,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal prompt: %v", err)
|
||||
}
|
||||
|
||||
var decoded PairingPrompt
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal prompt: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Token != prompt.Token {
|
||||
t.Errorf("expected Token=%s, got %s", prompt.Token, decoded.Token)
|
||||
}
|
||||
|
||||
if decoded.DeviceName != prompt.DeviceName {
|
||||
t.Errorf("expected DeviceName=%s, got %s", prompt.DeviceName, decoded.DeviceName)
|
||||
}
|
||||
|
||||
if decoded.Passkey == nil {
|
||||
t.Fatal("expected non-nil Passkey")
|
||||
}
|
||||
|
||||
if *decoded.Passkey != *prompt.Passkey {
|
||||
t.Errorf("expected Passkey=%d, got %d", *prompt.Passkey, *decoded.Passkey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptReplyJSON(t *testing.T) {
|
||||
reply := PromptReply{
|
||||
Secrets: map[string]string{
|
||||
"pin": "1234",
|
||||
"passkey": "567890",
|
||||
},
|
||||
Accept: true,
|
||||
Cancel: false,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(reply)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal reply: %v", err)
|
||||
}
|
||||
|
||||
var decoded PromptReply
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal reply: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Secrets["pin"] != reply.Secrets["pin"] {
|
||||
t.Errorf("expected pin=%s, got %s", reply.Secrets["pin"], decoded.Secrets["pin"])
|
||||
}
|
||||
|
||||
if decoded.Accept != reply.Accept {
|
||||
t.Errorf("expected Accept=%v, got %v", reply.Accept, decoded.Accept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptRequestJSON(t *testing.T) {
|
||||
passkey := uint32(123456)
|
||||
req := PromptRequest{
|
||||
DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
DeviceName: "TestDevice",
|
||||
DeviceAddr: "AA:BB:CC:DD:EE:FF",
|
||||
RequestType: "confirm",
|
||||
Fields: []string{"decision"},
|
||||
Hints: []string{"hint1", "hint2"},
|
||||
Passkey: &passkey,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
var decoded PromptRequest
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal request: %v", err)
|
||||
}
|
||||
|
||||
if decoded.DevicePath != req.DevicePath {
|
||||
t.Errorf("expected DevicePath=%s, got %s", req.DevicePath, decoded.DevicePath)
|
||||
}
|
||||
|
||||
if decoded.RequestType != req.RequestType {
|
||||
t.Errorf("expected RequestType=%s, got %s", req.RequestType, decoded.RequestType)
|
||||
}
|
||||
|
||||
if len(decoded.Fields) != len(req.Fields) {
|
||||
t.Errorf("expected %d fields, got %d", len(req.Fields), len(decoded.Fields))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user