-
Notifications
You must be signed in to change notification settings - Fork 482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feature] Support multiple service/characteristic instances with same UUID #1042
base: master
Are you sure you want to change the base?
Conversation
wow thanks!! this looks pretty good.
|
Is it possible to build for iOS on a Windows machine? Anyway I've since had a colleague confirm that this is working as expected on iOS!
Currently this is named after the Android
Can we be certain that the order will never change? Based on https://github.com/onmyway133/Runtime-Headers/blob/master/macOS/10.12/CoreBluetooth.framework/CBCharacteristic.h I suspect the internal hash function is just combining the handle and the uuid - so unless there are hundreds of characteristics with the same UUID it is highly unlikely they would collide.
Sure - will do unless you like my proposal for |
ah good point. I don't think so.
I think just keep it simple. What you have now is good. I just dont like the name. it's longer, more complicated, not available on iOS. But I understand it makes sense if this feature was android-only.
Yes. They guarantee order on iOS, because it's important for things like this. |
c5f9291
to
ecadf88
Compare
Have completed the changes and will have colleague verify iOS again. The Also implemented primary/secondary service support for descriptors. |
I have been refactoring secondary services, so we are going to have a merge conflict, sorry about that. I'm about to push the changes to master branch. |
here is the commit. please read this comment for context: #948 (comment) |
No worries, I was actually considering proposing that exact change so I am glad to see it! Will refactor tomorrow. |
also, you'll need to mirror some of the changes I made.
for you, you should do:
|
also make sure you update this code, to check that the indexes are equal
|
Although having had a quick look at it I'm not sure I expected Could we make it bi-directional by retaining |
@nebkat , I added it back: a42ca5d I just temporarily removed it. The underlying representation changed. Now instead of returning a The more important change is that I wanted to switch from Also, iOS and Android don't use |
I'm thinking about your change more. I think you're right. on ios youre probably right it's better to use the hash. but we could also make our own simple hash too if possible. that's probably better. |
I'm also wondering if we should remove tbh I'm not sure the best way to represent all of this stuff, especially with the addition of handles. But I know |
looking at this, they call it handle as well
and in the BLE spec as well, they use handles
But I think we should keep it |
better idea, what if we instead added a index to Guid?
string representation:
normally it will be set to 0. and only in cases where there are dupicate services, or duplicate characteristics in a service, or duplicate descriptors within a charachteristic, will it have a value. I think we should try to make it work with an index, first. That seems best to me. |
I would have to disagree there. A However, if we are open to wider changes I would propose the following: GATT does internally use Consequently it would make sense that we hide this implementation detail from the user in Dart, and rely on the same principle of identifying by specific instances of Thus I would suggest the following structure: class BluetoothAttribute {
final DeviceIdentifier remoteId;
final Guid uuid;
final int index = 0;
}
class BluetoothService extends BluetoothAttribute {
final bool isPrimary;
final BluetoothService? parent;
final List<BluetoothCharacteristic> characteristics = [];
final List<BluetoothService> includedServices = [];
BluetoothService._({
required super.remoteId,
required super.uuid,
required super.index,
required this.isPrimary,
this.parent,
});
void _addIncludedService(BluetoothService service) {
includedServices.add(service);
}
void _addCharacteristic(BluetoothCharacteristic characteristic) {
characteristics.add(characteristic);
}
}
class BluetoothCharacteristic extends BluetoothAttribute {
final BluetoothService service;
final List<BluetoothDescriptor> descriptors;
BluetoothCharacteristic._({
required super.remoteId,
required super.uuid,
required super.index,
required this.service,
});
void _addDescriptor(BluetoothDescriptor descriptor) {
descriptors.add(descriptor);
}
}
class BluetoothDescriptor extends BluetoothAttribute {
final BluetoothCharacteristic characteristic;
BluetoothDescriptor._({
required super.remoteId,
required super.uuid,
required this.characteristic
});
} Upon receiving the discovered services we would perform the two-stage mapping: initializing all the classes with their parent service/characteristic, and then adding them to the respective lists of children. This would have the following benefits:
If this is something we would be open to I would gladly provide a PR. |
I like it. However, I would use the same existing names where possible. i.e. characteristicUuid instead of just uuid. we already have a uuid convenience accessor. In fact, I think we can implement this with basically no breaking changes to the API? class BluetoothCharacteristic extends BluetoothAttribute {
final BluetoothAttribute _service;
final BluetoothAttribute? _primaryService;
Guid get serviceUuid => _service.uuid;
Guid get characteristicUuid => uuid;
Guid? get primaryServiceUuid = > _primaryService.uuid;
final List<BluetoothDescriptor> descriptors;
BluetoothCharacteristic({
required super.remoteId,
required serviceUuid,
required characteristicUuid,
this.primaryServiceUuid,
serviceIndex = 0,
characteristicIndex = 0,
primaryServiceIndex = 0
}) super.uuid = characteristicUuid,
_service = BluetoothAttribute(remoteId, serviceUuid, serviceIndex) ;
void _addDescriptor(BluetoothDescriptor descriptor) {
descriptors.add(descriptor);
}
} To be honest, I like your approach more. But I'd rather not have to go "2.0" with the API. Let's push a "1.0" compatible version first. Or maybe we can go 2.0 if we provide 1.0 compatible constructors as well. |
but the more a think about it. I think the clean 1.0 way is to not have a just add indexes to the existing classes. put them all together not interleaved, looks nicer:
However, Internally, I still like the idea of using the |
a guid is supposed to be globally unique. So I think it still makes more sense than what we have today. Obviously it was poorly named in the original I think adding index to Guid is the best "1.0" compatible way to add this feature. Open to alternatives. small breaking are changes okay, but maximum 1 or 2. |
Sure it's mostly obvious what a
Which of these classes should actually be constructible by the user? If I'm not mistaken none of these characteristics/services will work unless they are discovered in the low level API, so a user should really get a reference to our instance of the It would be great if we could save a round of development and go straight to 2.0, I don't think any of these changes would involve much migration - if any.
Internally I don't mind, we could even reference everything in the format: If you are open to it there are many ways we could significantly simplify the code that communicates between Dart/Java/Objective-C.
This is a confusing concept within GATT but a UUID is meant to uniquely identify the type of the data, not the characteristic itself. If we attempt to correct this by changing what a Guid is (which is just another name for UUID) we will add to the further confusion - instead I think we should educate users on the distinction. |
All fair. Just want it to be 1.0 api compatible, with no more than 1 or 2 small breaking changes. Any suggestions? I think modifying Guid is the easiest way to support this feature in 1.0 friendly way.
not necessarily. If it was just For example, at quick glance, what does It probably took you about 0.2 seconds to find out. But when it's named By using Having both is the best. |
Let me make a PR this weekend, I think it will be possible with 0 or 1 breaking changes.
If
These instances will practically all be eliminated - there will be a single place in the code where we map from the |
On further investigation a lot of things seems strangely overcomplicated with all of the |
yes. because users can instantiate these objects themselves. This function has always been part of the API, and users have used it since day 1.
so they need to be able to access the last value from a global store But also, I think the design of global + tokens makes more sense. I know theres a lot of breaking changes we could make, but I try not to break more than ~1 thing at a time. This makes it easier for most users to update. |
Another thing to keep in mind, verbose logs. this I think adding index to Guid is still the most obvious solution that meets all criteria, without blowing up the code base to add a feature almost no one cares about. |
This is ending up a slightly bigger change than anticipated but I'll get it finished and you can consider the option. So far looking like a pretty huge reduction in code bloat. There is possibly a pretty neat solution to not breaking the token style construction with something like: factory BluetoothCharacteristic({
DeviceIdentifier remoteId,
Guid this.serviceUuid,
Guid this.characteristicUuid,
Guid? primaryServiceUuid,
}) {
return FlutterBluePlus.findCharacteristic(remoteId, serviceUuid, primaryServiceUuid, characteristicUuid);
}
I don't doubt that this is the simplest solution but I think it's important to ensure that implementation details are not leaking into the public API and complicating things in the future. If we were to do this would a |
No. They are not equivalent.
this refers to the first service with uuid =
this refers to the second service with uuid = If users want to compare uuid, they will need to compare for most users, its a completely transparent change, since this feature is rarely important. which is exactly what we want. only when a users specifically wants to refer to the second service of a given uuid will they need to use instead of representing a UUID, GUID now represents a specific svc/chr/desc. |
ah okay, the first thing we should do is to fix the existing code then maybe let's call it 'parentServiceUuid' that said, it would probably be a list, because it could be included by multiple parents i assume or i guess its intended to treat each parent/child pair as a separate service. and we should use the isPrimary/isSecondary flags from the host OS, and copy them into the BmService struct like they used to be and add docs to the codebase with explanations of all this stuff |
my product launched this week, so i won't have time for awhile |
How about we use the pointer address of the |
pointer seems like a good idea except i'm not sure how a user would instantiate it themself then before discoverServices maybe we should just break that use case i imagine it's used to do things like create chr listeners before you connect, for simpler code or we can just assume null pointer is the first instance and not support that feature for included services on second thought index seems better it's more helpful for debugging too |
I think if we allow the So if you do If someone needs to do multi-UUID (service or characteristic) setups they should use discovery mode. This is the same logic as Indices will break if services are inserted/removed at an earlier position in the table, whereas pointers should remain valid. |
Okay, sure, lets do ptr. but keep it null unless there are multiple of the same service, chr, etc. if there are multiple, lets give them all an pointer. and I still think we should modify Guid. |
looking reasonable. Some nice improvements, and some I think are not needed. Early feedback:
|
Okay, I kept trying to give more feedback, but the PR is too big for me to wrap my head around right now. There's a a lot of changes here. A lot of it I do like, and would merge. Now that you have a very good understanding of the FBP code, can you submit PR requests incrementally? A lot of these changes can be their own PR. ^ I'm sure you already realized this. And I know you've just been exploring whats possible. |
|
I find this really hard to read.
It's easier to read like this:
You might think "internal" representations don't matter. But it's not internal. We log these everywhere. Heck, I even expose these in my app when users look at the logs. So it's user facing too. Keep it as legible as before. In other words, please turn on verbose logs, and make it look decent to read. Mind you, it needs to be performant too. I once tried to pretty format everything, and performance tanked. |
This was an accidental push to the PR as I had a reference to the fork@master, but yeah it's a current snapshot of WIP.
Yeah sorry it's not very pretty right now but I've had to get it to a working state as I have deadlines with our project.
Can do my best to break up changes into their own commits. Maybe if you could comment in the code the areas you are 100% happy with can start extracting and rebasing piece by piece until its a smaller diff?
Personally suggest maximum DRY until the divergence actually occurs. It's pretty easy to un-abstract if the need arises, but without great care the duplicated code can quickly balloon to a difficult to manage state.
As far as the BLE spec is concerned they are almost identical (see
Note that prior to these changes the same functions were re-parsing the events everywhere: Stream<List<int>> get lastValueStream => FlutterBluePlus._methodStream.stream
.where((m) => m.method == "OnCharacteristicReceived" || m.method == "OnCharacteristicWritten")
.map((m) => m.arguments)
.map((args) => BmCharacteristicData.fromMap(args)) So I figured delegating discrimination to the
Agree it's rather vague as is, how about I do believe as a general principle that brevity like this is beneficial and makes classes more readable: bool get isNotifying => (cccd?.lastValue?._firstOrNull ?? 0) & (CCCD_BIT_NOTIFY | CCCD_BIT_INDICATE) > 0
Ok so this should live in
Yeah this is not strictly the final form, was just convenient for now, however...
The fact that we can't pretty print everything means that you will get Would you be open to this? 20% less characters but still comparatively quite readable:
Either way I'm happy with some separation of them to cut out the string parsing on native side. |
} else { | ||
throw UnimplementedError("methodCallMap: ${call.method}"); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be a good place to start, together with _extractEventStream<T>
, but without the changes to FBP/device class structure otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems reasonable. Instead of dynamic
lets use a dart enum.
also, no need for 'if else', just use 'if' here. It's easier on the eyes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i dont really like the name.
let's just call it FlutterBluePlus._events
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems reasonable. Instead of dynamic lets use a dart enum.
Not sure what you mean by this? At best we could give the event classes a common superclass so it's not dynamic, but we'll still always be performing runtime type testing.
also, no need for 'if else', just use 'if' here. It's easier on the eyes.
I'll replace with a switch statement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure what you mean.
Maybe let's use this pattern
enum EventType {
login,
signup,
cancel
}
class Event {
EventType type;
dynamic _underlying;
LoginEvent get login {
assert(type == EventType.login);
assert(_underlying is LoginEvent);
return _underlying as! LoginEvent;
}
...
...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This introduces the possibility that the EventType
might not match the underlying class (thus the assertions). Why not just let the consumer directly observe whether event is LoginEvent
?
The BluetoothEvents
getters already provide nice typed access to the events if this is for user benefit.
too much to discuss at one time. let's just do it one PR at a time. |
this is not easier to understand than just having the very very little code it replaces in the class. too much DRY. Having code duplicate is not bad. It often leads to easier to understand code, easier to modify code, and flatter class hierarchies. When designing code "look a common pattern! I can replace all these lines into a single new concept!" it not a good guiding principal. Instead "this code is too complicated, and I can make it easier to understand", is all we should care about. Edit: sorry if I sound preachy here ^. Just my point of voice. DRY often leads to more complicated code, for little benefit. |
I think it would be splitting hairs here to suggest that a mixin is somehow significantly less readable than the line it replaced - when that line is copied 2-10x in the file. Could equally argue the mixins allow one to focus on what is unique to each event and really this is the perfect use-case for them. It is however an issue of maintainability when a simple change in the parameters of a class requires 10 identical changes elsewhere. This introduces a very real risk of missing one of the required changes and makes it more difficult to understand and review changes. To take a concrete example: 2064c18, the line There is of course no single right answer on the degree to which the code should be abstracted, but I can tell you from my perspective as an external contributor that in its current form the library is quite difficult to work with primarily due to the amount of duplicated code. Every time I see a duplicated block I must ask myself why isn't this abstracted if it's doing the exact same thing, is there something unique about it? In addition to that, while it is merely a metric - when I see files with hundreds or even thousands of lines of code it is very daunting. The 700 lines this branch removed while maintaining the same functionality is 700 less lines I have to understand as a contributor. Look especially at the before and after of service/char/desc classes, I can now get the gist of what they are responsible for without even having to scroll. |
I had to google to remember the rules of mixins. Thats certainly not more readable than a few extra lines. Eventually you end up with the hell of C++, where you need to understand huge textbooks to read 100 lines of code. mixins: "At first, I found this concept somewhat difficult to understand" https://medium.com/flutter-community/dart-what-are-mixins-3a72344011f3 Simply put, agree to disagree. The code is not perfect, ofc. Some refactors are overdue. We dont need to introduce the concept of mixins to this codebase. readability > "maintainability" |
RE: contributors & "700" less lines of code. Lines of code is not a good metric. I gave up on flutter_reactive_ble because it was too abstracted, and used too many swift language features that I didn't want to have to learn, in order to reduce lines of code. look at this file: https://github.com/PhilipsHue/flutter_reactive_ble/blob/master/packages/reactive_ble_mobile/ios/Classes/ReactiveBle/Central.swift ^ look at And look where they ended up. They went from the most popular BLE library, to the 2nd most popular. And soon to be the 3rd. You should look at their code and see if it would have been easier for you to contribute to. It does the same exact stuff as this library. Keeping code "flat" is important. that said this code base is not perfect either. the obj-c & java files are too big. They're not complicated. But they're too big now. Just some simple util files needed. |
anyway, still, lets just go PR by PR, if you want to merge. We dont need to agree on everything. |
..."until I realized how powerful it was."? Instead of arguing the merits of not introducing mixins to the codebase because you are not familiar with them, why not learn how they work and benefit from their use? Every language feature is difficult until you learn how to use it.
Maintainability is no less important than readability. If I am writing professional software I need to have confidence that I can easily develop new features and fix bugs in the library.
Repeating identical code because it's technically quicker to read in one place vs over multiple classes does not make it flat or readable. Abstractions are useful and necessary and there is a lot more nuance to it than flat hierarchy = better.
The external API of this library is better and it is more actively maintained, that is why it is popular, not because it lacks abstractions.
No, we certainly don't. I have obviously been willing to devote a lot of time to these changes and this discussion, and I appreciate the same from you. I'm not adamanant on mixins or any part of this change in particular, and I hope we are just getting caught up on minor details. That said we will need to reach a reasonable consensus if I am going to dedicate further time breaking this up. Have no problem skipping out on some of the functional changes but quite frankly I will need a lot more commitment than "some simple util files". |
The benefit here is too small. And it's not really about me. It's about the new Flutter developer learning Dart for the first time.
I'd probably phrase it like this: But still, needing to keep these docs generic across descriptors & characteristics is not something I want to care about. There's really no reason for DRY here, since the code/concepts do not need to stay in sync. It just happens to be in sync right now. I've only given DRY push back for It's really a small amount of pushback, given the size of this PR. If you're okay not merging those, we can move on. To be clear, I'm not saying its bad, they're reasonable. It's just not the tradeoffs i'm looking for in this codebase. |
@Deprecated('Use remoteId instead') | ||
DeviceIdentifier get deviceId => remoteId; | ||
|
||
BluetoothAttribute? get _parentAttribute => null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this variable should only exist for services. Not chrs & descs. It's just confusing otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was mainly for identifier path generation, so will be removed if we are using a map instead.
For your consideration: a developer that is just learning the language is highly unlikely to be poking around the internals of a BLE library, let alone contributing changes.
It would perhaps help to understand what changes you are anticipating to them - as according to the GATT spec these concepts are very much in sync. Surely keeping one or two extra lines of documentation in sync is easier than keeping double the code and double the documentation in sync? Anyway, good that we are mostly on the same page then, I'll get started on the events refactor. |
For example, on iOS there are restrictions which descriptors can be written to. These belong in the DESC docs, not CHR docs. And these restrictions can change any time. |
@nebkat , have you ever read this article? https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction In this pr we see exactly the problem this article is describing already starting to happen. The docs having new "only applies to characteristics" conditionals, Only deduplicate (i.e. add abstractions) when the value of it is high. When you need a single source of information. etc. |
@chipweinberger Yes, I have. It's a good article and it makes a very valid point - however it's important not to take such opinion pieces at face value without considering the specific context. You seem to be under the impression that avoiding abstraction is implicitly better because there is a risk of it being wrong, which is IMO looking at the problem backwards. A flat hierarchy should rarely be the goal. It is one of the possible solutions when direct abstracting doesn't work. Thus the important question is whether or not a given abstraction is actually wrong. If you look at the example given in the article:
I absolutely agree that if
The single extra line of documentation needed to clarify that notifications are only relevant to characteristics is not a "parameter" as described above. It's totally normal to document different behaviors of derived classes (that's kinda the point of deriving in the first place!). Should Flex not exist because some of its properties provide extra context for the derived classes? You also mention the iOS restrictions on writing certain descriptors - but the The point the article is making is that you shouldn't be afraid to remove abstractions when they are no longer serving their purpose. I suggest you read some of the comments on this article, as well as the discussions here.
Figure 6.3: GATT-Based Profile hierarchy from the spec: As you can see service is the parent of characteristic is the parent of descriptor in the GATT hierarchy. |
That's all fair. But at the end of the day, it's my project, and I find duplication easier to reason about. I try to avoid abstracted code. It's my preference. |
And oh, okay. I see how you intend to use parent now. I'm not sure if we really need that. I've thought about adding it before tho. But seems unecessary. |
it would be great to fix this in the current FBP. |
Untested on iOS, working on Android.
Based on pauldemarco/flutter_blue#215
Closes #795