1
0
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:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View 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)
}

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

View 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"])
}
}

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

View 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"])
}
}

View 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, &currentState) {
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
}

View 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()
}

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

View 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))
}
}