Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions adapter_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package bluetooth

import (
"errors"
"syscall/js"
)

var _ BLEAdapter = (*Adapter)(nil)

// Adapter represents the WebBluetooth adapter accessed via navigator.bluetooth.
type Adapter struct {
bluetooth js.Value
connectHandler func(device Device, connected bool)

// RequestedServices is the list of service UUIDs that will be passed as
// optionalServices to navigator.bluetooth.requestDevice(). WebBluetooth
// requires services to be declared upfront — you cannot discover or
// access services that are not listed here or matched by a filter.
//
// Set this before calling Scan().
RequestedServices []UUID
}

// DefaultAdapter is the default adapter using the navigator.bluetooth API.
//
// Make sure to call Enable() before using it to initialize the adapter.
var DefaultAdapter = &Adapter{
connectHandler: func(device Device, connected bool) {},
}

// Enable configures the BLE stack. It must be called before any
// Bluetooth-related calls (unless otherwise indicated).
func (a *Adapter) Enable() error {
navigator := js.Global().Get("navigator")
if navigator.IsUndefined() {
return errors.New("bluetooth: navigator is not available")
}
bt := navigator.Get("bluetooth")
if bt.IsUndefined() {
return errors.New("bluetooth: WebBluetooth is not supported in this browser")
}
a.bluetooth = bt
return nil
}
2 changes: 2 additions & 0 deletions examples/webbluetooth/html/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
wasm_exec.js
wasm.wasm
38 changes: 38 additions & 0 deletions examples/webbluetooth/html/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebBluetooth - TinyGo Device Information</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
button { font-size: 1.1rem; padding: 0.5rem 1.5rem; cursor: pointer; }
#log { background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 1rem; margin-top: 1rem; min-height: 120px; }
#log p { margin: 0.25rem 0; font-family: monospace; font-size: 0.9rem; }
</style>
</head>
<body>
<h1>WebBluetooth - TinyGo Device Information</h1>
<p>Click the button below to connect to a BLE device and read its Device Information service (manufacturer, model, firmware).</p>
<button id="connectBtn" disabled>Loading WASM…</button>
<div id="log"></div>

<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("wasm.wasm"), go.importObject).then((result) => {
go.run(result.instance);
const btn = document.getElementById("connectBtn");
btn.textContent = "Connect";
btn.disabled = false;
btn.addEventListener("click", () => {
if (typeof btConnect === "function") {
btConnect();
} else {
alert("WASM not ready yet");
}
});
});
</script>
</body>
</html>
152 changes: 152 additions & 0 deletions examples/webbluetooth/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// This example demonstrates using the Web Bluetooth API via TinyGo WASM.
// It opens the browser's Bluetooth device picker, connects to any BLE
// peripheral, and reads the Device Information service (manufacturer name,
// model number, firmware revision).
//
// To build:
//
// tinygo build -o ./examples/webbluetooth/html/wasm.wasm -target wasm ./examples/webbluetooth/
//
// Then serve the html directory over HTTPS (or localhost). A minimal server is
// included:
//
// go run ./examples/webbluetooth/server/
//
// Open http://localhost:8080 in Chrome/Edge, click the "Connect" button and
// pick a Bluetooth device from the browser dialog.
package main

import (
"syscall/js"

"tinygo.org/x/bluetooth"
)

var adapter = bluetooth.DefaultAdapter

var (
deviceInfoServiceUUID = bluetooth.ServiceUUIDDeviceInformation

manufacturerNameUUID = bluetooth.CharacteristicUUIDManufacturerNameString
modelNumberUUID = bluetooth.CharacteristicUUIDModelNumberString
firmwareRevisionUUID = bluetooth.CharacteristicUUIDFirmwareRevisionString
)

func main() {
// Export a function that the HTML page calls when the user clicks Connect.
js.Global().Set("btConnect", js.FuncOf(func(this js.Value, args []js.Value) any {
go run()
return nil
}))

logMsg("WebBluetooth example loaded. Click 'Connect' to start.")

// Keep the Go program alive.
select {}
}

