diff --git a/README.md b/README.md index 4ac00ceb..bb914ef7 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ func must(action string, err error) { ## Linux -Go Bluetooth support for Linux uses [BlueZ](http://www.bluez.org/) via the [D-Bus](https://en.wikipedia.org/wiki/D-Bus) interface thanks to the https://github.com/muka/go-bluetooth package. This should work with most distros that support BlueZ such as Ubuntu, Debian, Fedora, and Arch Linux, among others. +Go Bluetooth support for Linux uses [BlueZ](http://www.bluez.org/) via the [D-Bus](https://en.wikipedia.org/wiki/D-Bus) interface. This should work with most distros that support BlueZ such as Ubuntu, Debian, Fedora, and Arch Linux, among others. Linux can be used both as a BLE Central or as a BLE Peripheral. diff --git a/adapter_linux.go b/adapter_linux.go index 8ff92600..0ea9b452 100644 --- a/adapter_linux.go +++ b/adapter_linux.go @@ -7,15 +7,20 @@ package bluetooth import ( "errors" + "fmt" - "github.com/muka/go-bluetooth/api" - "github.com/muka/go-bluetooth/bluez/profile/adapter" + "github.com/godbus/dbus/v5" ) +const defaultAdapter = "hci0" + type Adapter struct { - adapter *adapter.Adapter1 id string - cancelChan chan struct{} + scanCancelChan chan struct{} + bus *dbus.Conn + bluez dbus.BusObject // object at / + adapter dbus.BusObject // object at /org/bluez/hciX + address string defaultAdvertisement *Advertisement connectHandler func(device Address, connected bool) @@ -26,29 +31,38 @@ type Adapter struct { // // Make sure to call Enable() before using it to initialize the adapter. var DefaultAdapter = &Adapter{ + id: defaultAdapter, connectHandler: func(device Address, connected bool) { - return }, } // Enable configures the BLE stack. It must be called before any // Bluetooth-related calls (unless otherwise indicated). func (a *Adapter) Enable() (err error) { - if a.id == "" { - a.adapter, err = api.GetDefaultAdapter() - if err != nil { - return + bus, err := dbus.SystemBus() + if err != nil { + return err + } + a.bus = bus + a.bluez = a.bus.Object("org.bluez", dbus.ObjectPath("/")) + a.adapter = a.bus.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+a.id)) + addr, err := a.adapter.GetProperty("org.bluez.Adapter1.Address") + if err != nil { + if err, ok := err.(dbus.Error); ok && err.Name == "org.freedesktop.DBus.Error.UnknownObject" { + return fmt.Errorf("bluetooth: adapter %s does not exist", a.adapter.Path()) } - a.id, err = a.adapter.GetAdapterID() + return fmt.Errorf("could not activate BlueZ adapter: %w", err) } + addr.Store(&a.address) + return nil } func (a *Adapter) Address() (MACAddress, error) { - if a.adapter == nil { + if a.address == "" { return MACAddress{}, errors.New("adapter not enabled") } - mac, err := ParseMAC(a.adapter.Properties.Address) + mac, err := ParseMAC(a.address) if err != nil { return MACAddress{}, err } diff --git a/gap_linux.go b/gap_linux.go index 358eed69..7bd2973e 100644 --- a/gap_linux.go +++ b/gap_linux.go @@ -3,18 +3,20 @@ package bluetooth import ( - "context" "errors" + "fmt" "strings" + "sync/atomic" "github.com/godbus/dbus/v5" - "github.com/muka/go-bluetooth/api" - "github.com/muka/go-bluetooth/bluez" - "github.com/muka/go-bluetooth/bluez/profile/advertising" - "github.com/muka/go-bluetooth/bluez/profile/device" + "github.com/godbus/dbus/v5/prop" ) var errAdvertisementNotStarted = errors.New("bluetooth: stop advertisement that was not started") +var errAdvertisementAlreadyStarted = errors.New("bluetooth: start advertisement that was already started") + +// Unique ID per advertisement (to generate a unique object path). +var advertisementID uint64 // Address contains a Bluetooth MAC address. type Address struct { @@ -23,10 +25,9 @@ type Address struct { // Advertisement encapsulates a single advertisement instance. type Advertisement struct { - adapter *Adapter - advertisement *api.Advertisement - properties *advertising.LEAdvertisement1Properties - cancel func() + adapter *Adapter + properties *prop.Properties + path dbus.ObjectPath } // DefaultAdvertisement returns the default advertisement instance but does not @@ -44,42 +45,70 @@ func (a *Adapter) DefaultAdvertisement() *Advertisement { // // On Linux with BlueZ, it is not possible to set the advertisement interval. func (a *Advertisement) Configure(options AdvertisementOptions) error { - if a.advertisement != nil { + if a.properties != nil { panic("todo: configure advertisement a second time") } - a.properties = &advertising.LEAdvertisement1Properties{ - Type: advertising.AdvertisementTypeBroadcast, - Timeout: 1<<16 - 1, - LocalName: options.LocalName, - ManufacturerData: options.ManufacturerData, - } + var serviceUUIDs []string for _, uuid := range options.ServiceUUIDs { - a.properties.ServiceUUIDs = append(a.properties.ServiceUUIDs, uuid.String()) + serviceUUIDs = append(serviceUUIDs, uuid.String()) + } + + // Build an org.bluez.LEAdvertisement1 object, to be exported over DBus. + id := atomic.AddUint64(&advertisementID, 1) + a.path = dbus.ObjectPath(fmt.Sprintf("/org/tinygo/bluetooth/advertisement%d", id)) + propsSpec := map[string]map[string]*prop.Prop{ + "org.bluez.LEAdvertisement1": { + "Type": {Value: "broadcast"}, + "ServiceUUIDs": {Value: serviceUUIDs}, + "ManufacturerData": {Value: options.ManufacturerData}, + "LocalName": {Value: options.LocalName}, + // The documentation states: + // > Timeout of the advertisement in seconds. This defines the + // > lifetime of the advertisement. + // however, the value 0 also works, and presumably means "no + // timeout". + "Timeout": {Value: uint16(0)}, + // TODO: MinInterval and MaxInterval (experimental as of BlueZ 5.71) + }, + } + props, err := prop.Export(a.adapter.bus, a.path, propsSpec) + if err != nil { + return err } + a.properties = props return nil } // Start advertisement. May only be called after it has been configured. func (a *Advertisement) Start() error { - if a.advertisement != nil { - panic("todo: start advertisement a second time") + // Register our advertisement object to start advertising. + err := a.adapter.adapter.Call("org.bluez.LEAdvertisingManager1.RegisterAdvertisement", 0, a.path, map[string]interface{}{}).Err + if err != nil { + if err, ok := err.(dbus.Error); ok && err.Name == "org.bluez.Error.AlreadyExists" { + return errAdvertisementAlreadyStarted + } + return fmt.Errorf("bluetooth: could not start advertisement: %w", err) } - cancel, err := api.ExposeAdvertisement(a.adapter.id, a.properties, uint32(a.properties.Timeout)) + + // Make us discoverable. + err = a.adapter.adapter.SetProperty("org.bluez.Adapter1.Discoverable", dbus.MakeVariant(true)) if err != nil { - return err + return fmt.Errorf("bluetooth: could not start advertisement: %w", err) } - a.cancel = cancel return nil } // Stop advertisement. May only be called after it has been started. func (a *Advertisement) Stop() error { - if a.cancel == nil { - return errAdvertisementNotStarted + err := a.adapter.adapter.Call("org.bluez.LEAdvertisingManager1.UnregisterAdvertisement", 0, a.path).Err + if err != nil { + if err, ok := err.(dbus.Error); ok && err.Name == "org.bluez.Error.DoesNotExist" { + return errAdvertisementNotStarted + } + return fmt.Errorf("bluetooth: could not stop advertisement: %w", err) } - a.cancel() return nil } @@ -92,7 +121,7 @@ func (a *Advertisement) Stop() error { // possible some events are missed and perhaps even possible that some events // are duplicated. func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error { - if a.cancelChan != nil { + if a.scanCancelChan != nil { return errScanning } @@ -100,58 +129,61 @@ func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error { // Detecting whether the scan is stopped can be done by doing a non-blocking // read from it. If it succeeds, the scan is stopped. cancelChan := make(chan struct{}) - a.cancelChan = cancelChan + a.scanCancelChan = cancelChan // This appears to be necessary to receive any BLE discovery results at all. - defer a.adapter.SetDiscoveryFilter(nil) - err := a.adapter.SetDiscoveryFilter(map[string]interface{}{ + defer a.adapter.Call("org.bluez.Adapter1.SetDiscoveryFilter", 0) + err := a.adapter.Call("org.bluez.Adapter1.SetDiscoveryFilter", 0, map[string]interface{}{ "Transport": "le", - }) - if err != nil { - return err - } - - bus, err := dbus.SystemBus() + }).Err if err != nil { return err } signal := make(chan *dbus.Signal) - bus.Signal(signal) - defer bus.RemoveSignal(signal) + a.bus.Signal(signal) + defer a.bus.RemoveSignal(signal) propertiesChangedMatchOptions := []dbus.MatchOption{dbus.WithMatchInterface("org.freedesktop.DBus.Properties")} - bus.AddMatchSignal(propertiesChangedMatchOptions...) - defer bus.RemoveMatchSignal(propertiesChangedMatchOptions...) + a.bus.AddMatchSignal(propertiesChangedMatchOptions...) + defer a.bus.RemoveMatchSignal(propertiesChangedMatchOptions...) newObjectMatchOptions := []dbus.MatchOption{dbus.WithMatchInterface("org.freedesktop.DBus.ObjectManager")} - bus.AddMatchSignal(newObjectMatchOptions...) - defer bus.RemoveMatchSignal(newObjectMatchOptions...) + a.bus.AddMatchSignal(newObjectMatchOptions...) + defer a.bus.RemoveMatchSignal(newObjectMatchOptions...) // Go through all connected devices and present the connected devices as // scan results. Also save the properties so that the full list of // properties is known on a PropertiesChanged signal. We can't present the // list of cached devices as scan results as devices may be cached for a // long time, long after they have moved out of range. - deviceList, err := a.adapter.GetDevices() + var deviceList map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err = a.bluez.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&deviceList) if err != nil { return err } - devices := make(map[dbus.ObjectPath]*device.Device1Properties) - for _, dev := range deviceList { - if dev.Properties.Connected { - callback(a, makeScanResult(dev.Properties)) + devices := make(map[dbus.ObjectPath]map[string]dbus.Variant) + for path, v := range deviceList { + device, ok := v["org.bluez.Device1"] + if !ok { + continue // not a device + } + if !strings.HasPrefix(string(path), string(a.adapter.Path())) { + continue // not part of our adapter + } + if device["Connected"].Value().(bool) { + callback(a, makeScanResult(device)) select { case <-cancelChan: return nil default: } } - devices[dev.Path()] = dev.Properties + devices[path] = device } // Instruct BlueZ to start discovering. - err = a.adapter.StartDiscovery() + err = a.adapter.Call("org.bluez.Adapter1.StartDiscovery", 0).Err if err != nil { return err } @@ -163,8 +195,7 @@ func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error { // StopScan is called). select { case <-cancelChan: - a.adapter.StopDiscovery() - return nil + return a.adapter.Call("org.bluez.Adapter1.StopDiscovery", 0).Err default: } @@ -180,35 +211,24 @@ func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error { if !ok { continue } - var props *device.Device1Properties - props, _ = props.FromDBusMap(rawprops) - devices[objectPath] = props - callback(a, makeScanResult(props)) + devices[objectPath] = rawprops + callback(a, makeScanResult(rawprops)) case "org.freedesktop.DBus.Properties.PropertiesChanged": interfaceName := sig.Body[0].(string) if interfaceName != "org.bluez.Device1" { continue } changes := sig.Body[1].(map[string]dbus.Variant) - props := devices[sig.Path] - for field, val := range changes { - switch field { - case "RSSI": - props.RSSI = val.Value().(int16) - case "Name": - props.Name = val.Value().(string) - case "UUIDs": - props.UUIDs = val.Value().([]string) - case "ManufacturerData": - // work around for https://github.com/muka/go-bluetooth/issues/163 - mData := make(map[uint16]interface{}) - for k, v := range val.Value().(map[uint16]dbus.Variant) { - mData[k] = v.Value().(interface{}) - } - props.ManufacturerData = mData - } + device, ok := devices[sig.Path] + if !ok { + // This shouldn't happen, but protect against it just in + // case. + continue + } + for k, v := range changes { + device[k] = v } - callback(a, makeScanResult(props)) + callback(a, makeScanResult(device)) } case <-cancelChan: continue @@ -222,49 +242,49 @@ func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error { // callback to stop the current scan. If no scan is in progress, an error will // be returned. func (a *Adapter) StopScan() error { - if a.cancelChan == nil { + if a.scanCancelChan == nil { return errNotScanning } - close(a.cancelChan) - a.cancelChan = nil + close(a.scanCancelChan) + a.scanCancelChan = nil return nil } -// makeScanResult creates a ScanResult from a Device1 object. -func makeScanResult(props *device.Device1Properties) ScanResult { +// makeScanResult creates a ScanResult from a raw DBus device. +func makeScanResult(props map[string]dbus.Variant) ScanResult { // Assume the Address property is well-formed. - addr, _ := ParseMAC(props.Address) + addr, _ := ParseMAC(props["Address"].Value().(string)) // Create a list of UUIDs. var serviceUUIDs []UUID - for _, uuid := range props.UUIDs { + for _, uuid := range props["UUIDs"].Value().([]string) { // Assume the UUID is well-formed. parsedUUID, _ := ParseUUID(uuid) serviceUUIDs = append(serviceUUIDs, parsedUUID) } a := Address{MACAddress{MAC: addr}} - a.SetRandom(props.AddressType == "random") - - mData := make(map[uint16][]byte) - for k, v := range props.ManufacturerData { - // can be either variant or just byte value - switch val := v.(type) { - case dbus.Variant: - mData[k] = val.Value().([]byte) - case []byte: - mData[k] = val + a.SetRandom(props["AddressType"].Value().(string) == "random") + + manufacturerData := make(map[uint16][]byte) + if mdata, ok := props["ManufacturerData"].Value().(map[uint16]dbus.Variant); ok { + for k, v := range mdata { + manufacturerData[k] = v.Value().([]byte) } } + // Get optional properties. + localName, _ := props["Name"].Value().(string) + rssi, _ := props["RSSI"].Value().(int16) + return ScanResult{ - RSSI: props.RSSI, + RSSI: rssi, Address: a, AdvertisementPayload: &advertisementFields{ AdvertisementFields{ - LocalName: props.Name, + LocalName: localName, ServiceUUIDs: serviceUUIDs, - ManufacturerData: mData, + ManufacturerData: manufacturerData, }, }, } @@ -272,12 +292,9 @@ func makeScanResult(props *device.Device1Properties) ScanResult { // Device is a connection to a remote peripheral. type Device struct { - device *device.Device1 // bluez device interface - ctx context.Context // context for our event watcher, canceled on disconnect event - cancel context.CancelFunc // cancel function to halt our event watcher context - propchanged chan *bluez.PropertyChanged // channel that device property changes will show up on - adapter *Adapter // the adapter that was used to form this device connection - address Address // the address of the device + device dbus.BusObject // bluez device interface + adapter *Adapter // the adapter that was used to form this device connection + address Address // the address of the device } // Connect starts a connection attempt to the given peripheral device address. @@ -285,27 +302,57 @@ type Device struct { // On Linux and Windows, the IsRandom part of the address is ignored. func (a *Adapter) Connect(address Address, params ConnectionParams) (*Device, error) { devicePath := dbus.ObjectPath(string(a.adapter.Path()) + "/dev_" + strings.Replace(address.MAC.String(), ":", "_", -1)) - dev, err := device.NewDevice1(devicePath) - if err != nil { - return nil, err - } - device := &Device{ - device: dev, + device: a.bus.Object("org.bluez", devicePath), adapter: a, address: address, } - device.ctx, device.cancel = context.WithCancel(context.Background()) - device.watchForConnect() // Set this up before we trigger a connection so we can capture the connect event - if !dev.Properties.Connected { - // Not yet connected, so do it now. - // The properties have just been read so this is fresh data. - err := dev.Connect() + // Already start watching for property changes. We do this before reading + // the Connected property below to avoid a race condition: if the device + // were connected between the two calls the signal wouldn't be picked up. + signal := make(chan *dbus.Signal) + a.bus.Signal(signal) + defer a.bus.RemoveSignal(signal) + propertiesChangedMatchOptions := []dbus.MatchOption{dbus.WithMatchInterface("org.freedesktop.DBus.Properties")} + a.bus.AddMatchSignal(propertiesChangedMatchOptions...) + defer a.bus.RemoveMatchSignal(propertiesChangedMatchOptions...) + + // Read whether this device is already connected. + connected, err := device.device.GetProperty("org.bluez.Device1.Connected") + if err != nil { + return nil, err + } + + // Connect to the device, if not already connected. + if !connected.Value().(bool) { + // Start connecting (async). + err := device.device.Call("org.bluez.Device1.Connect", 0).Err if err != nil { - device.cancel() // cancel our watcher routine - return nil, err + return nil, fmt.Errorf("bluetooth: failed to connect: %w", err) } + + // Wait until the device has connected. + connectChan := make(chan struct{}) + go func() { + for sig := range signal { + switch sig.Name { + case "org.freedesktop.DBus.Properties.PropertiesChanged": + interfaceName := sig.Body[0].(string) + if interfaceName != "org.bluez.Device1" { + continue + } + if sig.Path != device.device.Path() { + continue + } + changes := sig.Body[1].(map[string]dbus.Variant) + if connected, ok := changes["Connected"].Value().(bool); ok && connected { + close(connectChan) + } + } + } + }() + <-connectChan } return device, nil @@ -316,48 +363,5 @@ func (a *Adapter) Connect(address Address, params ConnectionParams) (*Device, er func (d *Device) Disconnect() error { // we don't call our cancel function here, instead we wait for the // property change in `watchForConnect` and cancel things then - return d.device.Disconnect() -} - -// watchForConnect watches for a signal from the bluez device interface that indicates a Connection/Disconnection. -// -// We can add extra signals to watch for here, -// see https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/device-api.txt, for a full list -func (d *Device) watchForConnect() error { - var err error - d.propchanged, err = d.device.WatchProperties() - if err != nil { - return err - } - - go func() { - for { - select { - case changed := <-d.propchanged: - - // we will receive a nil if bluez.UnwatchProperties(a, ch) is called, if so we can stop watching - if changed == nil { - d.cancel() - return - } - - switch changed.Name { - case "Connected": - // Send off a notification indicating we have connected or disconnected - d.adapter.connectHandler(d.address, d.device.Properties.Connected) - - if !d.device.Properties.Connected { - d.cancel() - return - } - } - - continue - case <-d.ctx.Done(): - return - } - } - }() - - return nil + return d.device.Call("org.bluez.Device1.Disconnect", 0).Err } diff --git a/gattc_linux.go b/gattc_linux.go index 462040d1..101a85fa 100644 --- a/gattc_linux.go +++ b/gattc_linux.go @@ -9,8 +9,6 @@ import ( "time" "github.com/godbus/dbus/v5" - "github.com/muka/go-bluetooth/bluez" - "github.com/muka/go-bluetooth/bluez/profile/gatt" ) var ( @@ -24,8 +22,8 @@ type uuidWrapper = UUID // DeviceService is a BLE service on a connected peripheral device. type DeviceService struct { uuidWrapper - - service *gatt.GattService1 + adapter *Adapter + servicePath string } // UUID returns the UUID for this DeviceService. @@ -47,14 +45,16 @@ func (d *Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { start := time.Now() for { - resolved, err := d.device.GetServicesResolved() + resolved, err := d.device.GetProperty("org.bluez.Device1.ServicesResolved") if err != nil { return nil, err } - if resolved { + if resolved.Value().(bool) { break } // This is a terrible hack, but I couldn't find another way. + // TODO: actually there is, by waiting for a property change event of + // ServicesResolved. time.Sleep(10 * time.Millisecond) if time.Since(start) > 10*time.Second { return nil, errors.New("timeout on DiscoverServices") @@ -62,16 +62,13 @@ func (d *Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { } services := []DeviceService{} - uuidServices := make(map[string]string) + uuidServices := make(map[UUID]struct{}) servicesFound := 0 // Iterate through all objects managed by BlueZ, hoping to find the services // we're looking for. - om, err := bluez.GetObjectManager() - if err != nil { - return nil, err - } - list, err := om.GetManagedObjects() + var list map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := d.adapter.bluez.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&list) if err != nil { return nil, err } @@ -84,19 +81,17 @@ func (d *Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { if !strings.HasPrefix(objectPath, string(d.device.Path())+"/service") { continue } - suffix := objectPath[len(d.device.Path()+"/"):] - if len(strings.Split(suffix, "/")) != 1 { + properties, ok := list[dbus.ObjectPath(objectPath)]["org.bluez.GattService1"] + if !ok { continue } - service, err := gatt.NewGattService1(dbus.ObjectPath(objectPath)) - if err != nil { - return nil, err - } + + serviceUUID, _ := ParseUUID(properties["UUID"].Value().(string)) if len(uuids) > 0 { found := false for _, uuid := range uuids { - if service.Properties.UUID == uuid.String() { + if uuid == serviceUUID { // One of the services we're looking for. found = true break @@ -107,20 +102,21 @@ func (d *Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { } } - if _, ok := uuidServices[service.Properties.UUID]; ok { + if _, ok := uuidServices[serviceUUID]; ok { // There is more than one service with the same UUID? // Don't overwrite it, to keep the servicesFound count correct. continue } - uuid, _ := ParseUUID(service.Properties.UUID) - ds := DeviceService{uuidWrapper: uuid, - service: service, + ds := DeviceService{ + uuidWrapper: serviceUUID, + adapter: d.adapter, + servicePath: objectPath, } services = append(services, ds) servicesFound++ - uuidServices[service.Properties.UUID] = service.Properties.UUID + uuidServices[serviceUUID] = struct{}{} } if servicesFound < len(uuids) { @@ -134,9 +130,10 @@ func (d *Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { // device. type DeviceCharacteristic struct { uuidWrapper - - characteristic *gatt.GattCharacteristic1 - property chan *bluez.PropertyChanged // channel where notifications are reported + adapter *Adapter + characteristic dbus.BusObject + property chan *dbus.Signal // channel where notifications are reported + propertiesChangedMatchOption dbus.MatchOption // the same value must be passed to RemoveMatchSignal } // UUID returns the UUID for this DeviceCharacteristic. @@ -163,11 +160,8 @@ func (s *DeviceService) DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacter // Iterate through all objects managed by BlueZ, hoping to find the // characteristic we're looking for. - om, err := bluez.GetObjectManager() - if err != nil { - return nil, err - } - list, err := om.GetManagedObjects() + var list map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := s.adapter.bluez.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&list) if err != nil { return nil, err } @@ -177,21 +171,18 @@ func (s *DeviceService) DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacter } sort.Strings(objects) for _, objectPath := range objects { - if !strings.HasPrefix(objectPath, string(s.service.Path())+"/char") { + if !strings.HasPrefix(objectPath, s.servicePath+"/char") { continue } - suffix := objectPath[len(s.service.Path()+"/"):] - if len(strings.Split(suffix, "/")) != 1 { + properties, ok := list[dbus.ObjectPath(objectPath)]["org.bluez.GattCharacteristic1"] + if !ok { continue } - characteristic, err := gatt.NewGattCharacteristic1(dbus.ObjectPath(objectPath)) - if err != nil { - return nil, err - } - cuuid, _ := ParseUUID(characteristic.Properties.UUID) + cuuid, _ := ParseUUID(properties["UUID"].Value().(string)) char := DeviceCharacteristic{ uuidWrapper: cuuid, - characteristic: characteristic, + adapter: s.adapter, + characteristic: s.adapter.bus.Object("org.bluez", dbus.ObjectPath(objectPath)), } if len(uuids) > 0 { @@ -231,7 +222,7 @@ func (s *DeviceService) DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacter // writes can be in flight at any given time. This call is also known as a // "write command" (as opposed to a write request). func (c DeviceCharacteristic) WriteWithoutResponse(p []byte) (n int, err error) { - err = c.characteristic.WriteValue(p, nil) + err = c.characteristic.Call("org.bluez.GattCharacteristic1.WriteValue", 0, p, map[string]dbus.Variant(nil)).Err if err != nil { return 0, err } @@ -251,25 +242,31 @@ func (c *DeviceCharacteristic) EnableNotifications(callback func(buf []byte)) er return errDupNotif } - ch, err := c.characteristic.WatchProperties() - if err != nil { - return err - } + // Start watching for changes in the Value property. + c.property = make(chan *dbus.Signal) + c.adapter.bus.Signal(c.property) + c.propertiesChangedMatchOption = dbus.WithMatchInterface("org.freedesktop.DBus.Properties") + c.adapter.bus.AddMatchSignal(c.propertiesChangedMatchOption) - err = c.characteristic.StartNotify() + err := c.characteristic.Call("org.bluez.GattCharacteristic1.StartNotify", 0).Err if err != nil { - _ = c.characteristic.UnwatchProperties(ch) return err } - c.property = ch go func() { - for update := range ch { - if update == nil { - continue - } - if update.Interface == "org.bluez.GattCharacteristic1" && update.Name == "Value" { - callback(update.Value.([]byte)) + for sig := range c.property { + if sig.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" { + interfaceName := sig.Body[0].(string) + if interfaceName != "org.bluez.GattCharacteristic1" { + continue + } + if sig.Path != c.characteristic.Path() { + continue + } + changes := sig.Body[1].(map[string]dbus.Variant) + if value, ok := changes["Value"].Value().([]byte); ok { + callback(value) + } } } }() @@ -281,26 +278,16 @@ func (c *DeviceCharacteristic) EnableNotifications(callback func(buf []byte)) er return nil } - e1 := c.characteristic.StopNotify() - e2 := c.characteristic.UnwatchProperties(c.property) + err := c.adapter.bus.RemoveMatchSignal(c.propertiesChangedMatchOption) + c.adapter.bus.RemoveSignal(c.property) c.property = nil - - // FIXME(sbinet): use errors.Join(e1, e2) - if e1 != nil { - return e1 - } - - if e2 != nil { - return e2 - } - - return nil + return err } } // GetMTU returns the MTU for the characteristic. func (c DeviceCharacteristic) GetMTU() (uint16, error) { - mtu, err := c.characteristic.GetProperty("MTU") + mtu, err := c.characteristic.GetProperty("org.bluez.GattCharacteristic1.MTU") if err != nil { return uint16(0), err } @@ -310,7 +297,8 @@ func (c DeviceCharacteristic) GetMTU() (uint16, error) { // Read reads the current characteristic value. func (c *DeviceCharacteristic) Read(data []byte) (int, error) { options := make(map[string]interface{}) - result, err := c.characteristic.ReadValue(options) + var result []byte + err := c.characteristic.Call("org.bluez.GattCharacteristic1.ReadValue", 0, options).Store(&result) if err != nil { return 0, err } diff --git a/gatts_linux.go b/gatts_linux.go index 288ca2ac..4354fde1 100644 --- a/gatts_linux.go +++ b/gatts_linux.go @@ -3,94 +3,154 @@ package bluetooth import ( - "github.com/muka/go-bluetooth/api/service" - "github.com/muka/go-bluetooth/bluez/profile/gatt" + "fmt" + "strconv" + "sync/atomic" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" ) +// Unique ID per service (to generate a unique object path). +var serviceID uint64 + // Characteristic is a single characteristic in a service. It has an UUID and a // value. type Characteristic struct { - handle *service.Char + char *bluezChar permissions CharacteristicPermissions } -// AddService creates a new service with the characteristics listed in the -// Service struct. -func (a *Adapter) AddService(s *Service) error { - app, err := service.NewApp(service.AppOptions{ - AdapterID: a.id, - }) - if err != nil { - return err +// A small ObjectManager for a single service. +type objectManager struct { + objects map[dbus.ObjectPath]map[string]map[string]*prop.Prop +} + +// This method implements org.freedesktop.DBus.ObjectManager. +func (om *objectManager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, *dbus.Error) { + // Convert from a map with *prop.Prop keys, to a map with dbus.Variant keys. + objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{} + for path, object := range om.objects { + obj := make(map[string]map[string]dbus.Variant) + objects[path] = obj + for iface, props := range object { + ifaceObj := make(map[string]dbus.Variant) + obj[iface] = ifaceObj + for k, v := range props { + ifaceObj[k] = dbus.MakeVariant(v.Value) + } + } } + return objects, nil +} - // disable magic uuid generation because we send through a fully formed UUID. - // muka/go-bluetooth does some magic so you can use short UUIDs and it'll auto - // expand them to the full 128 bit uuid. - // setting these flags disables that behavior. - app.Options.UUIDSuffix = "" - app.Options.UUID = "" +// Object that implements org.bluez.GattCharacteristic1 to be exported over +// DBus. Here is the documentation: +// https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/org.bluez.GattCharacteristic.rst +type bluezChar struct { + props *prop.Properties + writeEvent func(client Connection, offset int, value []byte) +} - bluezService, err := app.NewService(s.UUID.String()) - if err != nil { - return err - } +func (c *bluezChar) ReadValue(options map[string]dbus.Variant) ([]byte, *dbus.Error) { + // TODO: should we use the offset value? The BlueZ documentation doesn't + // clearly specify this. The go-bluetooth library doesn't, but I believe it + // should be respected. + value := c.props.GetMust("org.bluez.GattCharacteristic1", "Value").([]byte) + return value, nil +} - err = app.AddService(bluezService) - if err != nil { - return err +func (c *bluezChar) WriteValue(value []byte, options map[string]dbus.Variant) *dbus.Error { + if c.writeEvent != nil { + // BlueZ doesn't seem to tell who did the write, so pass 0 always as the + // connection ID. + client := Connection(0) + offset, _ := options["offset"].Value().(uint16) + c.writeEvent(client, int(offset), value) } + return nil +} - for _, char := range s.Characteristics { - // Create characteristic handle. - bluezChar, err := bluezService.NewChar(char.UUID.String()) - if err != nil { - return err - } +// AddService creates a new service with the characteristics listed in the +// Service struct. +func (a *Adapter) AddService(s *Service) error { + // Create a unique DBus path for this service. + id := atomic.AddUint64(&serviceID, 1) + path := dbus.ObjectPath(fmt.Sprintf("/org/tinygo/bluetooth/service%d", id)) + + // All objects that will be part of the ObjectManager. + objects := map[dbus.ObjectPath]map[string]map[string]*prop.Prop{} + + // Define the service to be exported over DBus. + serviceSpec := map[string]map[string]*prop.Prop{ + "org.bluez.GattService1": { + "UUID": {Value: s.UUID.String()}, + "Primary": {Value: true}, + }, + } + objects[path] = serviceSpec - // Set properties. + for i, char := range s.Characteristics { + // Calculate Flags field. bluezCharFlags := []string{ - gatt.FlagCharacteristicBroadcast, // bit 0 - gatt.FlagCharacteristicRead, // bit 1 - gatt.FlagCharacteristicWriteWithoutResponse, // bit 2 - gatt.FlagCharacteristicWrite, // bit 3 - gatt.FlagCharacteristicNotify, // bit 4 - gatt.FlagCharacteristicIndicate, // bit 5 + "broadcast", // bit 0 + "read", // bit 1 + "write-without-response", // bit 2 + "write", // bit 3 + "notify", // bit 4 + "indicate", // bit 5 } - for i := uint(0); i < 5; i++ { + var flags []string + for i := 0; i < len(bluezCharFlags); i++ { if (char.Flags>>i)&1 != 0 { - bluezChar.Properties.Flags = append(bluezChar.Properties.Flags, bluezCharFlags[i]) + flags = append(flags, bluezCharFlags[i]) } } - bluezChar.Properties.Value = char.Value - if char.Handle != nil { - char.Handle.handle = bluezChar - char.Handle.permissions = char.Flags + // Export the properties of this characteristic. + charPath := path + dbus.ObjectPath("/char"+strconv.Itoa(i)) + propsSpec := map[string]map[string]*prop.Prop{ + "org.bluez.GattCharacteristic1": { + "UUID": {Value: char.UUID.String()}, + "Service": {Value: path}, + "Flags": {Value: flags}, + "Value": {Value: []byte("foobar"), Writable: true, Emit: prop.EmitTrue}, + }, } - - // Do a callback when the value changes. - if char.WriteEvent != nil { - callback := char.WriteEvent - bluezChar.OnWrite(func(c *service.Char, value []byte) ([]byte, error) { - // BlueZ doesn't seem to tell who did the write, so pass 0 - // always. - // It also doesn't provide which part of the value was written, - // so pretend the entire characteristic was updated (which might - // not be the case). - callback(0, 0, value) - return nil, nil - }) + objects[charPath] = propsSpec + props, err := prop.Export(a.bus, charPath, propsSpec) + if err != nil { + return err } - // Add characteristic to the service, to activate it. - err = bluezService.AddChar(bluezChar) + // Export the methods of this characteristic. + obj := &bluezChar{ + props: props, + writeEvent: char.WriteEvent, + } + err = a.bus.Export(obj, charPath, "org.bluez.GattCharacteristic1") if err != nil { return err } + + // Keep the object around for Characteristic.Write. + if char.Handle != nil { + char.Handle.permissions = char.Flags + char.Handle.char = obj + } } - return app.Run() + // Export all objects that are part of our service. + om := &objectManager{ + objects: objects, + } + err := a.bus.Export(om, path, "org.freedesktop.DBus.ObjectManager") + if err != nil { + return err + } + + // Register our service. + return a.adapter.Call("org.bluez.GattManager1.RegisterApplication", 0, path, map[string]dbus.Variant(nil)).Err } // Write replaces the characteristic value with a new value. @@ -99,7 +159,10 @@ func (c *Characteristic) Write(p []byte) (n int, err error) { return 0, nil // nothing to do } - gattError := c.handle.WriteValue(p, nil) + if c.char.writeEvent != nil { + c.char.writeEvent(0, 0, p) + } + gattError := c.char.props.Set("org.bluez.GattCharacteristic1", "Value", dbus.MakeVariant(p)) if gattError != nil { return 0, gattError } diff --git a/go.mod b/go.mod index 85559c2e..9d7a3f0a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.18 require ( github.com/go-ole/go-ole v1.2.6 github.com/godbus/dbus/v5 v5.1.0 - github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1 github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1 github.com/tinygo-org/cbgo v0.0.4 golang.org/x/crypto v0.12.0 @@ -15,7 +14,6 @@ require ( ) require ( - github.com/fatih/structs v1.1.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/sirupsen/logrus v1.9.3 // indirect golang.org/x/sys v0.11.0 // indirect diff --git a/go.sum b/go.sum index 8dd63d17..ad044fc2 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,36 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1 h1:BuVRHr4HHJbk1DHyWkArJ7E8J/VA8ncCr/VLnQFazBo= -github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1 h1:L2YoWezgwpAZ2SEKjXk6yLnwOkM3u7mXq/mKuJeEpFM= github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= -github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8= github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= tinygo.org/x/drivers v0.26.1-0.20230922160320-ed51435c2ef6 h1:w18u47MirULgAl+bP0piUGu5VUZDs7TvXwHASEVXqHk=