chipweinberger / flutter_blue_plus

Flutter plugin for connecting and communicationg with Bluetooth Low Energy devices, on Android, iOS, macOS
Other
790 stars 478 forks source link

[Feature]: add support for `includedServices` (i.e. secondary services) #948

Closed DHT-Xavier closed 3 weeks ago

DHT-Xavier commented 4 months ago

Requirements

Have you checked this problem on the example app?

Yes

FlutterBluePlus Version

1.32.11

Flutter Version

3.22.3

What OS?

Android

OS Version

13

Bluetooth Module

Build-in BLE module on Samsung Galaxy Tab A9+

What is your problem?

I am using FBP to connect a weight scale using BLE. The scale has 2 service (0x181D for weight and 0x181B for body composition). Each of them should contain 2 characteristics for READ and INDICATE with a Descriptor for CCC.

Characteristics 0x2A9E, 0x2A9D under service 0x181D are working fine However, Characteristics 0x2A9B, 0x2A9C under service 0x181B (which isPrimary: false) are not able to READ or INDICATE

A PlatformException return while tried to setNotifyValue(true) on 0x2A9C

I/flutter (12268): [FBP] <setNotifyValue> args: {remote_id: 00:09:1F:82:D1:50, 
service_uuid: 181d, secondary_service_uuid: null, characteristic_uuid: 2a9c, 
force_indications: false, enable: true}
D/[FBP-Android](12268): [FBP] onMethodCall: setNotifyValue
I/flutter (12268): PlatformException(setNotifyValue, characteristic not found in 
service (chr: '2a9c' svc: '181d'), null, null)

Please note that the error mentions characteristic not found in service (chr: '2a9c' svc: '181d'), 0x2A9C should 'belong' to service 0x181B but not 0x181D

Seems while I am trying to do anything on the characteristic under service which is not primary service, it tried to sending command to it's serviceUuid but not the secondaryServiceUuid. To temporary fix this issue, I change the code in flutter_blue_plus-1.32.11\lib\src\bluetooth_characteristic.dart line 248

from

serviceUuid: serviceUuid,

to

serviceUuid: secondaryServiceUuid ?? serviceUuid,

So that I can temporary setNotifyValue to the characteristic

Here are some details of the isPrimary: false service and characteristic under the service.

0x181B BluetoothService:

I/flutter (16523): BluetoothService{remoteId: 00:09:1F:82:D1:50, serviceUuid: 181b, isPrimary: false, 
characteristics: [BluetoothCharacteristic{remoteId: 00:09:1F:82:D1:50, serviceUuid: 181d, 
secondaryServiceUuid: 181b, characteristicUuid: 2a9b, descriptors: [], properties: CharacteristicProperties
{broadcast: false, read: false, writeWithoutResponse: false, write: false, notify: false, indicate: false, 
authenticatedSignedWrites: false, extendedProperties: false, notifyEncryptionRequired: false, 
indicateEncryptionRequired: false}, value: []}, BluetoothCharacteristic{remoteId: 00:09:1F:82:D1:50, 
serviceUuid: 181d, secondaryServiceUuid: 181b, characteristicUuid: 2a9c, descriptors: [], properties: 
CharacteristicProperties{broadcast: false, read: false, writeWithoutResponse: false, write: false, notify: 
false, indicate: false, authenticatedSignedWrites: false, extendedProperties: false, 
notifyEncryptionRequired: false, indicateEncryptionRequired: false}, value: []}], includedServices: []}

0x2A9C BluetoothCharacteristic:

I/flutter (16523): BluetoothCharacteristic{remoteId: 00:09:1F:82:D1:50, serviceUuid: 
181d, secondaryServiceUuid: 181b, characteristicUuid: 2a9c, descriptors: [], 
properties: CharacteristicProperties{broadcast: false, read: false, 
writeWithoutResponse: false, write: false, notify: false, indicate: false, 
authenticatedSignedWrites: false, extendedProperties: false, 
notifyEncryptionRequired: false, indicateEncryptionRequired: false}, value: []}