func run() {
logMsg("Enabling BLE adapter...")
must("enable BLE stack", adapter.Enable())

// WebBluetooth requires listing the services you want to access upfront.
adapter.RequestedServices = []bluetooth.UUID{
deviceInfoServiceUUID,
}

logMsg("Opening device picker...")

var result bluetooth.ScanResult
err := adapter.Scan(func(a *bluetooth.Adapter, r bluetooth.ScanResult) {
result = r
a.StopScan()
})
must("scan", err)

logMsg("Selected device: " + result.LocalName() + " (" + result.Address.String() + ")")

// Connection can fail if the device is asleep or out of range. Retry a
// few times before giving up.
var device bluetooth.Device
const maxRetries = 3
for attempt := 1; attempt <= maxRetries; attempt++ {
logMsg("Connecting (attempt " + itoa(attempt) + "/" + itoa(maxRetries) + ")...")
device, err = adapter.Connect(result.Address, bluetooth.ConnectionParams{})
if err == nil {
break
}
logMsg("Connection failed: " + err.Error())
if attempt == maxRetries {
logMsg("Could not connect after " + itoa(maxRetries) + " attempts. Make sure the device is awake and in range, then click Connect again.")
return
}
}
logMsg("Connected!")

logMsg("Discovering Device Information service...")
srvcs, err := device.DiscoverServices([]bluetooth.UUID{deviceInfoServiceUUID})
must("discover services", err)

srvc := srvcs[0]
logMsg("Found service: " + srvc.UUID().String())

// Read each Device Information characteristic. Not all devices expose
// every characteristic, so we read them individually and tolerate errors.
buf := make([]byte, 128)

for _, item := range []struct {
name string
uuid bluetooth.UUID
}{
{"Manufacturer Name", manufacturerNameUUID},
{"Model Number", modelNumberUUID},
{"Firmware Revision", firmwareRevisionUUID},
} {
chars, err := srvc.DiscoverCharacteristics([]bluetooth.UUID{item.uuid})
if err != nil {
logMsg(" " + item.name + ": not available")
continue
}
n, err := chars[0].Read(buf)
if err != nil {
logMsg(" " + item.name + ": read error: " + err.Error())
continue
}
logMsg(" " + item.name + ": " + string(buf[:n]))
}

logMsg("Disconnecting...")
device.Disconnect()
logMsg("Done.")
}

// logMsg writes a message to the browser console and appends it to the #log element.
func logMsg(msg string) {
js.Global().Get("console").Call("log", msg)
doc := js.Global().Get("document")
logEl := doc.Call("getElementById", "log")
if !logEl.IsNull() {
p := doc.Call("createElement", "p")
p.Set("textContent", msg)
logEl.Call("appendChild", p)
}
}

func must(action string, err error) {
if err != nil {
logMsg("FATAL: failed to " + action + ": " + err.Error())
panic("failed to " + action + ": " + err.Error())
}
}

func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
111 changes: 111 additions & 0 deletions examples/webbluetooth/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// A minimal HTTP server for the WebBluetooth example.
//
// Usage:
//
// go run ./examples/webbluetooth/server/
//
// Then open http://localhost:8080 in Chrome or Edge.
// WebBluetooth works on localhost without HTTPS.
package main

import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)

func main() {
dir := findHTMLDir()

ensureWASMExecJS(dir)
ensureWASM(dir)

addr := ":8080"
fmt.Printf("Serving %s on http://localhost%s\n", dir, addr)
log.Fatal(http.ListenAndServe(addr, http.FileServer(http.Dir(dir))))
}

// findHTMLDir locates the html/ directory relative to this file or cwd.
func findHTMLDir() string {
// Try relative to the working directory first.
candidates := []string{
"examples/webbluetooth/html",
"html",
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c
}
}
fmt.Fprintln(os.Stderr, "Could not find html/ directory. Run from the repo root or the example directory.")
os.Exit(1)
return ""
}

// ensureWASMExecJS copies the TinyGo wasm_exec.js support file into the html directory.
func ensureWASMExecJS(dir string) {
dst := filepath.Join(dir, "wasm_exec.js")
if _, err := os.Stat(dst); err == nil {
return // already exists
}

// Try TinyGo's wasm_exec.js first (targets/wasm_exec.js).
if tinygoRoot := os.Getenv("TINYGOROOT"); tinygoRoot != "" {
src := filepath.Join(tinygoRoot, "targets", "wasm_exec.js")
if copyFile(src, dst) == nil {
fmt.Println("Copied wasm_exec.js from TINYGOROOT")
return
}
}

// Try to find tinygo in PATH.
if tinygo, err := exec.LookPath("tinygo"); err == nil {
// tinygo env TINYGOROOT
out, err := exec.Command(tinygo, "env", "TINYGOROOT").Output()
if err == nil {
root := strings.TrimSpace(string(out))
src := filepath.Join(root, "targets", "wasm_exec.js")
if copyFile(src, dst) == nil {
fmt.Println("Copied wasm_exec.js from tinygo in PATH")
return
}
}
}

// Last resort: standard Go's wasm_exec.js. Note: this will NOT work
// with TinyGo-built WASM because it lacks WASI shims.
goroot := runtime.GOROOT()
src := filepath.Join(goroot, "lib", "wasm", "wasm_exec.js")
if copyFile(src, dst) == nil {
fmt.Println("WARNING: copied wasm_exec.js from GOROOT — this will NOT work with TinyGo WASM.")
fmt.Println("Install TinyGo or set TINYGOROOT to get the correct wasm_exec.js.")
return
}

fmt.Fprintln(os.Stderr, "Warning: could not find wasm_exec.js. Copy it manually into", dir)
fmt.Fprintln(os.Stderr, " cp $(tinygo env TINYGOROOT)/targets/wasm_exec.js", dst)
}

// ensureWASM checks that wasm.wasm exists, and prints a hint if not.
func ensureWASM(dir string) {
dst := filepath.Join(dir, "wasm.wasm")
if _, err := os.Stat(dst); err == nil {
return
}
fmt.Fprintln(os.Stderr, "Warning: wasm.wasm not found in", dir)
fmt.Fprintln(os.Stderr, "Build it with:")
fmt.Fprintln(os.Stderr, " tinygo build -o "+dst+" -target wasm ./examples/webbluetooth/")
}

func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
Loading
Loading