btpeerd: Implement DeviceInfo and related tooling

Implements the simplest BtpeerManagementService, DeviceInfo,
which runs commands on the device to collect system info. To support
this, an exec runner lib has been added along with utils that use
it. Unit tests have been added for utils that run commands and
parse their output.

BUG=b:331246657
TEST=scripts/build.sh

Change-Id: Idb9999030c5e0fb76e990b644ad68d432794ab4c
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/btpeerd/+/5492527
Reviewed-by: Jason Stanko <jstanko@google.com>
Commit-Queue: Jared Bennett <jaredbennett@google.com>
Tested-by: Jared Bennett <jaredbennett@google.com>
diff --git a/go/src/bluetooth/chameleond/chameleond.go b/go/src/bluetooth/chameleond/chameleond.go
new file mode 100644
index 0000000..5c6271b
--- /dev/null
+++ b/go/src/bluetooth/chameleond/chameleond.go
@@ -0,0 +1,27 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package chameleond
+
+import (
+	"context"
+	"fmt"
+	"path"
+	"strings"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+// installDir is where the chameleond bundle, which is effectively a snapshot
+// of the chameleon repository, is installed on custom btpeer images.
+const installDir = "/etc/chromiumos/src/platform/chameleon"
+
+func Commit(ctx context.Context, runner exec.CmdRunner) (string, error) {
+	commitFilePath := path.Join(installDir, "/dist/commit")
+	commitFileContents, err := runner.Output(ctx, exec.DefaultTimeout, "cat", commitFilePath)
+	if err != nil {
+		return "", fmt.Errorf("commit: failed to read chameleond commit file at %q: %w", commitFilePath, err)
+	}
+	return strings.TrimSpace(string(commitFileContents)), nil
+}
diff --git a/go/src/bluetooth/chameleond/doc.go b/go/src/bluetooth/chameleond/doc.go
new file mode 100644
index 0000000..8bb35e5
--- /dev/null
+++ b/go/src/bluetooth/chameleond/doc.go
@@ -0,0 +1,7 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package chameleond provides utilities specific to the chameleond bluetooth
+// stack API.
+package chameleond
diff --git a/go/src/bluetooth/doc.go b/go/src/bluetooth/doc.go
new file mode 100644
index 0000000..55f9591
--- /dev/null
+++ b/go/src/bluetooth/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package bluetooth provides bluetooth management functionality.
+package bluetooth
diff --git a/go/src/core/core.go b/go/src/core/core.go
new file mode 100644
index 0000000..956fcce
--- /dev/null
+++ b/go/src/core/core.go
@@ -0,0 +1,26 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package core
+
+import (
+	"context"
+	"fmt"
+	"path"
+	"strings"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+// installDir is where btpeerd is installed on the btpeer.
+const installDir = "/etc/chromiumos/src/platform/btpeerd"
+
+func BtpeerdCommit(ctx context.Context, runner exec.CmdRunner) (string, error) {
+	commitFilePath := path.Join(installDir, "/COMMIT")
+	commitFileContents, err := runner.Output(ctx, exec.DefaultTimeout, "cat", commitFilePath)
+	if err != nil {
+		return "", fmt.Errorf("btpeerd commit: failed to read btpeerd commit file at %q: %w", commitFilePath, err)
+	}
+	return strings.TrimSpace(string(commitFileContents)), nil
+}
diff --git a/go/src/core/doc.go b/go/src/core/doc.go
new file mode 100644
index 0000000..e037fc2
--- /dev/null
+++ b/go/src/core/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package core provides the base functionality of btpeerd.
+package core
diff --git a/go/src/core/exec/doc.go b/go/src/core/exec/doc.go
new file mode 100644
index 0000000..0f1814d
--- /dev/null
+++ b/go/src/core/exec/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package exec provides utilities for running system commands.
+package exec
diff --git a/go/src/core/exec/mock/doc.go b/go/src/core/exec/mock/doc.go
new file mode 100644
index 0000000..4fd7194
--- /dev/null
+++ b/go/src/core/exec/mock/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package mock provides test mock implementations for the exec package.
+package mock
diff --git a/go/src/core/exec/mock/runner.go b/go/src/core/exec/mock/runner.go
new file mode 100644
index 0000000..a9b82c9
--- /dev/null
+++ b/go/src/core/exec/mock/runner.go
@@ -0,0 +1,41 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package mock
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+// CmdRunner mocks exec.CmdRunner for use in unit tests.
+type CmdRunner struct {
+	mockResult *exec.RunResult
+	mockErr    error
+}
+
+// NewCmdRunner creates a new CmdRunner with the provided mock results and err.
+func NewCmdRunner(mockResult *exec.RunResult, mockError error) exec.CmdRunner {
+	return &CmdRunner{
+		mockResult: mockResult,
+		mockErr:    mockError,
+	}
+}
+
+// Run simply returns the mock error.
+func (m CmdRunner) Run(ctx context.Context, timeout time.Duration, name string, args ...string) error {
+	return m.mockErr
+}
+
+// Output returns the stdout of the mock result and the mock error.
+func (m CmdRunner) Output(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
+	return m.mockResult.Stdout, m.mockErr
+}
+
+// RunForResult returns the mock result and mock error.
+func (m CmdRunner) RunForResult(ctx context.Context, timeout time.Duration, name string, args ...string) (*exec.RunResult, error) {
+	return m.mockResult, m.mockErr
+}
diff --git a/go/src/core/exec/runner.go b/go/src/core/exec/runner.go
new file mode 100644
index 0000000..f712af0
--- /dev/null
+++ b/go/src/core/exec/runner.go
@@ -0,0 +1,129 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package exec
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"log/slog"
+	"os/exec"
+	"time"
+)
+
+// DefaultTimeout can be used as the timeout for most quick command runs.
+const DefaultTimeout = 10 * time.Second
+
+// RunResult provides results from a CmdRunner run.
+type RunResult struct {
+	// Command is the full command that was run to produce this result.
+	Command string
+
+	// Stdout is the stdout produced by running the command.
+	Stdout []byte
+
+	// Stderr is the stderr produced by running the command.
+	Stderr []byte
+
+	// ExitCode is the exit code the command returned.
+	ExitCode int
+}
+
+// String returns the RunResult as a string for logging purposes.
+func (r *RunResult) String() string {
+	return fmt.Sprintf(
+		"RunResult{Command=%q ExitCode=%d Stdout=%q Stderr=%q}",
+		r.Command,
+		r.ExitCode,
+		string(r.Stdout),
+		string(r.Stderr),
+	)
+}
+
+// LogValue implements implementing slog.LogValuer.
+func (r *RunResult) LogValue() slog.Value {
+	return slog.StringValue(r.String())
+}
+
+// CmdRunner is an interface for running commands.
+type CmdRunner interface {
+	// Run will run the command with the specified name and optional arguments.
+	//
+	// Returns when the run is complete or the timeout is reached.
+	// Returns a non-nil error if the run was unsuccessful.
+	Run(ctx context.Context, timeout time.Duration, name string, args ...string) error
+
+	// Output will run the command with the specified name and optional arguments
+	// and then return the output of the command.
+	//
+	// Returns when the run is complete or the timeout is reached.
+	// Returns a non-nil error if the run was unsuccessful.
+	Output(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error)
+
+	// RunForResult will run the command with the specified name and optional
+	// arguments and then return the RunResult of the command for further
+	// evaluation by the user.
+	//
+	// Returns when the run is complete or the timeout is reached.
+	// Returns a non-nil error if the command failed to complete its execution.
+	// Does not return a non-nil error for non-zero exit codes.
+	RunForResult(ctx context.Context, timeout time.Duration, name string, args ...string) (*RunResult, error)
+}
+
+// SystemCmdRunner implements CmdRunner and will run commands on the local system.
+type SystemCmdRunner struct {
+}
+
+func (r *SystemCmdRunner) Run(ctx context.Context, timeout time.Duration, name string, args ...string) error {
+	_, err := r.Output(ctx, timeout, name, args...)
+	return err
+}
+
+func (r *SystemCmdRunner) Output(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
+	result, err := r.RunForResult(ctx, timeout, name, args...)
+	if err != nil {
+		return result.Stdout, err
+	}
+	if result.ExitCode != 0 {
+		return nil, fmt.Errorf(
+			"failed to run command %q: command returned non-zero exit code %d: %s",
+			result.Command,
+			result.ExitCode,
+			result,
+		)
+	}
+	return result.Stdout, nil
+}
+
+func (r *SystemCmdRunner) RunForResult(ctx context.Context, timeout time.Duration, name string, args ...string) (*RunResult, error) {
+	ctx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, name, args...)
+	result := &RunResult{
+		Command:  cmd.String(),
+		ExitCode: -1,
+	}
+	stderrBuffer := bytes.Buffer{}
+	cmd.Stderr = &stderrBuffer
+	slog.Info("Running system command", "cmd", result.Command)
+	stdout, runErr := cmd.Output()
+	result.Stdout = stdout
+	result.Stderr = stderrBuffer.Bytes()
+	if runErr != nil {
+		if exitErr, ok := runErr.(*exec.ExitError); ok {
+			// Normal exit error, extract exit code.
+			result.ExitCode = exitErr.ExitCode()
+			slog.Info("Successfully ran system command", "result", result)
+			return result, nil
+		}
+		// Abnormal exit error or timout reached.
+		slog.Warn("Failed to run system command", "result", result, "err", runErr)
+		return result, fmt.Errorf("failed to run command %s: %w", result, runErr)
+	}
+	// Successful execution, assume zero exit code.
+	result.ExitCode = 0
+	slog.Info("Successfully ran system command", "result", result)
+	return result, nil
+}
diff --git a/go/src/core/linux/doc.go b/go/src/core/linux/doc.go
new file mode 100644
index 0000000..3116570
--- /dev/null
+++ b/go/src/core/linux/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package linux provides system utilities for debian-based linux systems.
+package linux
diff --git a/go/src/core/linux/linux.go b/go/src/core/linux/linux.go
new file mode 100644
index 0000000..bee1d88
--- /dev/null
+++ b/go/src/core/linux/linux.go
@@ -0,0 +1,22 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package linux
+
+import (
+	"context"
+	"fmt"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+// PackageVersion returns the version of system package installed on the device
+// using the dpkg-query command.
+func PackageVersion(ctx context.Context, runner exec.CmdRunner, packageName string) (string, error) {
+	version, err := runner.Output(ctx, exec.DefaultTimeout, "dpkg-query", "-Wf", `'${Version}\n'`, packageName)
+	if err != nil {
+		return "", fmt.Errorf("package version: failed to get version of system package %q with dpkg-query: %w", packageName, err)
+	}
+	return string(version), nil
+}
diff --git a/go/src/core/linux/networking.go b/go/src/core/linux/networking.go
new file mode 100644
index 0000000..0696ad8
--- /dev/null
+++ b/go/src/core/linux/networking.go
@@ -0,0 +1,39 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package linux
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+// EthernetMacAddress runs "cat /sys/class/net/<networkInterfaceName>/address"
+// and returns the output, which should be the mac address of the interface.
+func EthernetMacAddress(ctx context.Context, runner exec.CmdRunner, networkInterfaceName string) (string, error) {
+	addressFilePath := fmt.Sprintf("/sys/class/net/%s/address", networkInterfaceName)
+	addressFileContents, err := runner.Output(ctx, exec.DefaultTimeout, "cat", addressFilePath)
+	if err != nil {
+		return "", fmt.Errorf("ethernet mac address: %w", err)
+	}
+	return string(addressFileContents), nil
+}
+
+// IPv4Address runs "ifconfig <networkInterfaceName>" and returns the first IPv4
+// address found in the output.
+func IPv4Address(ctx context.Context, runner exec.CmdRunner, networkInterfaceName string) (string, error) {
+	ifconfigStdout, err := runner.Output(ctx, exec.DefaultTimeout, "ifconfig", networkInterfaceName)
+	if err != nil {
+		return "", fmt.Errorf("ipv4 address: %w", err)
+	}
+	matcher := regexp.MustCompile(`inet (\d+\.\d+\.\d+\.\d+)`)
+	match := matcher.FindStringSubmatch(string(ifconfigStdout))
+	if len(match) != 2 {
+		return "", fmt.Errorf("ipv4 address: failed parse ipv4 address from ifconfig output %q", string(ifconfigStdout))
+	}
+	return match[1], nil
+}
diff --git a/go/src/core/linux/networking_test.go b/go/src/core/linux/networking_test.go
new file mode 100644
index 0000000..421bb86
--- /dev/null
+++ b/go/src/core/linux/networking_test.go
@@ -0,0 +1,67 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package linux
+
+import (
+	"context"
+	"testing"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec/mock"
+)
+
+func TestIPv4Address(t *testing.T) {
+	type args struct {
+		ifconfigStdout       string
+		networkInterfaceName string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		wantErr bool
+	}{
+		{
+			"empty output",
+			args{
+				ifconfigStdout:       "",
+				networkInterfaceName: "eth0",
+			},
+			"",
+			true,
+		},
+		{
+			"valid",
+			args{
+				ifconfigStdout: `eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
+inet 100.71.224.110  netmask 255.255.240.0  broadcast 100.71.239.255
+inet6 fe80::dea6:32ff:fe86:b585  prefixlen 64  scopeid 0x20<link>
+ether dc:a6:32:86:b5:85  txqueuelen 1000  (Ethernet)
+RX packets 161260331  bytes 1558837028 (1.4 GiB)
+RX errors 1049445  dropped 1049445  overruns 0  frame 0
+TX packets 93333  bytes 40883200 (38.9 MiB)
+TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
+
+`,
+				networkInterfaceName: "eth0",
+			},
+			"100.71.224.110",
+			false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mockRunner := mock.NewCmdRunner(&exec.RunResult{Stdout: []byte(tt.args.ifconfigStdout)}, nil)
+			got, err := IPv4Address(context.Background(), mockRunner, tt.args.networkInterfaceName)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("IPv4Address() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("IPv4Address() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/go/src/core/raspberrypi/constants.go b/go/src/core/raspberrypi/constants.go
new file mode 100644
index 0000000..544bae0
--- /dev/null
+++ b/go/src/core/raspberrypi/constants.go
@@ -0,0 +1,9 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package raspberrypi
+
+// EthernetPortInterface is the network interface name of the single ethernet
+// port on the pi.
+const EthernetPortInterface = "eth0"
diff --git a/go/src/core/raspberrypi/doc.go b/go/src/core/raspberrypi/doc.go
new file mode 100644
index 0000000..bbd12e8
--- /dev/null
+++ b/go/src/core/raspberrypi/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package raspberrypi provides utilities specific for Raspberry Pis.
+package raspberrypi
diff --git a/go/src/core/raspberrypi/raspberrypi.go b/go/src/core/raspberrypi/raspberrypi.go
new file mode 100644
index 0000000..345f54a
--- /dev/null
+++ b/go/src/core/raspberrypi/raspberrypi.go
@@ -0,0 +1,30 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package raspberrypi
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+)
+
+const osReleaseFilePath = "/etc/os-release"
+
+// OSVersion returns the version of the Raspberry Pi OS as specified in the
+// "/etc/os-release" file as the PRETTY_NAME.
+func OSVersion(ctx context.Context, runner exec.CmdRunner) (string, error) {
+	osReleaseFileContents, err := runner.Output(ctx, exec.DefaultTimeout, "cat", osReleaseFilePath)
+	if err != nil {
+		return "", fmt.Errorf("os version: failed to read contents of file %q: %w", osReleaseFilePath, err)
+	}
+	matcher := regexp.MustCompile(`PRETTY_NAME="(.+)"\n`)
+	match := matcher.FindStringSubmatch(string(osReleaseFileContents))
+	if len(match) != 2 {
+		return "", fmt.Errorf("os version: failed parse PRETTY_NAME from %q file contents %q", osReleaseFilePath, string(osReleaseFileContents))
+	}
+	return match[1], nil
+}
diff --git a/go/src/core/raspberrypi/raspberrypi_test.go b/go/src/core/raspberrypi/raspberrypi_test.go
new file mode 100644
index 0000000..316fd8e
--- /dev/null
+++ b/go/src/core/raspberrypi/raspberrypi_test.go
@@ -0,0 +1,66 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package raspberrypi
+
+import (
+	"context"
+	"testing"
+
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec/mock"
+)
+
+func TestOSVersion(t *testing.T) {
+	type args struct {
+		osReleaseFileContents string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		wantErr bool
+	}{
+		{
+			"empty output",
+			args{
+				osReleaseFileContents: "",
+			},
+			"",
+			true,
+		},
+		{
+			"valid",
+			args{
+				osReleaseFileContents: `PRETTY_NAME="Raspbian GNU/Linux 10 (buster)"
+NAME="Raspbian GNU/Linux"
+VERSION_ID="10"
+VERSION="10 (buster)"
+VERSION_CODENAME=buster
+ID=raspbian
+ID_LIKE=debian
+HOME_URL="http://www.raspbian.org/"
+SUPPORT_URL="http://www.raspbian.org/RaspbianForums"
+BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs"
+
+`,
+			},
+			"Raspbian GNU/Linux 10 (buster)",
+			false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mockRunner := mock.NewCmdRunner(&exec.RunResult{Stdout: []byte(tt.args.osReleaseFileContents)}, nil)
+			got, err := OSVersion(context.Background(), mockRunner)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("OSVersion() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("OSVersion() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/go/src/server/btpeer_management_service.go b/go/src/server/btpeer_management_service.go
index 7950ff8..e7d1166 100644
--- a/go/src/server/btpeer_management_service.go
+++ b/go/src/server/btpeer_management_service.go
@@ -6,19 +6,66 @@
 
 import (
 	"context"
+	"fmt"
 
 	"go.chromium.org/chromiumos/config/go/test/lab/api/btpeerd"
+	"go.chromium.org/chromiumos/platform/btpeerd/bluetooth/chameleond"
+	"go.chromium.org/chromiumos/platform/btpeerd/core"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/linux"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/raspberrypi"
 )
 
-type BtpeerManagementServiceServer struct{}
+type BtpeerManagementServiceServer struct {
+	runner exec.CmdRunner
+}
 
-func NewBtpeerManagementServiceServer() *BtpeerManagementServiceServer {
-	return &BtpeerManagementServiceServer{}
+func NewBtpeerManagementServiceServer(runner exec.CmdRunner) *BtpeerManagementServiceServer {
+	return &BtpeerManagementServiceServer{
+		runner: runner,
+	}
 }
 
 func (s *BtpeerManagementServiceServer) DeviceInfo(ctx context.Context, request *btpeerd.DeviceInfoRequest) (*btpeerd.DeviceInfoResponse, error) {
-	//TODO implement me
-	panic("implement me")
+	resp := &btpeerd.DeviceInfoResponse{}
+
+	ethMac, err := linux.EthernetMacAddress(ctx, s.runner, raspberrypi.EthernetPortInterface)
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get device ethernet mac address: %w", err)
+	}
+	resp.MacEth0 = ethMac
+
+	addr, err := linux.IPv4Address(ctx, s.runner, raspberrypi.EthernetPortInterface)
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get device ethernet ipv4 address: %w", err)
+	}
+	resp.Ipv4Address = addr
+
+	osVersion, err := raspberrypi.OSVersion(ctx, s.runner)
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get os version: %w", err)
+	}
+	resp.OsVersion = osVersion
+
+	chameleondCommit, err := chameleond.Commit(ctx, s.runner)
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get chameleond commit: %w", err)
+	}
+	resp.ChameleondCommit = chameleondCommit
+
+	bluezVersion, err := linux.PackageVersion(ctx, s.runner, "bluez")
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get bluez version: %w", err)
+	}
+	resp.BluezVersion = bluezVersion
+
+	btpeerdCommit, err := core.BtpeerdCommit(ctx, s.runner)
+	if err != nil {
+		return nil, fmt.Errorf("device info: failed to get btpeerd commit: %w", err)
+	}
+	resp.BtpeerdCommit = btpeerdCommit
+
+	return resp, nil
 }
 
 func (s *BtpeerManagementServiceServer) DeviceStatus(ctx context.Context, request *btpeerd.DeviceStatusRequest) (*btpeerd.DeviceStatusResponse, error) {
diff --git a/go/src/server/server.go b/go/src/server/server.go
index be09ffd..5f9b031 100644
--- a/go/src/server/server.go
+++ b/go/src/server/server.go
@@ -13,6 +13,7 @@
 	"net"
 
 	"go.chromium.org/chromiumos/config/go/test/lab/api/btpeerd"
+	"go.chromium.org/chromiumos/platform/btpeerd/core/exec"
 	"google.golang.org/grpc"
 )
 
@@ -23,7 +24,8 @@
 	// Configure server.
 	var serverOpts []grpc.ServerOption
 	server := grpc.NewServer(serverOpts...)
-	btpeerd.RegisterBtpeerManagementServiceServer(server, NewBtpeerManagementServiceServer())
+	runner := &exec.SystemCmdRunner{}
+	btpeerd.RegisterBtpeerManagementServiceServer(server, NewBtpeerManagementServiceServer(runner))
 
 	// Start server, stopping it if the ctx expires.
 	listenAddr := fmt.Sprintf("localhost:%d", port)