Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
// Package bluetooth wraps bluetoothctl to read and control Bluetooth state
// (controller power, device pairing/connection). Every exported function
// shells out to bluetoothctl in its non-interactive single-command mode;
// there is no direct D-Bus/BlueZ integration.
package bluetooth
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
)
var (
ErrNotFound = errors.New("bluetoothctl not found in PATH")
ErrNoController = errors.New("no Bluetooth controller found")
)
const defaultTimeout = 15 * time.Second
// CheckAvailable reports whether bluetoothctl is installed and a Bluetooth
// controller is reachable. Call it before using the rest of the package.
func CheckAvailable() error {
if _, err := exec.LookPath("bluetoothctl"); err != nil {
return ErrNotFound
}
out, err := run(defaultTimeout, "list")
if err != nil {
return fmt.Errorf("%w: %v", ErrNoController, err)
}
if strings.TrimSpace(out) == "" {
return ErrNoController
}
return nil
}
// run executes bluetoothctl in single-command (non-interactive) mode.
// bluetoothctl writes both normal output and error messages to stdout
// (colored with ANSI escapes), so errors are extracted from there rather
// than stderr.
func run(timeout time.Duration, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bluetoothctl", args...) // #nosec G204 -- args are fixed bluetoothctl subcommands or user-supplied device addresses, never shell-interpreted
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
out := ansi.Strip(stdout.String())
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", ErrNotFound
}
msg := strings.TrimSpace(out)
if msg == "" {
msg = strings.TrimSpace(stderr.String())
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), msg)
}
return out, nil
}
+61
View File
@@ -0,0 +1,61 @@
package bluetooth
import "strings"
// ControllerInfo is the state of the default Bluetooth adapter, as reported
// by `bluetoothctl show`.
type ControllerInfo struct {
Address string
Name string
Powered bool
Discoverable bool
Pairable bool
Discovering bool
}
// ShowController returns the state of the default Bluetooth controller.
func ShowController() (ControllerInfo, error) {
out, err := run(defaultTimeout, "show")
if err != nil {
return ControllerInfo{}, err
}
var c ControllerInfo
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(line, "Controller "); ok {
if fields := strings.Fields(rest); len(fields) > 0 {
c.Address = fields[0]
}
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.TrimSpace(value)
switch strings.TrimSpace(key) {
case "Name":
c.Name = value
case "Powered":
c.Powered = value == "yes"
case "Discoverable":
c.Discoverable = value == "yes"
case "Pairable":
c.Pairable = value == "yes"
case "Discovering":
c.Discovering = value == "yes"
}
}
return c, nil
}
// SetPower turns the Bluetooth controller on or off entirely.
func SetPower(on bool) error {
state := "off"
if on {
state = "on"
}
_, err := run(defaultTimeout, "power", state)
return err
}
+225
View File
@@ -0,0 +1,225 @@
package bluetooth
import (
"strconv"
"strings"
"time"
)
const (
// actionTimeout bounds connect/pair calls, which can take longer than a
// plain query while BlueZ negotiates with the remote device.
actionTimeout = 30 * time.Second
scanBuffer = 10 * time.Second
)
// Device is a Bluetooth device known to BlueZ: either paired, or seen
// during a recent scan.
type Device struct {
Address string
Name string
Paired bool
Bonded bool
Trusted bool
Connected bool
Blocked bool
Icon string // freedesktop icon name, e.g. "input-mouse"; empty if unknown
Battery int // 0-100, -1 if unknown
}
// DeviceDetail extends Device with the extra fields shown on the "device
// info" screen.
type DeviceDetail struct {
Device
Modalias string
UUIDs []string // human-readable service labels
}
// parseDeviceLines parses the "Device <address> <name>" lines printed by
// `bluetoothctl devices` into address -> name.
func parseDeviceLines(out string) map[string]string {
names := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
rest, ok := strings.CutPrefix(line, "Device ")
if !ok {
continue
}
addr, name, ok := strings.Cut(rest, " ")
if !ok {
continue
}
names[addr] = name
}
return names
}
func deviceSet(filter string) (map[string]bool, error) {
args := []string{"devices"}
if filter != "" {
args = append(args, filter)
}
out, err := run(defaultTimeout, args...)
if err != nil {
return nil, err
}
set := make(map[string]bool)
for addr := range parseDeviceLines(out) {
set[addr] = true
}
return set, nil
}
// ListDevices returns every device BlueZ currently knows about: paired
// devices plus anything seen during a recent scan. Paired devices get an
// extra `info` lookup for icon/battery/blocked state; unpaired ones don't,
// to keep a large scan result snappy.
func ListDevices() ([]Device, error) {
out, err := run(defaultTimeout, "devices")
if err != nil {
return nil, err
}
names := parseDeviceLines(out)
paired, err := deviceSet("Paired")
if err != nil {
return nil, err
}
connected, err := deviceSet("Connected")
if err != nil {
return nil, err
}
trusted, err := deviceSet("Trusted")
if err != nil {
return nil, err
}
devices := make([]Device, 0, len(names))
for addr, name := range names {
d := Device{
Address: addr,
Name: name,
Paired: paired[addr],
Connected: connected[addr],
Trusted: trusted[addr],
Battery: -1,
}
if d.Paired {
if detail, err := Info(addr); err == nil {
d.Icon = detail.Icon
d.Battery = detail.Battery
d.Bonded = detail.Bonded
d.Blocked = detail.Blocked
if detail.Name != "" {
d.Name = detail.Name
}
}
}
if d.Name == "" {
d.Name = d.Address
}
devices = append(devices, d)
}
return devices, nil
}
// Info fetches full detail for one device via `bluetoothctl info`.
func Info(address string) (DeviceDetail, error) {
out, err := run(defaultTimeout, "info", address)
if err != nil {
return DeviceDetail{}, err
}
d := DeviceDetail{Device: Device{Address: address, Battery: -1}}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Device ") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "Name":
d.Name = value
case "Icon":
d.Icon = value
case "Paired":
d.Paired = value == "yes"
case "Bonded":
d.Bonded = value == "yes"
case "Trusted":
d.Trusted = value == "yes"
case "Blocked":
d.Blocked = value == "yes"
case "Connected":
d.Connected = value == "yes"
case "Modalias":
d.Modalias = value
case "UUID":
if i := strings.Index(value, "("); i >= 0 {
value = strings.TrimSpace(value[:i])
}
if value != "" {
d.UUIDs = append(d.UUIDs, value)
}
case "Battery Percentage":
if i, j := strings.Index(value, "("), strings.Index(value, ")"); i >= 0 && j > i {
if n, err := strconv.Atoi(value[i+1 : j]); err == nil {
d.Battery = n
}
}
}
}
return d, nil
}
// Scan puts the controller into discovery mode for the given number of
// seconds, then returns. Newly seen devices show up in a subsequent
// ListDevices call.
func Scan(seconds int) error {
_, err := run(time.Duration(seconds)*time.Second+scanBuffer, "--timeout", strconv.Itoa(seconds), "scan", "on")
return err
}
// Pair initiates pairing with a device. Because non-interactive
// bluetoothctl has no agent to answer PIN/confirmation prompts, this only
// succeeds for "just works" devices (most mice, keyboards, headsets).
func Pair(address string) error {
_, err := run(actionTimeout, "pair", address)
return err
}
// Connect brings up all profiles for an already-paired (or just-paired)
// device.
func Connect(address string) error {
_, err := run(actionTimeout, "connect", address)
return err
}
// Disconnect disconnects a device without forgetting it.
func Disconnect(address string) error {
_, err := run(defaultTimeout, "disconnect", address)
return err
}
// SetTrusted marks a device as trusted (allowed to reconnect without
// confirmation) or not.
func SetTrusted(address string, trust bool) error {
verb := "untrust"
if trust {
verb = "trust"
}
_, err := run(defaultTimeout, verb, address)
return err
}
// Remove deletes a paired device's profile, or drops an unpaired device
// from BlueZ's scan cache.
func Remove(address string) error {
_, err := run(defaultTimeout, "remove", address)
return err
}