bluetooth: the stack, its daemon, and the devices as tools (hq to-be 42 phase 2.9)

bluez and bluez-utils, and bluetooth.service running and enabled. On both
workstations bluez is installed only as a dependency; declaring it keeps a
clean-up from taking it.

Nine Go tools over bluetoothctl: controller, power, devices with battery
where reported, a bounded scan, connect, disconnect, trust, pair (an agent
that confirms nothing, for headphones) and remove. An act whose output says
it failed is an error whatever the exit status, and one bluez refuses the
account is repeated through sudo -n.
This commit is contained in:
jochen
2026-10-04 13:02:23 +02:00
parent b352f3920e
commit 3b43a1ef4d
10 changed files with 1298 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# bluetooth
Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
## Owns
| what | where |
|---|---|
| the Bluetooth stack and its daemon | package `bluez` |
| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
| the daemon, running and enabled | `bluetooth.service` |
All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
(every setting commented out). The module states nothing in it, so it declares nothing there.
## Improves
- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
for the next clean-up to take, and Bluetooth with it.
- **An owner for the daemon**, running and enabled on both today with nothing recording why.
- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
any machine, without the applet.
## Tools
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
bluetoothctl's exit status.
| tool | what |
|---|---|
| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
| `bluetooth_power` (r/a) | read, or switch the controller on or off |
| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
| `bluetooth_trust` (a) | trust, or untrust |
| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
| `bluetooth_remove` (a) | forget a device |
## What changes when it is assigned
Nothing on disk on either workstation: both packages are installed, and the service is enabled and
running. `bluez` becomes explicitly the mesh's.
## Leaves as found
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
That line is the `i3` module's to keep or drop.
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
@@ -0,0 +1,281 @@
package main
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`)
func addressOf(args map[string]any) (string, error) {
a, err := text(args, "address")
if err != nil {
return "", err
}
if !macAddress.MatchString(a) {
return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a)
}
return strings.ToUpper(a), nil
}
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`)
// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status.
var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`)
// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the
// account act, and if it refuses, the act is run through sudo -n.
func bt(timeout time.Duration, args ...string) (string, error) {
c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout}
r := run(c)
if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") {
c.Root = true
r = run(c)
}
out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "")
if r.Error != "" {
return out, failure(c, r)
}
if strings.Contains(out, "No default controller available") {
return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running")
}
if m := btFailed.FindString(out); m != "" || r.Status != 0 {
said := strings.TrimSpace(out)
if said == "" {
said = fmt.Sprintf("exit status %d", r.Status)
}
return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000))
}
return out, nil
}
// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value.
func fields(s string) map[string]string {
out := map[string]string{}
for _, l := range strings.Split(s, "\n") {
if !strings.HasPrefix(l, "\t") {
continue
}
k, v, ok := strings.Cut(strings.TrimSpace(l), ":")
if !ok {
continue
}
if _, seen := out[k]; !seen {
out[k] = strings.TrimSpace(v)
}
}
return out
}
func yes(v string) bool { return v == "yes" }
// ControllerAnswer is what bluetooth_controller answers.
type ControllerAnswer struct {
Address string `json:"address"`
Name string `json:"name"`
Alias string `json:"alias"`
Powered bool `json:"powered"`
PowerState string `json:"power_state,omitempty"`
Discoverable bool `json:"discoverable"`
Pairable bool `json:"pairable"`
Discovering bool `json:"discovering"`
}
// Controller answers the default controller.
func Controller() (ControllerAnswer, error) {
out, err := bt(CallTimeout, "show")
if err != nil {
return ControllerAnswer{}, err
}
c := ControllerAnswer{}
for _, l := range strings.Split(out, "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" {
c.Address = f[1]
break
}
}
if c.Address == "" {
return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500))
}
f := fields(out)
c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"]
c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"])
return c, nil
}
// Power switches the controller on or off.
func Power(on bool) (map[string]any, error) {
word := "off"
if on {
word = "on"
}
if _, err := bt(CallTimeout, "power", word); err != nil {
return nil, err
}
c, err := Controller()
if err != nil {
return nil, err
}
return map[string]any{"powered": c.Powered, "asked": word}, nil
}
// Device is one device bluez knows.
type Device struct {
Address string `json:"address"`
Name string `json:"name"`
Icon string `json:"kind,omitempty"`
Paired bool `json:"paired"`
Bonded bool `json:"bonded"`
Trusted bool `json:"trusted"`
Blocked bool `json:"blocked"`
Connected bool `json:"connected"`
Battery *int `json:"battery_percent,omitempty"`
RSSI *int `json:"rssi,omitempty"`
}
var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`)
// number reads "0x50 (80)" or "-62" as a number.
func number(v string) *int {
if m := inParens.FindStringSubmatch(v); m != nil {
v = m[1]
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return nil
}
return &n
}
// deviceInfo asks bluez for one device.
func deviceInfo(address string) (Device, error) {
out, err := bt(CallTimeout, "info", address)
if err != nil {
return Device{}, err
}
f := fields(out)
d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]),
Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])}
if d.Name == "" {
d.Name = f["Alias"]
}
if v, ok := f["Battery Percentage"]; ok {
d.Battery = number(v)
}
if v, ok := f["RSSI"]; ok {
d.RSSI = number(v)
}
return d, nil
}
// listed reads "Device <address> <name>" lines.
func listed(out string) []string {
seen := map[string]bool{}
addrs := []string{}
for _, l := range strings.Split(out, "\n") {
f := strings.Fields(strings.TrimSpace(l))
if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
seen[f[1]] = true
addrs = append(addrs, f[1])
}
}
return addrs
}
// Devices answers the devices bluez knows, with each one's state.
func Devices(which string) (map[string]any, error) {
if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
return nil, err
}
args := []string{"devices"}
if which != "all" {
args = append(args, strings.ToUpper(which[:1])+which[1:])
}
out, err := bt(CallTimeout, args...)
if err != nil {
return nil, err
}
devices := []Device{}
for _, a := range listed(out) {
d, err := deviceInfo(a)
if err != nil {
return nil, err
}
devices = append(devices, d)
}
sort.SliceStable(devices, func(i, k int) bool {
if devices[i].Connected != devices[k].Connected {
return devices[i].Connected
}
return devices[i].Name < devices[k].Name
})
return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
}
// Scan discovers for a while and answers the devices found that are not paired.
func Scan(seconds int) (map[string]any, error) {
// bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
limit := time.Duration(seconds+4) * time.Second
if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
return nil, err
}
out, err := bt(CallTimeout, "devices")
if err != nil {
return nil, err
}
found := []Device{}
for _, a := range listed(out) {
// A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
d, err := deviceInfo(a)
if err != nil {
continue
}
if !d.Paired {
found = append(found, d)
}
}
sort.SliceStable(found, func(i, k int) bool {
ri, rk := -1000, -1000
if found[i].RSSI != nil {
ri = *found[i].RSSI
}
if found[k].RSSI != nil {
rk = *found[k].RSSI
}
return ri > rk
})
return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
}
// Act runs one act on a device and answers the device's state afterwards.
func Act(verb, address string) (map[string]any, error) {
limit := CallTimeout
if verb == "connect" {
// A connect waits for the device; bluetoothctl's own timeout ends it first.
if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
return nil, err
}
} else if _, err := bt(limit, verb, address); err != nil {
return nil, err
}
if verb == "remove" {
return map[string]any{"act": verb, "address": address, "removed": true}, nil
}
d, err := deviceInfo(address)
if err != nil {
return nil, err
}
return map[string]any{"act": verb, "device": d}, nil
}
// Pair pairs a device with an agent that confirms nothing, then trusts it.
func Pair(address string) (map[string]any, error) {
if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
return nil, err
}
return Act("trust", address)
}
@@ -0,0 +1,163 @@
package main
import (
"strings"
"testing"
)
func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
m := readManifest(t)
holdsTheBundle(t, m, "bluetooth")
if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
t.Errorf("packages %s: the applet and the TUI are the operator's", got)
}
s := m.services()["bluetooth.service"]
if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
t.Errorf("%v", s)
}
if len(m.Resources) != 3 {
t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
}
}
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
func headphones(connected bool) string {
c := "no"
extra := ""
if connected {
c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
}
return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
}
func TestControllerReadsShow(t *testing.T) {
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
c, err := Controller()
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
t.Fatalf("%+v %v", c, err)
}
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
t.Fatalf("%v", err)
}
}
func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
switch line {
case "bluetoothctl devices Paired":
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
case "bluetoothctl info 80:99:E7:C2:29:DA":
return ok(headphones(true))
}
return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
})
got, err := Devices("paired")
devices := got["devices"].([]Device)
if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
t.Fatalf("%+v %v", got, err)
}
if f.lines()[0] != "bluetoothctl devices Paired" {
t.Errorf("%v", f.lines())
}
if _, err := Devices("nearby"); err == nil {
t.Error("an unknown which")
}
}
func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
using(t, func(line string, c Cmd) Result {
return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
})
if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
t.Fatalf("%v", err)
}
using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
t.Fatalf("%v", err)
}
}
func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.Contains(line, "connect") {
return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
}
return ok(headphones(true))
})
got, err := Act("connect", "80:99:E7:C2:29:DA")
if err != nil || !got["device"].(Device).Connected {
t.Fatalf("%v %v", got, err)
}
if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
t.Errorf("%v", f.lines())
}
}
func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.HasPrefix(line, "sudo") {
return ok("Changing power off succeeded\n" + show)
}
return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
})
if _, err := Power(false); err != nil {
t.Fatal(err)
}
if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
t.Errorf("%v", l)
}
}
func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
switch {
case strings.Contains(line, "scan on"):
return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
case line == "bluetoothctl devices":
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
case strings.HasSuffix(line, "EE:01"):
return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
case strings.HasSuffix(line, "EE:02"):
return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
case strings.HasSuffix(line, "EE:03"):
return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
}
return ok(headphones(false))
})
got, err := Scan(8)
found := got["found"].([]Device)
if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
t.Fatalf("%+v %v", got, err)
}
if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
}
}
func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.Contains(line, " pair ") {
return ok("Pairing successful\n")
}
return ok(headphones(false))
})
if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
t.Fatal(err)
}
if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
t.Errorf("%v", l)
}
}
func TestAnAddressIsSixHexPairs(t *testing.T) {
for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
if _, err := addressOf(map[string]any{"address": bad}); err == nil {
t.Errorf("%q accepted", bad)
}
}
if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
t.Errorf("%s %v", a, err)
}
}
@@ -0,0 +1,352 @@
package main
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
// than shared; a change to one copy is made to all eight.
//
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
// started when it takes longer;
// - each stream is kept to 256 KiB, and the answer says when it was cut;
// - a failure is an error with what went wrong in it, never an empty answer.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds every command is held to.
const (
CallTimeout = 20 * time.Second
MostOutput = 256 << 10
)
// Cmd is one command a tool runs.
type Cmd struct {
Name string
Args []string
// Stdin is written to the command's standard input when not empty.
Stdin string
// Env is added to this process's own environment.
Env []string
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
Root bool
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
Timeout time.Duration
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
// selection: its streams go to files, because a pipe the child inherits would hold the call open
// until the child exits.
Detached bool
}
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int `json:"status"`
// Error is why it did not run to an answer: "not-found" when the program is not there,
// "timeout" when it was ended for taking too long, else the spawn error.
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// Runner runs a command. Tests replace it; nothing else does.
type Runner func(Cmd) Result
var (
run Runner = execRun
euid = os.Geteuid
)
// argv is the command as it is run: through sudo without a prompt when it needs root and this
// process is not root.
func argv(c Cmd) (string, []string) {
if c.Root && euid() != 0 {
return "sudo", append([]string{"-n", c.Name}, c.Args...)
}
return c.Name, c.Args
}
// bounded keeps the first MostOutput bytes written to it and notes that more came.
type bounded struct {
b bytes.Buffer
cut bool
}
func (w *bounded) Write(p []byte) (int, error) {
room := MostOutput - w.b.Len()
if room <= 0 {
w.cut = w.cut || len(p) > 0
return len(p), nil
}
if len(p) > room {
w.b.Write(p[:room])
w.cut = true
return len(p), nil
}
return w.b.Write(p)
}
func execRun(c Cmd) Result {
timeout := c.Timeout
if timeout <= 0 {
timeout = CallTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
name, args := argv(c)
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
if !c.Detached {
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
cmd.WaitDelay = 2 * time.Second
if c.Stdin != "" {
cmd.Stdin = strings.NewReader(c.Stdin)
}
var out, errs bounded
var outFile, errFile *os.File
if c.Detached {
var err error
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(outFile.Name())
defer outFile.Close()
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(errFile.Name())
defer errFile.Close()
cmd.Stdout, cmd.Stderr = outFile, errFile
} else {
cmd.Stdout, cmd.Stderr = &out, &errs
}
err := cmd.Run()
if c.Detached {
for _, f := range []struct {
file *os.File
into *bounded
}{{outFile, &out}, {errFile, &errs}} {
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
_, _ = io.Copy(f.into, f.file)
}
}
}
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
r.Status, r.Error = 124, "timeout"
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
r.Status, r.Error = 127, "not-found"
case errors.As(err, &exit):
r.Status = exit.ExitCode()
default:
r.Status, r.Error = 127, err.Error()
}
return r
}
// call runs a command and answers its result, or an error naming what went wrong.
func call(c Cmd) (Result, error) {
r := run(c)
if r.Status == 0 && r.Error == "" {
return r, nil
}
return r, failure(c, r)
}
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
// status with the end of what it said.
func failure(c Cmd, r Result) error {
program, _ := argv(c)
switch {
case r.Error == "not-found" && program == "sudo":
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
case r.Error == "not-found":
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case r.Error == "timeout":
limit := c.Timeout
if limit <= 0 {
limit = CallTimeout
}
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
case r.Error != "":
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
c.Name, firstLine(r.Stderr))
}
said := tail(strings.TrimSpace(r.Stderr), 2000)
if said == "" {
said = tail(strings.TrimSpace(r.Stdout), 2000)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
// lines are a command's output lines, blank ones dropped.
func lines(s string) []string {
out := []string{}
for _, l := range strings.Split(s, "\n") {
if strings.TrimSpace(l) != "" {
out = append(out, strings.TrimRight(l, "\r"))
}
}
return out
}
// Arguments, read the way a tool's JSON arguments arrive.
func text(args map[string]any, key string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return "", fmt.Errorf("%s is required", key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return "", fmt.Errorf("%s must not be empty", key)
}
return s, nil
}
func optText(args map[string]any, key, def string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return def, nil
}
return s, nil
}
// optWhole reads a whole number, defaulted, refused below least and held to most.
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s must be a number", key)
}
}
if f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
func optFlag(args map[string]any, key string, def bool) (bool, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
func optList(args map[string]any, key string) ([]string, error) {
v, ok := args[key]
if !ok || v == nil {
return nil, nil
}
items, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("%s must be a list of strings", key)
}
out := make([]string, 0, len(items))
for _, it := range items {
s, ok := it.(string)
if !ok || strings.TrimSpace(s) == "" {
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
}
out = append(out, s)
}
return out, nil
}
// oneOf refuses a value outside a closed set.
func oneOf(key, value string, allowed ...string) error {
for _, a := range allowed {
if value == a {
return nil
}
}
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
}
// plainName refuses a name that could be read as an option or carries a path or a space: package,
// snap, application and printer names never do.
func plainName(key, value string) error {
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
return fmt.Errorf("%s %q is not a plain name", key, value)
}
return nil
}
@@ -0,0 +1,147 @@
package main
// Tests of kit.go, the same in each workstation module.
import (
"strings"
"testing"
"time"
)
// fake records the commands asked and answers each from a function of the command line.
type fake struct {
asked []Cmd
answer func(line string, c Cmd) Result
}
func (f *fake) runner() Runner {
return func(c Cmd) Result {
f.asked = append(f.asked, c)
name, args := argv(c)
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
if f.answer == nil {
return Result{}
}
return f.answer(line, c)
}
}
func (f *fake) lines() []string {
out := []string{}
for _, c := range f.asked {
name, args := argv(c)
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
}
return out
}
// using installs a fake runner and a non-root uid for one test.
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
t.Helper()
f := &fake{answer: answer}
wasRun, wasUID := run, euid
run, euid = f.runner(), func() int { return 1000 }
t.Cleanup(func() { run, euid = wasRun, wasUID })
return f
}
func ok(stdout string) Result { return Result{Stdout: stdout} }
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
t.Fatalf("not root: %s %v", name, args)
}
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
t.Fatalf("a read is run as the account: %s", name)
}
euid = func() int { return 0 }
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
t.Fatalf("as root no sudo: %s", name)
}
}
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
cases := []struct {
c Cmd
r Result
want string
}{
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
}
for _, k := range cases {
err := failure(k.c, k.r)
if err == nil || !strings.Contains(err.Error(), k.want) {
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
}
}
}
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
var w bounded
big := strings.Repeat("a", MostOutput+10)
n, _ := w.Write([]byte(big))
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
}
}
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("%+v", r)
}
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
if r.Error != "timeout" {
t.Fatalf("a slow command: %+v", r)
}
r = execRun(Cmd{Name: "no-such-program-anywhere"})
if r.Error != "not-found" {
t.Fatalf("a missing program: %+v", r)
}
r = execRun(Cmd{Name: "cat", Stdin: "given"})
if r.Stdout != "given" {
t.Fatalf("stdin: %+v", r)
}
start := time.Now()
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
}
}
func TestKitArgumentsAreReadStrictly(t *testing.T) {
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if _, err := text(args, "missing"); err == nil {
t.Error("a missing required string")
}
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
t.Errorf("held to most: %d", n)
}
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
t.Error("below least")
}
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
t.Error("a fraction")
}
if l, _ := optList(args, "l"); len(l) != 2 {
t.Errorf("list: %v", l)
}
if b, _ := optFlag(args, "b", false); !b {
t.Error("flag")
}
if err := plainName("name", "--all"); err == nil {
t.Error("an option as a name")
}
}
@@ -0,0 +1,135 @@
// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
// account, and speaks to bluez through bluetoothctl.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var providedBy = map[string]string{
"bluetoothctl": "the bluez-utils package, which this module installs",
}
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
a, err := addressOf(args)
if err != nil {
return nil, err
}
return f(a)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "bluetooth_controller",
Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Controller() },
},
{
Name: "bluetooth_power",
Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
Run: func(args map[string]any) (any, error) {
if _, given := args["on"]; !given {
c, err := Controller()
if err != nil {
return nil, err
}
return map[string]any{"powered": c.Powered}, nil
}
on, err := optFlag(args, "on", true)
if err != nil {
return nil, err
}
return Power(on)
},
},
{
Name: "bluetooth_devices",
Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
"name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
Run: func(args map[string]any) (any, error) {
which, err := optText(args, "which", "all")
if err != nil {
return nil, err
}
return Devices(which)
},
},
{
Name: "bluetooth_scan",
Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
"paired, with their signal strength. (r)",
Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
Run: func(args map[string]any) (any, error) {
s, err := optWhole(args, "seconds", 8, 1, 15)
if err != nil {
return nil, err
}
return Scan(s)
},
},
{
Name: "bluetooth_connect",
Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
},
{
Name: "bluetooth_disconnect",
Description: "Disconnect a device. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
},
{
Name: "bluetooth_trust",
Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
Run: func(args map[string]any) (any, error) {
a, err := addressOf(args)
if err != nil {
return nil, err
}
trusted, err := optFlag(args, "trusted", true)
if err != nil {
return nil, err
}
if trusted {
return Act("trust", a)
}
return Act("untrust", a)
},
},
{
Name: "bluetooth_pair",
Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
"confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Pair(a) }),
},
{
Name: "bluetooth_remove",
Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
},
}
}
@@ -0,0 +1,107 @@
package main
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
// definition so the module's own tests can hold it to what it says.
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
return nil
}
// packages are the packages the module installs, sorted.
func (m manifest) packages() []string {
out := []string{}
for _, r := range m.Resources {
if r["type"] == "package" && r["absent"] != true {
out = append(out, r["package"].(string))
}
}
sort.Strings(out)
return out
}
// services are the units the module declares, by unit name.
func (m manifest) services() map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
if r["type"] == "service" {
out[r["unit"].(string)] = r
}
}
return out
}
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
t.Helper()
registered := []string{}
for _, tool := range tools() {
registered = append(registered, tool.Name)
if !strings.HasPrefix(tool.Name, prefix+"_") {
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
}
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
}
}
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
t.Errorf("registered %v, listed %v", registered, m.Tools)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
}
cwd, _ := os.Getwd()
binary := filepath.Base(cwd)
a := m.Build.Artifacts[0]
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
for k, v := range want {
if a[k] != v {
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
}
}
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
}
if m.Claims != nil || m.Seats != nil {
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
}
}
+5
View File
@@ -0,0 +1,5 @@
module bluetooth
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+53
View File
@@ -0,0 +1,53 @@
{
"module": "bluetooth",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"tools": [
"bluetooth_controller",
"bluetooth_power",
"bluetooth_devices",
"bluetooth_scan",
"bluetooth_connect",
"bluetooth_disconnect",
"bluetooth_trust",
"bluetooth_pair",
"bluetooth_remove"
],
"resources": [
{
"id": "stack",
"type": "package",
"package": "bluez"
},
{
"id": "utilities",
"type": "package",
"package": "bluez-utils"
},
{
"id": "daemon",
"type": "service",
"unit": "bluetooth.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/bluetooth-tools",
"binary": "bluetooth-tools",
"loads": [
"bluetooth-tools"
]
}
]
}
}