This is what FBP example app discover: FBP_example_app

This is a simple demo flutter app using FBP to list all services and characteristics and properties: FBP_simple_app

This is the services discover result using nRF Connect app on the same device connecting the same ble device: nRF_Connect_result nRF_Connect_logs

Logs

Launching lib/main.dart on SM X210 in debug mode...
✓ Built build/app/outputs/flutter-apk/app-debug.apk
I/m.example.uc421( 2906): Compiler allocated 6133KB to compile void android.view.ViewRootImpl.performTraversals()
Connecting to VM Service at ws://127.0.0.1:54518/jxjl2AlRa0s=/ws
W/Choreographer( 2906): Frame time is 0.190208 ms in the future!  Check that graphics HAL is generating vsync timestamps using the correct timebase.
D/[FBP-Android]( 2906): [FBP] onMethodCall: flutterRestart
D/[FBP-Android]( 2906): [FBP] initializing BluetoothAdapter
I/flutter ( 2906): [FBP] <setLogLevel> args: 5
I/BluetoothAdapter( 2906): BluetoothAdapter() : com.example.uc421
I/BluetoothAdapter( 2906): STATE_ON
D/[FBP-Android]( 2906): [FBP] disconnectAllDevices(flutterRestart)
D/[FBP-Android]( 2906): [FBP] connectedPeripherals: 0
D/[FBP-Android]( 2906): [FBP] onMethodCall: setLogLevel
I/BLASTBufferQueue( 2906): [SurfaceView[com.example.uc421/com.example.uc421.MainActivity]@0#1](f:0,a:0) onFrameAvailable the first frame is available
D/SurfaceView@f248( 2906):  setAlpha: mUseAlpha = false alpha=1.0
D/SurfaceView@f248( 2906):  updateSurfaceAlpha: setUseAlpha() is not called, ignored.
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): performTraversals params={(0,0)(fillxfill) sim={adjust=resize forwardNavigation} ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x1030001
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   fl=81810100
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   pfl=16020040
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   vsysui=500
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   apr=LIGHT_STATUS_BARS
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   bhv=DEFAULT
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   fitSides= naviIconColor=0}
I/flutter ( 2906): [FBP] <setLogLevel> result: true
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): performTraversals mFirst=false windowShouldResize=false viewVisibilityChanged=false mForceNextWindowRelayout=false params={(0,0)(fillxfill) sim={adjust=resize forwardNavigation} ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x1030001
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   fl=81810100
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   pfl=16020040
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   vsysui=500
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   apr=LIGHT_STATUS_BARS
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   bhv=DEFAULT
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906):   fitSides= naviIconColor=0}
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): updateBlastSurfaceIfNeeded mBlastBufferQueue=0xb400007abb834500 isSameSurfaceControl=true
I/flutter ( 2906): [FBP] <getBondedDevices> args: null
I/BLASTBufferQueue( 2906): update, w= 1200 h= 1920 mName = ViewRootImpl@5d0e7d5[MainActivity] mNativeObject= 0xb400007abb834500 sc.mNativeObject= 0xb400007a349fe8a0 format= -3 caller= android.view.ViewRootImpl.updateBlastSurfaceIfNeeded:2928 android.view.ViewRootImpl.relayoutWindow:9990 android.view.ViewRootImpl.performTraversals:3919 android.view.ViewRootImpl.doTraversal:3151 android.view.ViewRootImpl$TraversalRunnable.run:11068 android.view.Choreographer$CallbackRecord.run:1321
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Relayout returned: old=(0,0,1200,1920) new=(0,0,1200,1920) req=(1200,1920)0 dur=6 res=0x1 s={true 0xb400007a4e50e000} ch=false seqId=0
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): updateBoundsLayer: t = android.view.SurfaceControl$Transaction@4033ea1 sc = Surface(name=Bounds for - com.example.uc421/com.example.uc421.MainActivity@0)/@0x3fce8c6 frame = 1
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): reportNextDraw android.view.ViewRootImpl.performTraversals:4473 android.view.ViewRootImpl.doTraversal:3151 android.view.ViewRootImpl$TraversalRunnable.run:11068 android.view.Choreographer$CallbackRecord.run:1321 android.view.Choreographer$CallbackRecord.run:1329
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Setup new sync id=0
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Setting syncFrameCallback
I/SurfaceSyncer@14754eb( 2906): invalid sync id = 0 host = null Callers = android.window.SurfaceSyncer.merge:206 android.view.ViewRootImpl.mergeSync:13724 android.view.SurfaceView.surfaceSyncStarted:1253 android.view.ViewRootImpl.notifySurfaceSyncStarted:2873 android.view.ViewRootImpl.createSyncIfNeeded:4569
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): registerCallbacksForSync syncBuffer=false
D/SurfaceView@f248( 2906): updateSurfacePosition RenderWorker, frameNr = 1, position = [0, 0, 1200, 1848] surfaceSize = 1200x1848
I/SurfaceView@f248( 2906): uSP: rtp = Rect(0, 0 - 1200, 1848) rtsw = 1200 rtsh = 1848
I/SurfaceView@f248( 2906): onSSPAndSRT: pl = 0 pt = 0 sx = 1.0 sy = 1.0
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Received frameDrawingCallback syncResult=0 frameNum=1.
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Setting up sync and frameCommitCallback
I/SurfaceView@f248( 2906): aOrMT: ViewRootImpl@5d0e7d5[MainActivity] t = android.view.SurfaceControl$Transaction@c7423dd fN = 1 android.view.SurfaceView.-$$Nest$mapplyOrMergeTransaction:0 android.view.SurfaceView$SurfaceViewPositionUpdateListener.positionChanged:1548 android.graphics.RenderNode$CompositePositionUpdateListener.positionChanged:373
I/BLASTBufferQueue( 2906): [ViewRootImpl@5d0e7d5[MainActivity]#0](f:0,a:0) onFrameAvailable the first frame is available
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): Received frameCommittedCallback lastAttemptedDrawFrameNum=1 didProduceBuffer=true
D/OpenGLRenderer( 2906): CFMS:: SetUp Pid : 2906    Tid : 2957
W/Parcel  ( 2906): Expecting binder but got null!
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): onSyncComplete
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): setupSync seqId=0 mSyncId=0 fn=1 caller=android.view.ViewRootImpl$$ExternalSyntheticLambda11.accept:6 android.window.SurfaceSyncer.lambda$setupSync$1$android-window-SurfaceSyncer:168 android.window.SurfaceSyncer$$ExternalSyntheticLambda1.accept:8 android.window.SurfaceSyncer$SyncSet.checkIfSyncIsComplete:495 android.window.SurfaceSyncer$SyncSet.markSyncReady:454 android.window.SurfaceSyncer.markSyncReady:191 android.view.ViewRootImpl.performTraversals:4538
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): reportDrawFinished seqId=0 mSyncId=-1 fn=1 mSurfaceChangedTransaction=0xb400007a34a09900
D/[FBP-Android]( 2906): [FBP] onMethodCall: getBondedDevices
I/BluetoothAdapter( 2906): BluetoothAdapter() : com.example.uc421
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): MSG_WINDOW_FOCUS_CHANGED 1 0
I/ViewRootImpl@5d0e7d5[MainActivity]( 2906): mThreadedRenderer.initializeIfNeeded()#2 mSurface={isValid=true 0xb400007a4e50e000}
D/InputMethodManager( 2906): startInputInner - Id : 0
I/InputMethodManager( 2906): startInputInner - mService.startInputOrWindowGainedFocus
I/flutter ( 2906): [FBP] <getBondedDevices> result: {devices: [{remote_id: 00:09:1F:82:D1:50, platform_name: UC-421BLE_82D150}, {remote_id: C4:AC:59:7A:09:F0, platform_name: UA-1200BLE_7A09F0}, {remote_id: FF:8D:C5:03:3C:00, platform_name: UP-200BLE_000025}]}
D/InputMethodManager( 2906): startInputInner - Id : 0
I/flutter ( 2906): [FBP] <connect> args: {remote_id: 00:09:1F:82:D1:50, auto_connect: 0}
D/[FBP-Android]( 2906): [FBP] onMethodCall: connect
I/BluetoothAdapter( 2906): STATE_ON
D/BluetoothGatt( 2906): connect() - device: 00091F_0, auto: false
D/BluetoothGatt( 2906): registerApp()
D/BluetoothGatt( 2906): registerApp() - UUID=eb2f73ce-60c8-40c9-bc0b-d10e4dcf9f86
D/BluetoothGatt( 2906): onClientRegistered() - status=0 clientIf=5
I/flutter ( 2906): [FBP] <connect> result: true
I/flutter ( 2906): [FBP] <getAdapterState> args: null
D/[FBP-Android]( 2906): [FBP] onMethodCall: getAdapterState
I/flutter ( 2906): [FBP] <getAdapterState> result: {adapter_state: 4}
D/BluetoothGatt( 2906): onClientConnectionState() - status=0 clientIf=5 device=00091F_0
D/[FBP-Android]( 2906): [FBP] onConnectionStateChange:connected
D/[FBP-Android]( 2906): [FBP]   status: SUCCESS
I/flutter ( 2906): [FBP] [[ OnConnectionStateChanged ]] result: {disconnect_reason_code: 0, disconnect_reason_string: SUCCESS, remote_id: 00:09:1F:82:D1:50, connection_state: 1}
I/flutter ( 2906): [FBP] <requestMtu> args: {remote_id: 00:09:1F:82:D1:50, mtu: 512}
D/[FBP-Android]( 2906): [FBP] onMethodCall: requestMtu
D/BluetoothGatt( 2906): configureMTU() - device: 00091F_0 mtu: 512
I/flutter ( 2906): [FBP] <requestMtu> result: true
D/BluetoothGatt( 2906): onConfigureMTU() - Device=00091F_0 mtu=512 status=0
D/[FBP-Android]( 2906): [FBP] onMtuChanged:
D/[FBP-Android]( 2906): [FBP]   mtu: 512
D/[FBP-Android]( 2906): [FBP]   status: GATT_SUCCESS (0)
I/flutter ( 2906): [FBP] [[ OnMtuChanged ]] result: {error_string: GATT_SUCCESS, success: 1, remote_id: 00:09:1F:82:D1:50, error_code: 0, mtu: 512}
I/flutter ( 2906): [FBP] <discoverServices> args: 00:09:1F:82:D1:50
D/[FBP-Android]( 2906): [FBP] onMethodCall: discoverServices
D/BluetoothGatt( 2906): discoverServices() - device: 00091F_0
I/flutter ( 2906): [FBP] <discoverServices> result: true
D/BluetoothGatt( 2906): onSearchComplete() = Device=00091F_0 Status=0
D/[FBP-Android]( 2906): [FBP] onServicesDiscovered:
D/[FBP-Android]( 2906): [FBP]   count: 11
D/[FBP-Android]( 2906): [FBP]   status: 0GATT_SUCCESS
I/flutter ( 2906): [FBP] [[ OnDiscoveredServices ]] result: {error_string: GATT_SUCCESS, success: 1, remote_id: 00:09:1F:82:D1:50, error_code: 0, services: [{included_services: [], characteristics: [{descriptors: [], service_uuid: 1800, remote_id: 00:09:1F:82:D1:50, characteristic_uuid: 2a00, properties: {broadcast: 0, write_without_response: 0, notify_encryption_required: 0, read: 1, authenticated_signed_writes: 0, extended_properties: 0, indicate: 0, indicate_encryption_required: 0, write: 0, notify: 0}}, {descriptors: [], service_uuid: 1800, remote_id: 00:09:1F:82:D1:50, characteristic_uuid: 2a01, properties: {broadcast: 0, write_without_response: 0, notify_encryption_required: 0, read: 1, authenticated_signed_writes: 0, extended_properties: 0, indicate: 0, indicate_encryption_required: 0, write: 0, notify: 0}}, {descriptors: [], service_uuid: 1800, remote_id: 00:09:1F:82:D1:50, characteristic_uuid: 2a04, properties: {broadcast: 0, write_without_response: 0, notify_encryption_required: 0, read: 1, authenticated_signed_write
I/flutter ( 2906): [FBP] <setNotifyValue> args: {remote_id: 00:09:1F:82:D1:50, service_uuid: 1801, secondary_service_uuid: null, characteristic_uuid: 2a05, force_indications: false, enable: true}
D/[FBP-Android]( 2906): [FBP] onMethodCall: setNotifyValue
D/BluetoothGatt( 2906): setCharacteristicNotification() - uuid: 00002a05-0000-1000-8000-00805f9b34fb enable: true
I/flutter ( 2906): [FBP] <setNotifyValue> result: true
D/[FBP-Android]( 2906): [FBP] onDescriptorWrite:
D/[FBP-Android]( 2906): [FBP]   chr: 2a05
D/[FBP-Android]( 2906): [FBP]   desc: 2902
D/[FBP-Android]( 2906): [FBP]   status: GATT_SUCCESS (0)
I/flutter ( 2906): [FBP] [[ OnDescriptorWritten ]] result: {error_string: GATT_SUCCESS, service_uuid: 1801, success: 1, remote_id: 00:09:1F:82:D1:50, descriptor_uuid: 2902, error_code: 0, characteristic_uuid: 2a05, value: 0200}
I/flutter ( 2906): [FBP] <setNotifyValue> args: {remote_id: 00:09:1F:82:D1:50, service_uuid: 181d, secondary_service_uuid: null, characteristic_uuid: 2a9c, force_indications: false, enable: true}
D/[FBP-Android]( 2906): [FBP] onMethodCall: setNotifyValue
I/flutter ( 2906): PlatformException(setNotifyValue, characteristic not found in service (chr: '2a9c' svc: '181d'), null, null)
I/flutter ( 2906): [FBP] disconnect: enforcing 2000ms disconnect gap, delaying 561ms
I/flutter ( 2906): [FBP] <disconnect> args: 00:09:1F:82:D1:50
D/[FBP-Android]( 2906): [FBP] onMethodCall: disconnect
D/BluetoothGatt( 2906): cancelOpen() - device: 00091F_0
D/BluetoothGatt( 2906): onClientConnectionState() - status=0 clientIf=5 device=00091F_0
I/flutter ( 2906): [FBP] <disconnect> result: true
D/[FBP-Android]( 2906): [FBP] onConnectionStateChange:disconnected
D/[FBP-Android]( 2906): [FBP]   status: SUCCESS
D/BluetoothGatt( 2906): close()
D/BluetoothGatt( 2906): unregisterApp() - mClientIf=5
I/flutter ( 2906): [FBP] [[ OnConnectionStateChanged ]] result: {disconnect_reason_code: 0, disconnect_reason_string: SUCCESS, remote_id: 00:09:1F:82:D1:50, connection_state: 0}
Application finished.

Exited.
chipweinberger commented 4 months ago

please open a PR

thanks

chipweinberger commented 4 months ago

but i don't think your fix is correct

chipweinberger commented 4 months ago

to be clear, fbp has never been tested with secondaryServiceUuid

so you should test everything and open a pr to fix everything

DHT-Xavier commented 4 months ago

to be clear, fbp has never been tested with secondaryServiceUuid

so you should test everything and open a pr to fix everything

I am not familiar with BLE and android native. I did mention that it was a temporary code modification in order to subscribe to the characteristic. The characteristic properties still shows notify: false, indicate: false, etc. I was able to tell what I think is a problem base on the hint in the PlatformException.

DHT-Xavier commented 4 months ago

try

secondaryServiceUuid: secondaryServiceUuid

A new PlatformException returned:

PlatformException(setNotifyValue, secondaryService not found '181b', null, null)

Seems this is returning from flutter_blue_plus-1.32.11\android\src\main\java\com\lib\flutter_blue_plus\FlutterBluePlusPlugin.java line 1614

https://github.com/boskokg/flutter_blue_plus/blob/54e81550204156127f20c719feae5b98d435bb5f/android/src/main/java/com/lib/flutter_blue_plus/FlutterBluePlusPlugin.java#L1609-L1616

On the same file, line 1612, should serviceId change to secondaryServiceId ?

DHT-Xavier commented 1 month ago

I forked this project and tried to apply the fix. Those changes seems work as excepted in my use case.

Please feel free to take a look and verify the change => Comparing changes.

DHT-Xavier commented 1 month ago

Sorry for closing. pressed the wrong button by accident.

chipweinberger commented 3 weeks ago

hi, thanks for following up! sorry for the slow response.

I'll merge them!

chipweinberger commented 3 weeks ago

this change does not look correct:

if (s.serviceUuid == (secondaryServiceUuid ?? serviceUuid))

I think we would check both serviceUuid && secondaryServiceUuid match.

chipweinberger commented 3 weeks ago

I think it should be this

  // get known service
  BmBluetoothService? get _bmsvc {
    if (FlutterBluePlus._knownServices[remoteId] != null) {
      for (var s in FlutterBluePlus._knownServices[remoteId]!.services) {
        if (s.serviceUuid == serviceUuid) {
          if (secondaryServiceUuid != null) {
            // search includedServices (i.e. secondary services)
            for (var s2 in s.includedServices) {
              if (s2.serviceUuid == secondaryServiceUuid) {
                return s2;
              }
            }
          } else {
            return s;
          }
        }
      }
    }
    return null;
  }

  /// get known characteristic
  BmBluetoothCharacteristic? get _bmchr {
    if (_bmsvc != null) {
      for (var c in _bmsvc!.characteristics) {
        if (c.characteristicUuid == uuid) {
          return c;
        }
      }
    }
    return null;
  }
chipweinberger commented 3 weeks ago

for reference, a 'discoverServices' result with secondary services would look like this:

[
  {
    "uuid": "180D",
    "type": "primary",
    "description": "Heart Rate Service",
    "characteristics": [
      {
        "uuid": "2A37",
        "description": "Heart Rate Measurement"
      },
      {
        "uuid": "2A38",
        "description": "Body Sensor Location"
      }
    ]
  },
  {
    "uuid": "180F",
    "type": "primary",
    "description": "Battery Service",
    "characteristics": [
      {
        "uuid": "2A19",
        "description": "Battery Level"
      }
    ]
  },
  {
    "uuid": "1823",
    "type": "secondary",
    "description": "Heart Rate Sensor Configuration",
    "characteristics": [
      {
        "uuid": "2A52",
        "description": "Heart Rate Control Point"
      }
    ],
    "associatedPrimaryService": "180D"
  }
]

you can see, the secondaryService has an associatedPrimaryService.

associatedPrimaryService seems like a good easy way to represent them.

But annoyingly it looks like iOS, Android, and FlutterBlue instead sometimes use includedServices to represent them, and other times use a primary + secondary uuid pair.

the code is inconsistent, and therefore harder to understand.

I'd rather we refactor the code to use associatedPrimaryService everywhere possible.

chipweinberger commented 3 weeks ago

^ I implemented this change, here: https://github.com/chipweinberger/flutter_blue_plus/commit/2064c18e40d1a01e2bf1e382c746acc588cc029f#diff-acb51591d3f05a2f2fb0b18fb30b61f3ced2c41c24f8e4707a25f81ca2704f92R1073

chipweinberger commented 3 weeks ago

added in 1.34.0

please open new issues if you find bugs.