From 314ca89209dc0a9f7e35cef7c22b16b1b42f5805 Mon Sep 17 00:00:00 2001 From: Carter Melnychuk <82341302+Cartermel@users.noreply.github.com> Date: Thu, 9 May 2024 10:34:24 -0600 Subject: [PATCH] Winrt full support (#266) windows: full functionality --- Makefile | 2 + adapter_windows.go | 8 ++ gap_windows.go | 102 +++++++++++++++ gatts.go | 4 +- gatts_other.go | 2 +- gatts_windows.go | 308 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 8 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 gatts_windows.go diff --git a/Makefile b/Makefile index 268d820..cbab2b4 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ smoketest-windows: GOOS=windows go build -o /tmp/go-build-discard ./examples/scanner GOOS=windows go build -o /tmp/go-build-discard ./examples/discover GOOS=windows go build -o /tmp/go-build-discard ./examples/heartrate-monitor + GOOS=windows go build -o /tmp/go-build-discard ./examples/advertisement + GOOS=windows go build -o /tmp/go-build-discard ./examples/heartrate smoketest-macos: # Test on macos. diff --git a/adapter_windows.go b/adapter_windows.go index 747c973..726a45e 100644 --- a/adapter_windows.go +++ b/adapter_windows.go @@ -1,6 +1,7 @@ package bluetooth import ( + "errors" "fmt" "github.com/go-ole/go-ole" @@ -13,6 +14,8 @@ type Adapter struct { watcher *advertisement.BluetoothLEAdvertisementWatcher connectHandler func(device Device, connected bool) + + defaultAdvertisement *Advertisement } // DefaultAdapter is the default adapter on the system. @@ -56,3 +59,8 @@ func awaitAsyncOperation(asyncOperation *foundation.IAsyncOperation, genericPara } return nil } + +func (a *Adapter) Address() (MACAddress, error) { + // TODO: get mac address + return MACAddress{}, errors.New("not implemented") +} diff --git a/gap_windows.go b/gap_windows.go index fc92b0f..24bbb31 100644 --- a/gap_windows.go +++ b/gap_windows.go @@ -18,6 +18,108 @@ type Address struct { MACAddress } +type Advertisement struct { + advertisement *advertisement.BluetoothLEAdvertisement + publisher *advertisement.BluetoothLEAdvertisementPublisher +} + +// DefaultAdvertisement returns the default advertisement instance but does not +// configure it. +func (a *Adapter) DefaultAdvertisement() *Advertisement { + if a.defaultAdvertisement == nil { + a.defaultAdvertisement = &Advertisement{} + } + + return a.defaultAdvertisement +} + +// Configure this advertisement. +// on Windows we're only able to set "Manufacturer Data" for advertisements. +// https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.advertisement.bluetoothleadvertisementpublisher?view=winrt-22621#remarks +// following this c# source for this implementation: https://github.com/microsoft/Windows-universal-samples/blob/main/Samples/BluetoothAdvertisement/cs/Scenario2_Publisher.xaml.cs +// adding service data / localname leads to errors when starting the advertisement. +func (a *Advertisement) Configure(options AdvertisementOptions) error { + // we can only advertise manufacturer / company data on windows, so no need to continue if we have none + if len(options.ManufacturerData) == 0 { + return nil + } + + if a.publisher != nil { + a.publisher.Release() + } + + if a.advertisement != nil { + a.advertisement.Release() + } + + pub, err := advertisement.NewBluetoothLEAdvertisementPublisher() + if err != nil { + return err + } + + a.publisher = pub + + ad, err := a.publisher.GetAdvertisement() + if err != nil { + return err + } + + a.advertisement = ad + + vec, err := ad.GetManufacturerData() + if err != nil { + return err + } + + for _, optManData := range options.ManufacturerData { + writer, err := streams.NewDataWriter() + if err != nil { + return err + } + defer writer.Release() + + err = writer.WriteBytes(uint32(len(optManData.Data)), optManData.Data) + if err != nil { + return err + } + + buf, err := writer.DetachBuffer() + if err != nil { + return err + } + + manData, err := advertisement.BluetoothLEManufacturerDataCreate(optManData.CompanyID, buf) + if err != nil { + return err + } + + if err = vec.Append(unsafe.Pointer(&manData.IUnknown.RawVTable)); err != nil { + return err + } + } + + return nil +} + +// Start advertisement. May only be called after it has been configured. +func (a *Advertisement) Start() error { + // publisher will be present if we actually have manufacturer data to advertise. + if a.publisher != nil { + return a.publisher.Start() + } + + return nil +} + +// Stop advertisement. May only be called after it has been started. +func (a *Advertisement) Stop() error { + if a.publisher != nil { + return a.publisher.Stop() + } + + return nil +} + // Scan starts a BLE scan. It is stopped by a call to StopScan. A common pattern // is to cancel the scan when a particular device has been found. func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) (err error) { diff --git a/gatts.go b/gatts.go index f2559ed..70e5518 100644 --- a/gatts.go +++ b/gatts.go @@ -7,6 +7,8 @@ type Service struct { Characteristics []CharacteristicConfig } +type WriteEvent = func(client Connection, offset int, value []byte) + // CharacteristicConfig contains some parameters for the configuration of a // single characteristic. // @@ -17,7 +19,7 @@ type CharacteristicConfig struct { UUID Value []byte Flags CharacteristicPermissions - WriteEvent func(client Connection, offset int, value []byte) + WriteEvent WriteEvent } // CharacteristicPermissions lists a number of basic permissions/capabilities diff --git a/gatts_other.go b/gatts_other.go index ac0312b..849fd1e 100644 --- a/gatts_other.go +++ b/gatts_other.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !windows package bluetooth diff --git a/gatts_windows.go b/gatts_windows.go new file mode 100644 index 0000000..d96b7cd --- /dev/null +++ b/gatts_windows.go @@ -0,0 +1,308 @@ +package bluetooth + +import ( + "fmt" + "sync" + "syscall" + "unsafe" + + "github.com/go-ole/go-ole" + "github.com/saltosystems/winrt-go" + "github.com/saltosystems/winrt-go/windows/devices/bluetooth/genericattributeprofile" + "github.com/saltosystems/winrt-go/windows/foundation" + "github.com/saltosystems/winrt-go/windows/foundation/collections" + "github.com/saltosystems/winrt-go/windows/storage/streams" +) + +// Characteristic is a single characteristic in a service. It has an UUID and a +// value. +type Characteristic struct { + wintCharacteristic *genericattributeprofile.GattLocalCharacteristic + writeEvent WriteEvent + flags CharacteristicPermissions + + valueMtx *sync.Mutex + value []byte +} + +// AddService creates a new service with the characteristics listed in the +// Service struct. +func (a *Adapter) AddService(s *Service) error { + gattServiceOp, err := genericattributeprofile.GattServiceProviderCreateAsync(syscallUUIDFromUUID(s.UUID)) + + if err != nil { + return err + } + + if err = awaitAsyncOperation(gattServiceOp, genericattributeprofile.SignatureGattServiceProviderResult); err != nil { + return err + } + + res, err := gattServiceOp.GetResults() + if err != nil { + return err + } + + serviceProviderResult := (*genericattributeprofile.GattServiceProviderResult)(res) + serviceProvider, err := serviceProviderResult.GetServiceProvider() + if err != nil { + return err + } + + localService, err := serviceProvider.GetService() + if err != nil { + return err + } + + // TODO: "ParameterizedInstanceGUID" + "foundation.NewTypedEventHandler" + // seems to always return the same instance, need to figure out how to get different instances each time... + // was following c# source for this flow: https://github.com/microsoft/Windows-universal-samples/blob/main/Samples/BluetoothLE/cs/Scenario3_ServerForeground.xaml.cs + // which relies on instanced event handlers. for now we'll manually setup our handlers with a map of golang characteristics + // + // TypedEventHandler + guid := winrt.ParameterizedInstanceGUID( + foundation.GUIDTypedEventHandler, + genericattributeprofile.SignatureGattLocalCharacteristic, + genericattributeprofile.SignatureGattWriteRequestedEventArgs) + + goChars := map[syscall.GUID]*Characteristic{} + + writeRequestedHandler := foundation.NewTypedEventHandler(ole.NewGUID(guid), func(instance *foundation.TypedEventHandler, sender, args unsafe.Pointer) { + writeReqArgs := (*genericattributeprofile.GattWriteRequestedEventArgs)(args) + reqAsyncOp, err := writeReqArgs.GetRequestAsync() + if err != nil { + return + } + + if err = awaitAsyncOperation(reqAsyncOp, genericattributeprofile.SignatureGattWriteRequest); err != nil { + return + } + + res, err := reqAsyncOp.GetResults() + if err != nil { + return + } + + gattWriteRequest := (*genericattributeprofile.GattWriteRequest)(res) + + buf, err := gattWriteRequest.GetValue() + if err != nil { + return + } + + offset, err := gattWriteRequest.GetOffset() + if err != nil { + return + } + + characteristic := (*genericattributeprofile.GattLocalCharacteristic)(sender) + uuid, err := characteristic.GetUuid() + if err != nil { + return + } + + goChar, ok := goChars[uuid] + if !ok { + return + } + + if goChar.writeEvent != nil { + // TODO: connection? + goChar.writeEvent(0, int(offset), bufferToSlice(buf)) + } + }) + + guid = winrt.ParameterizedInstanceGUID( + foundation.GUIDTypedEventHandler, + genericattributeprofile.SignatureGattLocalCharacteristic, + genericattributeprofile.SignatureGattReadRequestedEventArgs) + + readRequestedHandler := foundation.NewTypedEventHandler(ole.NewGUID(guid), func(instance *foundation.TypedEventHandler, sender, args unsafe.Pointer) { + readReqArgs := (*genericattributeprofile.GattReadRequestedEventArgs)(args) + reqAsyncOp, err := readReqArgs.GetRequestAsync() + if err != nil { + return + } + + if err = awaitAsyncOperation(reqAsyncOp, genericattributeprofile.SignatureGattReadRequest); err != nil { + return + } + + res, err := reqAsyncOp.GetResults() + if err != nil { + return + } + + gattReadRequest := (*genericattributeprofile.GattReadRequest)(res) + + characteristic := (*genericattributeprofile.GattLocalCharacteristic)(sender) + uuid, err := characteristic.GetUuid() + if err != nil { + return + } + + goChar, ok := goChars[uuid] + if !ok { + return + } + + writer, err := streams.NewDataWriter() + if err != nil { + return + } + defer writer.Release() + + goChar.valueMtx.Lock() + defer goChar.valueMtx.Unlock() + if len(goChar.value) > 0 { + if err = writer.WriteBytes(uint32(len(goChar.value)), goChar.value); err != nil { + return + } + } + + buf, err := writer.DetachBuffer() + if err != nil { + return + } + + gattReadRequest.RespondWithValue(buf) + buf.Release() + }) + + for _, char := range s.Characteristics { + params, err := genericattributeprofile.NewGattLocalCharacteristicParameters() + if err != nil { + return err + } + + if err = params.SetCharacteristicProperties(genericattributeprofile.GattCharacteristicProperties(char.Flags)); err != nil { + return err + } + + uuid := syscallUUIDFromUUID(char.UUID) + createCharOp, err := localService.CreateCharacteristicAsync(uuid, params) + if err != nil { + return err + } + + if err = awaitAsyncOperation(createCharOp, genericattributeprofile.SignatureGattLocalCharacteristicResult); err != nil { + return err + } + + res, err := createCharOp.GetResults() + if err != nil { + return err + } + + characteristicResults := (*genericattributeprofile.GattLocalCharacteristicResult)(res) + characteristic, err := characteristicResults.GetCharacteristic() + if err != nil { + return err + } + + _, err = characteristic.AddWriteRequested(writeRequestedHandler) + if err != nil { + return err + } + + _, err = characteristic.AddReadRequested(readRequestedHandler) + if err != nil { + return err + } + + // Keep the object around for Characteristic.Write. + if char.Handle != nil { + char.Handle.wintCharacteristic = characteristic + char.Handle.value = char.Value + char.Handle.valueMtx = &sync.Mutex{} + char.Handle.flags = char.Flags + goChars[uuid] = char.Handle + } + } + + params, err := genericattributeprofile.NewGattServiceProviderAdvertisingParameters() + if err != nil { + return err + } + + if err = params.SetIsConnectable(true); err != nil { + return err + } + + if err = params.SetIsDiscoverable(true); err != nil { + return err + } + + return serviceProvider.StartAdvertisingWithParameters(params) +} + +// Write replaces the characteristic value with a new value. +func (c *Characteristic) Write(p []byte) (n int, err error) { + length := len(p) + + if length == 0 { + return 0, nil // nothing to do + } + + if c.writeEvent != nil { + c.writeEvent(0, 0, p) + } + + // writes are only actually processed on read events from clients, we just set a variable here. + c.valueMtx.Lock() + defer c.valueMtx.Unlock() + c.value = p + + // only notify if it's enabled, otherwise the below leads to an error + if c.flags&CharacteristicNotifyPermission != 0 { + writer, err := streams.NewDataWriter() + if err != nil { + return length, err + } + + defer writer.Release() + err = writer.WriteBytes(uint32(len(p)), p) + if err != nil { + return length, err + } + + buf, err := writer.DetachBuffer() + if err != nil { + return length, err + } + defer buf.Release() + + op, err := c.wintCharacteristic.NotifyValueAsync(buf) + if err != nil { + return length, err + } + + // IVectorView + signature := fmt.Sprintf("pinterface({%s};%s)", collections.GUIDIVectorView, genericattributeprofile.SignatureGattClientNotificationResult) + if err = awaitAsyncOperation(op, signature); err != nil { + return length, err + } + defer op.Release() + + res, err := op.GetResults() + if err != nil { + return length, err + } + + // TODO: process notification results, just getting this to release + vec := (*collections.IVectorView)(res) + vec.Release() + } + + return length, nil +} + +func syscallUUIDFromUUID(uuid UUID) syscall.GUID { + guid := ole.NewGUID(uuid.String()) + return syscall.GUID{ + Data1: guid.Data1, + Data2: guid.Data2, + Data3: guid.Data3, + Data4: guid.Data4, + } +} diff --git a/go.mod b/go.mod index 305f91e..c7a09c8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/go-ole/go-ole v1.2.6 github.com/godbus/dbus/v5 v5.1.0 - github.com/saltosystems/winrt-go v0.0.0-20240320113951-a2e4fc03f5f4 + github.com/saltosystems/winrt-go v0.0.0-20240509090452-45c2d7a6235a github.com/tinygo-org/cbgo v0.0.4 golang.org/x/crypto v0.12.0 tinygo.org/x/drivers v0.26.1-0.20230922160320-ed51435c2ef6 diff --git a/go.sum b/go.sum index 96a6e7d..7d4cc58 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 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-20240320113951-a2e4fc03f5f4 h1:zurEWtOr/OYiTb5bcD7eeHLOfj6vCR30uldlwse1cSM= -github.com/saltosystems/winrt-go v0.0.0-20240320113951-a2e4fc03f5f4/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= +github.com/saltosystems/winrt-go v0.0.0-20240509090452-45c2d7a6235a h1:i7iQTpf9AlppS14KWzsTvSS8jx4BLPCAwM2lywguZCY= +github.com/saltosystems/winrt-go v0.0.0-20240509090452-45c2d7a6235a/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=