diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index e2b8fc2..7d9368e 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -23,12 +23,12 @@ jobs: # https://github.com/dart-lang/setup-dart/blob/main/README.md - uses: dart-lang/setup-dart@v1 with: - sdk: 3.1.3 + sdk: stable - - uses: subosito/flutter-action@v1 + - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: 3.13.0 + flutter-version: 3.19.0 - name: Install dependencies run: flutter pub get diff --git a/.gitignore b/.gitignore index c20e093..2a5dff8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +# Do not remove or rename entries in this file, only add new ones +# See https://github.com/flutter/flutter/issues/128635 for more context. + # Miscellaneous *.class +*.lock *.log *.pyc *.swp @@ -15,26 +19,143 @@ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ +.ccls-cache + +# This file, on the master branch, should never exist or be checked-in. +# +# On a *final* release branch, that is, what will ship to stable or beta, the +# file can be force added (git add --force) and checked-in in order to effectively +# "pin" the engine artifact version so the flutter tool does not need to use git +# to determine the engine artifacts. +# +# See https://github.com/flutter/flutter/blob/main/docs/tool/Engine-artifacts.md. +/bin/internal/engine.version + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/internal/engine.realm +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated # Flutter/Dart/Pub related **/doc/api/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies +**/generated_plugin_registrant.dart .packages +.pub-preload-cache/ .pub-cache/ .pub/ build/ -# If you're building an application, you may want to check-in your pubspec.lock -pubspec.lock +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks +local.properties +**/.cxx/ + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake +# Coverage +coverage/ +# Symbols +app.*.symbols -## Custom stuff +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json -coverage/ \ No newline at end of file +# Monorepo +.cipd +.gclient +.gclient_entries +.python-version +.gclient_previous_custom_vars +.gclient_previous_sync_commits \ No newline at end of file diff --git a/README.md b/README.md index 0ea9197..2bb2675 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ C2Bluetooth is a flutter package designed to provide an easy API for accessing data from Concept2 PM5 Indoor fitness machines via bluetooth. This library implements the [Concept2 Performance Monitor Bluetooth Smart Communications Interface Definition](https://www.concept2.com/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf) Specification ([newer versions](https://www.c2forum.com/viewtopic.php?f=15&t=193697#p527068) are also available). It also relies heavily on the [CSAFE specification](https://web.archive.org/web/20060718175014/http://www.fitlinxx.com/csafe/specification.htm) from FitLinxx. ## Demo +This package comes with a demo app in the `example/` directory. -![A demo showing the distance completed after a workout](docs/images/demo/demo1-small.jpg) +See the [`example/README.md`](example/README.md) for more detailed information about the demo app and how to use it. -This is a relatively old screenshot of the included example app using an older version of the library to display the completed distance from a short 20-second test workout. Many improvements to expose more datapoints have been made since this screenshot was taken. -## Key Features +## Key Library Features Currently this library supports a few basic features such as: - retrieving workout summary information from the erg after a workout @@ -45,7 +45,6 @@ Similar to how the underlying bluetooth library works, pretty much everything be ```dart ErgBleManager bleManager = ErgBleManager(); -bleManager.init(); //ready to go! ``` ### Scanning for devices Next, you need to start scanning for available devices. This uses a Stream to return an instance of the `Ergometer` class for each erg found. Each of these instances represents an erg and should be stored for later reuse as these act as the base upon which everything else (retrieving data, sending workouts .etc) is based. @@ -55,27 +54,51 @@ Next, you need to start scanning for available devices. This uses a Stream to re ```dart Ergometer myErg; -bleManager.startErgScan().listen((erg) { +StreamSubscription ergScanStream = bleManager.startErgScan().listen((erg) { //your code for detecting an erg here. - myErg = erg + + //you can store the erg instance somewhere + myErg = erg; + + //or connect to it (see later examples) + + //or stop scanning + ergScanStream.cancel(); + + return erg; }); ``` This block of code is where you can do things like: - determine what erg(s) you want to work with (this can be based on name, user choice, or basicaly anything) - store the erg instance somewhere more permanent, like the `myErg` variable to allow you to be able to access it after you stop scanning. - - call `bleManager.stopErgScan()` if you know you are done scanning early. As an example, one way to immediately connect to the first erg found is to unconditionally call `stopErgScan` within this function so the scan stops after the first erg is received. Don't forget to close the stream too! + - cancel the stream if you are done scanning. ### Connecting and disconnecting -Once you have the `Ergometer` instance for the erg you want to connect to, you can call `connectAndDiscover()` on it to connect. +Once you have the `Ergometer` instance for the erg you want to connect to, you can call `connectAndDiscover()` on it to connect. This will provide you with a stream indicating the connection state of the erg. ```dart -await myErg.connectAndDiscover(); +StreamSubscription ergConnectionStream = myErg.connectAndDiscover().listen((event) { + if(event == ErgometerConnectionState.connected) { + //do stuff here once the erg is connected + } else if (event == ErgometerConnectionState.disconnected) { + //handle disconnection here + } +}, onError: (Object error) { + // Handle a possible error + if (error is C2ConnectionException) { + //handle connection errors + } else if (error is C2BluetoothException) { + print("C2Bluetooth error: ${error.message}"); + } else { + print("Unknown error: $error"); + } +}); ``` -When you are done, make sure to disconnect from your erg: +When you are done, disconnect from your erg by cancelling the stream: ```dart -await myErg.disconnectOrCancel(); +ergConnectionStream.cancel(); ``` ### Getting data from the erg diff --git a/docs/API.md b/docs/API.md index cc54f1e..242b123 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,54 +3,61 @@ This document is the starting point for learning more about the c2bluetooth API and inner workings at varying levels of detail: - The broadest overview comes from using the API as documented in the README -- sections like [Overall API Design](#overall-api-design) explain some of the core concepts or goals that we wanted to achieve with the API. -- For people looking to get into the internals of c2bluetooth, the [Core API Concepts](#core-api-concepts) section below is a good mid-level overview of the various groups or categories of classes that are used in the API and what their purpose is. -- For summaries of how c2bluetooth works internally and all the things it "takes care of" for end users, see the [internals](internals.md) document +- sections like [Core Values](#core-values) help explain more of the "why" behind some of the design choices made in the API. +- The [Basic api details](#basic-api-details) section is a detailed, mid-level overview of as man aspects of the public API as posible and what their purpose is. +- For people looking to get into the internals of c2bluetooth or make contributions, see the [internals](internals.md) document. - Obviously the most detailed explaination of how the code works comes from reading the code and inline comments themselves. It is helpful to understand the general goals first +## Terms used +"implementor" generally refers to users of this library. This is intended to be an audience of primarily other flutter developers looking to use this library in their apps. -## Overall API design + +## Core values + +These are some of the core values that were considered as part of the development of this library and forms the ethos/soul of the project. These should be used as general guidance when questions of scope or direction of the library are considered. + +### The Silent Protector + +This principle shares its name with the [popular meme template](https://knowyourmeme.com/memes/the-silent-protector) depicting a soldier kneeling over and protecting a sleeping child. + +![A remix of the "silent protector" meme depicting c2bluetooth protecting the apps that use it from low-level details of bluetooth communications with a concept2 erg](../docs/images/silent-protector.jpg) + +This is essentially a graphical analogy to represent the idea that this library aims to "take on" as much responsibility for abstracting low-level details and hiding away various "gotchas" and complexities of the Concept2 Bluetooth interface specification as possible. + +To my knowledge this is not currently a software design principle found in the broader software industry. However I think it is an awesome way to explain the concept. ### Inspiration In order for this library to be a good fit within the community and provide a good user experience for developers, the goal is to design the interface for this library after other existing libraries interfacing with Concept2 rowing machines. The libraries looked at were [Py3Row](https://github.com/droogmic/Py3Row) (Python, BSD-2), [BoutFitness/Concept2-SDK](https://github.com/BoutFitness/Concept2-SDK) (Swift, MIT), [ErgometerJS](https://github.com/tijmenvangulik/ErgometerJS) (Javascript, Apache 2). -There are likely more libraries like these available, but these were just the first few that were looked at based on a GitHub search. +There are likely more libraries like these available, but these were just the first few that were looked at based on a GitHub search at the time of writing. ### Object Oriented -These three examples all seem to use some kind of Class-based approach where a particular instance of an object represents a particular rowing machine and contains functions to make interaction with the machine easier, like getting data. +These three examples all seem to use some kind of object-oriented approach where a particular instance of an object represents a particular rowing machine and contains functions to make interaction with the machine easier, like getting data. -Designing the library in an object oriented way seemed to make the most sense given what other projects in the space seem to ave done. This should also should keep things relatively straightforward to program and maintain. +Designing the library in an object oriented way seemed to make the most sense given what other projects in the space seem to have done. This should also should keep things relatively straightforward to program and maintain. ### Subscription-based data access -Both BoutFitness/Concept2-SDK and ErgometerJS also seemed to have a way to asynchronously "subscribe" to get a callback when particular values change so the screen can be updated. Since the FlutterBleLib bluetooth library also exposes [Flutter streams](https://apgapg.medium.com/using-streams-in-flutter-62fed41662e4) for reading values in this way, it seems like a good choice to follow this model when exposing data about a PM5. +Both BoutFitness/Concept2-SDK and ErgometerJS also seemed to have a way to asynchronously "subscribe" to get a callback when particular values change so values being displayed on the screen in the implementors flutter app can be updated. Since many Flutter bluetooth libraries also expose notification data from bluetooth devices as [Flutter streams](https://apgapg.medium.com/using-streams-in-flutter-62fed41662e4), this seems like a good, clean way to expose data about a PM5. #### Single Values For getting single values from an erg, such as serial number, software and hardware version info, and other things that likely wont change that often, Streams may be unnecessary and it might be easier to have a simple synchronous interface for grabbing a value either from the erg or from somewhere in the memory allocated to the particular Erg object being used. -Whether or not this is actually a good solution is still TBD -## Core API Concepts +## Basic API details This library is built from a few core concepts, some of which are shared with the `csafe-fitness` library. These core concepts represent general groupings of classes that serve a particular purpose or abstract certain aspects of communicating with an erg. These concepts are roughly divided up into "external" (i.e. those that are part of the libraries public API) and "internal". If you are just using the library in your app, the external concepts should be all you need. Anyone looking to contribute to this library might find the "internal" concepts helpful ### External Concepts -#### Data Objects -Data objects, like the WorkoutSummary class, are essentially wrappers around data provided by the PM and allow the data to be accessed as an object by an application. - -Data objects are primarily one-way communication from a PM to your application. - -Data objects are located in the `data` directory and represent a large chunk of the public API for c2bluetooth - #### Model Objects -This is a gairly general group of classes that represent various indoor rowing conceptsas objects for ease of use by applications looking to interact with ergs. Some examples of classses in this category are the `Ergometer` and `Workout` classes. Unlike Data Objects, they are intended to be able to enable bidirectional data flow. For example, an `Ergometer` object may have properties for getting data (like Data Objects) but also may contain methods like `sendWorkout()` that allow you to provide a `Workout` object to set up on the erg. `Workout` objects could also be returned by other methods as a way to represent a workout if needed. +This is a gairly general group of classes that represent various indoor rowing concepts (in the form of objects). Some examples of classses in this category are the `Ergometer` and `Workout` classes. Unlike Data Objects, they are intended to be able to enable bidirectional data flow. For example, an `Ergometer` object may have properties for getting data (such as Data Objects) but also may contain methods like `sendWorkout()` that allow you to provide a `Workout` object to set up on the erg. `Workout` objects could also be returned by other methods as a way to represent a workout. -Model objects are located in the `models` directory and represent a large chunk of the public API for c2bluetooth \ No newline at end of file +Model objects are located in the `models` directory. \ No newline at end of file diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index c9d198f..0b52632 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -1,7 +1,10 @@ # Design Decisions ## Bluetooth Library -This library ultimately depends on some bluetooth library to function. Originally the plan was to use [flutter_blue](https://github.com/pauldemarco/flutter_blue) because thats the first [tutorial](https://lupyuen.github.io/pinetime-rust-mynewt/articles/flutter#bluetooth-le-services) I came across. However, after seeing how many open issues and PR's they still have, the decline evident in their contributor graph, [comments online](https://www.reddit.com/r/FlutterDev/comments/hm63uk/why_bluetooth_in_flutter_is_so_problematic/), and [an analysis on cauldron.io](https://cauldron.io/project/5134), I've decided to use [FlutterBleLib](https://github.com/dotintent/FlutterBleLib) instead since, even though it seems similarly unmaintained, it has less open issues and seems to have reached a later stage of maturity based on its version number being in the 2.X range, rather than the 0.X range. +This library ultimately depends on some bluetooth library to function. Originally the plan was to use [flutter_blue](https://github.com/pauldemarco/flutter_blue) because thats the first [tutorial](https://lupyuen.github.io/pinetime-rust-mynewt/articles/flutter#bluetooth-le-services) I came across at the time development was started on c2bluetooth. However, after seeing how many open issues and PR's they still have, the decline evident in their contributor graph, [comments online](https://www.reddit.com/r/FlutterDev/comments/hm63uk/why_bluetooth_in_flutter_is_so_problematic/), and [an analysis on cauldron.io](https://cauldron.io/project/5134), [FlutterBleLib](https://github.com/dotintent/FlutterBleLib) was briefly used instead, before the project ultimately switched to using [flutter_reactive_ble](https://github.com/PhilipsHue/flutter_reactive_ble) mainteined by Philips Hue because it seems to be the most likely to continue to be maintained into the future. + + +During the transition from FlutterBleLib to flutter_reactive_ble creating an interface to represent any bluetooth library was considered because it would give implementors the ability to use a bluetooth library that may already exist in their app. This would halp maintainers reduce app dependencies, app size, and conflicting libraries, but was ultimately never implemented because it would make the process of debugging implementor-reported issues reported against the library more difficult. ## CSAFE API Usage diff --git a/docs/images/demo/completed.png b/docs/images/demo/completed.png new file mode 100644 index 0000000..deacd08 Binary files /dev/null and b/docs/images/demo/completed.png differ diff --git a/docs/images/demo/connected.png b/docs/images/demo/connected.png new file mode 100644 index 0000000..eb4b6e4 Binary files /dev/null and b/docs/images/demo/connected.png differ diff --git a/docs/images/demo/demo1-small.jpg b/docs/images/demo/demo1-small.jpg deleted file mode 100644 index 8c23263..0000000 Binary files a/docs/images/demo/demo1-small.jpg and /dev/null differ diff --git a/docs/images/demo/permission-denied.png b/docs/images/demo/permission-denied.png new file mode 100644 index 0000000..8848357 Binary files /dev/null and b/docs/images/demo/permission-denied.png differ diff --git a/docs/images/demo/pre-scan.png b/docs/images/demo/pre-scan.png new file mode 100644 index 0000000..1532305 Binary files /dev/null and b/docs/images/demo/pre-scan.png differ diff --git a/docs/images/demo/scanning.png b/docs/images/demo/scanning.png new file mode 100644 index 0000000..3ec4783 Binary files /dev/null and b/docs/images/demo/scanning.png differ diff --git a/docs/images/silent-protector.jpg b/docs/images/silent-protector.jpg new file mode 100644 index 0000000..fca1eb2 Binary files /dev/null and b/docs/images/silent-protector.jpg differ diff --git a/docs/internals.md b/docs/internals.md index 72cb15e..9c7a693 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1,11 +1,15 @@ # Internal API and Design Concepts This document is meant to be similar to the [API](API.md) document, but specifically for providing an overview of the internal API organization. -Only people interested in contributing to c2bluetooth should need to understand things at this level. +Only people interested in modifying c2bluetooth should need to understand things at this level. -## Internal API Design +This also can be thought of as an outline of many of the (often low-level) things that c2bluetooth "takes care of" for impleenting applications in keeping with the [Silent Protector principle](API.md#the-silent-protector). -TODO + +## Terms used + +### Segment +One key difference you may notice between this library and the source documentation that this is based on (such as the Concept2 Bluetooth API specifications) is the appearance of the term "segment". This is useful because splits and intervals are fundamentally similar enough that concept2 uses the same API to convey both split and interval data (the two are mutually exclusive anyway). Concept2's documentation, however, refers to it as "Split/interval", so the term segment was introduced to make this a little easier to think about and help differentiate data points that are unique to either splits or intervals. ## Internal API Concepts #### Commands @@ -33,3 +37,15 @@ If you need to create your own datatype, you should look at the existing datatyp This section intends to give a broad overview of various components of how c2bluetooth solves certain problems and why they were solved that way +### Subscription Data Multiplex + +In order to provide c2bluetooth with the most flexibility and control over data coming from the PM5, it is useful to insert an additional layer between the incoming data from the bluetooth streams from the PM5 and the stream going out to the user so that c2bluetooth can take on and abstract as much of the complexity as possible. + +This is similar to how a library might add a custom class of its own that wraps an existing API from one of its dependencies so that, even if the API being depended on by the library changes, users of the library are more insulated as the library has a locaton where it can perform changes to keep the API as consistent for the end user as possible. + +Within the context of c2bluetooth, this additional layer is intended to also add functionality by managing both the data requested by implementors of the c2bluetooth library and the data available to it via the concept2's bluetooth interface so as to provide the implementors with the data that they requested for use in their own apps. + +This layer has a few general objectives: +- to keep track of what data the implementor wants ("requested data") +- to keep track of what data we are currently receiving ("subscriptions" to bluetooth notifications from the PM) +- to route data that comes in via bluetooth subscriptions to "outgoing streams" that the implementor is listening to while conserving bluetooth bandwidth as much as possible diff --git a/example/.gitignore b/example/.gitignore index 0fa6b67..6ce9b51 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,5 +1,9 @@ +# Do not remove or rename entries in this file, only add new ones +# See https://github.com/flutter/flutter/issues/128635 for more context. + # Miscellaneous *.class +*.lock *.log *.pyc *.swp @@ -15,32 +19,143 @@ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* +.ccls-cache + +# This file, on the master branch, should never exist or be checked-in. +# +# On a *final* release branch, that is, what will ship to stable or beta, the +# file can be force added (git add --force) and checked-in in order to effectively +# "pin" the engine artifact version so the flutter tool does not need to use git +# to determine the engine artifacts. +# +# See https://github.com/flutter/flutter/blob/main/docs/tool/Engine-artifacts.md. +/bin/internal/engine.version + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/internal/engine.realm +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated # Flutter/Dart/Pub related **/doc/api/ -**/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies +**/generated_plugin_registrant.dart .packages +.pub-preload-cache/ .pub-cache/ .pub/ -/build/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks +local.properties +**/.cxx/ + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake -# Web related -lib/generated_plugin_registrant.dart +# Coverage +coverage/ -# Symbolication related +# Symbols app.*.symbols -# Obfuscation related -app.*.map.json +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Monorepo +.cipd +.gclient +.gclient_entries +.python-version +.gclient_previous_custom_vars +.gclient_previous_sync_commits \ No newline at end of file diff --git a/example/README.md b/example/README.md index 80716b9..371934b 100644 --- a/example/README.md +++ b/example/README.md @@ -1,17 +1,22 @@ -# fresh_example +# c2bluetooth_example This is a sample app created from a fresh flutter project it is useful as a playground for experimenting with/testing the c2bluetooth library as it is being built as well as providing an example for how this could be used in an app -This app simply connects to the first erg that it sees. +Currently this app just connects to the first erg that it sees. An update is planned to make this a little more user-friendly for testing in environments with many ergs. + +![The example app](/docs/images/demo/connected.png) ## Sample App capabilities ### Get workout summary information -1. build and run app. -2. long press app on android(samsung) home screen. click the info button, go to permissions and enable location permissions -3. confirm bluetooth is on -4. turn on PM and go to the screen where you would connect something like the ergdata app (usually this is a connect button on the main menu) -5. open/run the app. it should do some discovery and show you a stroke rate: 0 message -6. hit back on the erg and set up a piece. Recommended to set a 20 sec (minimum allowed) single time piece. -7. start the piece and take some strokes. after the piece is over you should see some data for the piece you completed appear on screen. feel free to modify the app to show other data points. +1. build and install the example app for your platform. +2. confirm bluetooth is on. +3. Accept any permission prompts you are given +4. turn on PM and go to the screen where you would connect something like the ergdata app (on newer firmware there will be a connect button on the main menu) +5. open/run the app. you should see a screen with a "Bluetooth Scan" button. +6. Press this "Bluetooth Scan" button when you are ready to start scanning for ergs. You will see a few messages on the screen while it scans. Wait until the app says "Connected". +7. You can use the erg to set up a piece. Example: A 20 sec (minimum allowed) single time piece is the shortest thing you can do that still works (just row pieces must be longer than 1 minute in order to be visible to the app and be saved in the PM's memory). +8. start the piece. after the piece is over you should see some data for the piece you completed appear on screen. +9. you are now ready to start making changes to the sample app to play around with the API and explore the other data points that are made available. + diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..bd65cd1 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: + package: + - flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 8c92756..7ca87b8 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,48 +1,36 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { + namespace "com.example.c2bluetooth_example" // Any value starting with "flutter." get its value from // the Flutter Gradle plugin. compileSdk flutter.compileSdkVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.fresh_example" + applicationId "com.example.c2bluetooth_example" // You can update the following value to match your application needs. minSdk flutter.minSdkVersion targetSdk flutter.targetSdkVersion // You can set these values in the property declaration or use a variable - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + versionCode flutter.versionCode.toInteger() + versionName flutter.versionName } buildTypes { @@ -57,7 +45,3 @@ android { flutter { source '../..' } - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml index 2cfc1d5..28c2184 100644 --- a/example/android/app/src/debug/AndroidManifest.xml +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="com.example.c2bluetooth_example"> diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 73a4d4d..d50c81e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,22 @@ + package="com.example.c2bluetooth_example"> - - - - + + + + + + + + + package="com.example.c2bluetooth_example"> diff --git a/example/android/build.gradle b/example/android/build.gradle index fc94ae1..1627dad 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,20 +1,7 @@ -buildscript { - ext.kotlin_version = '1.5.20' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index cfe88f6..1af9e09 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 44e62bc..8623888 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,11 +1,26 @@ -include ':app' -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.9.10" apply false +} + +include ":app" \ No newline at end of file diff --git a/example/assets/images/logo.png b/example/assets/images/logo.png new file mode 100644 index 0000000..b1c63db Binary files /dev/null and b/example/assets/images/logo.png differ diff --git a/example/assets/images/logo.svg b/example/assets/images/logo.svg new file mode 100644 index 0000000..ba74749 --- /dev/null +++ b/example/assets/images/logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..88359b2 --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 0000000..2f24574 --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,39 @@ +PODS: + - Flutter (1.0.0) + - permission_handler_apple (9.0.4): + - Flutter + - Protobuf (3.21.9) + - reactive_ble_mobile (0.0.1): + - Flutter + - Protobuf (~> 3.5) + - SwiftProtobuf (~> 1.0) + - SwiftProtobuf (1.20.3) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - reactive_ble_mobile (from `.symlinks/plugins/reactive_ble_mobile/ios`) + +SPEC REPOS: + trunk: + - Protobuf + - SwiftProtobuf + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + reactive_ble_mobile: + :path: ".symlinks/plugins/reactive_ble_mobile/ios" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce + Protobuf: 02524ec14183fe08fb259741659e79683788158b + reactive_ble_mobile: 9ce6723d37ccf701dbffd202d487f23f5de03b4c + SwiftProtobuf: b02b5075dcf60c9f5f403000b3b0c202a11b6ae1 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.11.3 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index fb03af2..3514c05 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ @@ -121,7 +121,6 @@ B8D80978C7D3224A89929ED7 /* Pods-Runner.release.xcconfig */, 05A57BCE264DB95F63255FBC /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -156,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1340; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -322,6 +321,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -340,7 +340,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -356,13 +356,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = UJPCPV3425; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.freshExample; + PRODUCT_BUNDLE_IDENTIFIER = com.adriancedwards.c2bluetooth.freshExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -393,6 +394,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -417,7 +419,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -448,6 +450,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -466,10 +469,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -485,11 +489,11 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.freshExample; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.freshExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -507,11 +511,11 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.freshExample; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.freshExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6..a0f6453 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - - - - + + - - - CADisableMinimumFrameDurationOnPhone - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -13,7 +11,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - fresh_example + c2bluetooth_example CFBundlePackageType APPL CFBundleShortVersionString @@ -43,14 +41,16 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + NSBonjourServices _dartobservatory._tcp NSBluetoothAlwaysUsageDescription - The app uses bluetooth to find, connect and transfer data between different devices + The app uses bluetooth to connect and pull data from Concept2 Ergometer PM5 monitors NSBluetoothPeripheralUsageDescription - The app uses bluetooth to find, connect and transfer data between different devices + The app uses bluetooth to connect and pull data from Concept2 Ergometer PM5 monitor UIBackgroundModes bluetooth-central diff --git a/example/lib/main.dart b/example/lib/main.dart index ca643be..bd02ef3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,213 +1,160 @@ import 'dart:async'; +import 'dart:io'; -import 'package:c2bluetooth/c2bluetooth.dart'; -import 'package:c2bluetooth/models/workout.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:c2bluetooth/c2bluetooth.dart'; -void main() { - runApp(MyApp()); -} - -class MyApp extends StatelessWidget { - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // Try running your application with "flutter run". You'll see the - // application has a blue toolbar. Then, without quitting the app, try - // changing the primarySwatch below to Colors.green and then invoke - // "hot reload" (press "r" in the console where you ran "flutter run", - // or simply save your changes to "hot reload" in a Flutter IDE). - // Notice that the counter didn't reset back to zero; the application - // is not restarted. - primarySwatch: Colors.blue, - ), - home: SimpleErgView(), - ); - } -} +void main() => runApp(const MaterialApp(home: QuickstartPage())); -class SimpleErgView extends StatefulWidget { +class QuickstartPage extends StatefulWidget { + const QuickstartPage({super.key}); @override - _SimpleErgViewState createState() => _SimpleErgViewState(); + State createState() => _QuickstartPageState(); } -class _SimpleErgViewState extends State { - String displayText = "hi"; - String displayText2 = "hi"; - String displayText3 = "hi"; - - ErgBleManager bleManager = ErgBleManager(); - - Ergometer? targetDevice; - StreamSubscription? scanSub; +class _QuickstartPageState extends State { + final ErgBleManager _bleManager = ErgBleManager(); + AppState _state = AppState.idle; + StreamSubscription? _connection; + double? _workDistance; @override void initState() { super.initState(); - unawaited( - bleManager.init().then((_) => startScan()), - ); + _initBle(); } - Future startScan() async { - setState(() { - displayText = "Start Scanning"; - }); - - scanSub = bleManager.startErgScan().listen((erg) { - //Scan one peripheral and stop scanning - print("Scanned Peripheral ${erg.name}"); - - stopScan(); - targetDevice = erg; - connectToDevice(); - }); + /// Ask once for permissions before init + Future _initBle() async { + final perms = [ + Permission.location, // Android: BLE scan needs location + if (Platform.isAndroid) Permission.bluetoothScan, + if (Platform.isAndroid) Permission.bluetoothConnect, + if (Platform.isIOS) Permission.bluetooth, // iOS + ]; + final statuses = await perms.request(); + if (!statuses.values.every((s) => s.isGranted)) { + setState(() => _state = AppState.permissionDenied); + return; + } } - stopScan() { - scanSub?.cancel(); - scanSub = null; - bleManager.stopErgScan(); - } - - connectToDevice() async { - if (targetDevice == null) return; - - setState(() { - displayText = "Device Connecting"; + Future _startBleFlow() async { + setState(() => _state = AppState.scanning); + + // Scan, take first ergometer + final erg = await _bleManager.startErgScan().first; + + // Connect & discover + _connection = erg.connectAndDiscover().listen((state) { + switch (state) { + case ErgometerConnectionState.connected: + setState(() => _state = AppState.connected); + break; + case ErgometerConnectionState.connecting: + setState(() => _state = AppState.connecting); + break; + case ErgometerConnectionState.disconnected: + _workDistance = null; + setState(() => _state = AppState.idle); + break; + } }); + // Wait for workout summary + final summary = await erg.monitorForData({Keys.ELAPSED_DISTANCE_KEY}).first; + _workDistance = await summary[Keys.ELAPSED_DISTANCE_KEY]; - await targetDevice!.connectAndDiscover(); - - // if (!connected) { - // targetDevice! - // .observeConnectionState( - // emitCurrentValue: true, completeOnDisconnect: true) - // .listen((connectionState) { - // print( - // "Peripheral ${targetDevice!.name} connection state is $connectionState"); - // }); - // try { - // await targetDevice!.connect(); - // } catch (BleError) { - // print("a"); - // } - // print('CONNECTING'); - // } else { - // print('DEVICE Already CONNECTED'); - // } - // setState(() { - // displayText = "Device Connected"; - // }); - // discoverServices(); - subscribeToStreams(); - } - - setup2kH() async { - if (targetDevice == null) return; - - targetDevice?.configure2kWorkout(); - } - - setup10kH() async { - if (targetDevice == null) return; - - targetDevice?.configure10kWorkout(); - } - - setup2k() async { - if (targetDevice == null) return; - - targetDevice?.configureWorkout(Workout.single(WorkoutGoal.meters(2000))); - } - - setup10k() async { - if (targetDevice == null) return; - - targetDevice?.configureWorkout(Workout.single(WorkoutGoal.meters(10000))); + setState(() => _state = AppState.done); } - disconnectFromDevice() async { - if (targetDevice == null) return; - - // targetDevice!.disconnect(); - await targetDevice?.disconnectOrCancel(); - + void _disconnectBle() { + _connection?.cancel(); setState(() { - displayText = "Device Disconnected"; - }); - } - - subscribeToStreams() async { - if (targetDevice == null) return; - - setState(() { - displayText = "Setting up streams"; - }); - - targetDevice!.monitorForWorkoutSummary().listen((summary) { - setState(() { - displayText = "distance: ${summary.workDistance}"; - displayText2 = "datetime: ${summary.timestamp}"; - displayText3 = "sr: ${summary.avgSPM}"; - }); + _state = AppState.idle; + _workDistance = null; }); } @override Widget build(BuildContext context) { + String message; + String hint; + VoidCallback? action; + IconData icon; + + switch (_state) { + case AppState.idle: + message = 'Disconnected'; + action = _startBleFlow; + icon = Icons.bluetooth_searching; + hint = 'Tap button to start scanning'; + break; + case AppState.permissionDenied: + message = 'Permissions denied'; + action = null; + icon = Icons.block; + hint = 'Restart app and grant them'; + break; + case AppState.scanning: + message = 'Scanning…'; + action = null; + icon = Icons.wifi_tethering; + hint = 'Scanning for the first erg around'; + break; + case AppState.connecting: + message = 'Connecting…'; + action = null; + icon = Icons.bluetooth_connected; + hint = 'Wait a second'; + break; + case AppState.connected: + message = 'Connected'; + action = _disconnectBle; + icon = Icons.link_off; + hint = 'You can try a rowing session or disconnect at any time'; + break; + case AppState.done: + message = '🏁 Done! Distance: ${_workDistance}'; + action = null; + icon = Icons.flag; + hint = 'This data was recovered from the ergometer subscription'; + break; + } + return Scaffold( - appBar: AppBar( - title: Text("hello"), - ), - body: Column(children: [ - Center( - child: Text( - displayText, - style: TextStyle(fontSize: 24, color: Colors.blue), - ), - ), - Center( - child: Text( - displayText2, - style: TextStyle(fontSize: 24, color: Colors.blue), - ), - ), - Center( - child: Text( - displayText3, - style: TextStyle(fontSize: 24, color: Colors.blue), + appBar: AppBar(title: Center(child: const Text('c2bluetooth example'))), + body: Column( + children: [ + Expanded( + flex: 1, + child: SvgPicture.asset( + 'assets/images/logo.svg', + width: 150.0, + height: 150.0, + ), ), - ), - Center( - child: TextButton( - onPressed: setup2kH, child: Text("Configure a 2k (hardcoded)")), - ), - Center( - child: TextButton( - onPressed: setup10kH, child: Text("Configure a 10k (hardcoded)")), - ), - Center( - child: TextButton(onPressed: setup2k, child: Text("Configure a 2k")), - ), - Center( - child: - TextButton(onPressed: setup10k, child: Text("Configure a 10k")), - ), - ]), + Expanded( + flex: 3, + child: Container( + child: Column( + children: [ + Center( + child: Text(message, + style: TextStyle(fontSize: 50), + textAlign: TextAlign.center)), + Center(child: Text(hint, textAlign: TextAlign.center)), + ], + ))), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: action, + child: Icon(icon), + ), ); } - - @override - void dispose() { - disconnectFromDevice(); - bleManager - .destroy(); //remember to release native resources when you're done! - super.dispose(); - } } + +enum AppState { idle, permissionDenied, scanning, connecting, connected, done } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index bc45844..92bbe2b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,5 +1,5 @@ -name: fresh_example -description: A new Flutter project. +name: c2bluetooth_example +description: Example app for c2bluetooth package. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. @@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.12.0 <3.0.0" + sdk: '>=3.2.0 <4.0.0' dependencies: flutter: @@ -28,9 +28,13 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + flutter_svg: any c2bluetooth: path: ../ + # ask user whether to grant or not bluetooth permissions + permission_handler: ^11.1.0 + dev_dependencies: flutter_test: @@ -46,11 +50,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/images/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 6db856c..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../lib/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/c2bluetooth.dart b/lib/c2bluetooth.dart index 5fb67a9..5fddd60 100644 --- a/lib/c2bluetooth.dart +++ b/lib/c2bluetooth.dart @@ -6,4 +6,4 @@ library c2bluetooth; export 'models/ergblemanager.dart'; export 'models/ergometer.dart'; export 'enums.dart'; -export 'data/workoutsummary.dart'; +export 'src/packets/keys.dart'; diff --git a/lib/constants.dart b/lib/constants.dart index 752a283..a301aa9 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -22,9 +22,21 @@ const String C2_ROWING_PRIMARY_SERVICE_UUID = const String C2_ROWING_GENERAL_STATUS_CHARACTERISTIC_UUID = "CE060031-43E5-11E4-916C-0800200C9A66"; -const String C2_ROWING_GENERAL_STATUS_CHARACTERISTIC2_UUID = +const String C2_ROWING_ADDITIONAL_STATUS1_UUID = "CE060032-43E5-11E4-916C-0800200C9A66"; +const String C2_ROWING_ADDITIONAL_STATUS2_UUID = + "CE060033-43E5-11E4-916C-0800200C9A66"; + +const String C2_ROWING_SAMPLE_RATE_UUID = + "CE060034-43E5-11E4-916C-0800200C9A66"; // sample rate read/write + +const String C2_ROWING_STROKE_DATA_UUID = + "CE060035-43E5-11E4-916C-0800200C9A66"; + +const String C2_ROWING_ADDITIONAL_STROKE_UUID = + "CE060036-43E5-11E4-916C-0800200C9A66"; + const String C2_ROWING_SPLIT_INTERVAL_DATA_CHARACTERISTIC_UUID = "CE060037-43E5-11E4-916C-0800200C9A66"; @@ -37,8 +49,40 @@ const String C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID = const String C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID = "CE06003A-43E5-11E4-916C-0800200C9A66"; +const String C2_ROWING_FORCE_CURVE_UUID = + "CE060040-43E5-11E4-916C-0800200C9A66"; + +const String C2_ROWING_MULTIPLEXED_INFORMATION_CHARACTERISTIC_UUID = + "CE060080-43E5-11E4-916C-0800200C9A66"; + // CE060010-43E5-11E4-916C-0800200C9A66 //C2 device info service uuid // CE060012-43E5-11E4-916C-0800200C9A66 //C2 serial number string characteristic // CE060013-43E5-11E4-916C-0800200C9A66 //C2 hardware revision string characteristic // CE060014-43E5-11E4-916C-0800200C9A66 //C2 firmware revision string characteristic // CE060015-43E5-11E4-916C-0800200C9A66 //C2 manufacturer string characteristic + +const Map dataKeyToCharacteristicMap = { + "something.something": 0xAB, + "something.something.average": 0xAB +}; + +Map> getCharacteristicToDataKeysMap( + Map keyToCharacteristicMap) { + Map> out = Map(); + for (var entry in dataKeyToCharacteristicMap.entries) { + var key = entry.key; + var value = entry.value; + + var currentSet = out[value]; + + if (currentSet != null) { + currentSet.add(key); + } else { + out[value] = {key}; + } + } + return out; +} + +Map> characteristicToDataKeysMap = + getCharacteristicToDataKeysMap(dataKeyToCharacteristicMap); diff --git a/lib/data/workoutsummary.dart b/lib/data/workoutsummary.dart deleted file mode 100644 index 0ecb5f4..0000000 --- a/lib/data/workoutsummary.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'dart:typed_data'; -import 'dart:async'; - -import 'package:c2bluetooth/extensions.dart'; -import 'package:csafe_fitness/csafe_fitness.dart'; - -import '../helpers.dart'; -import 'package:c2bluetooth/enums.dart'; - -///Represents a data packet from Concept2 that is stamped with a date. -class TimestampedData { - DateTime timestamp; - - TimestampedData.fromBytes(Uint8List bytes) - : timestamp = Concept2DateExtension.fromBytes(bytes.sublist(0, 4)); -} - -///Represents a data packet from Concept2 that is stamped with a duration. - -class DurationstampedData { - Duration elapsedTime; - - DurationstampedData.fromBytes(Uint8List data) - : elapsedTime = Concept2DurationExtension.fromBytes(data.sublist(0, 3)); -} - -/// Represents a summary of a completed workout -/// -/// This takes care of processesing the raw byte data from workout summary characteristics into easily accessible fields. This class also takes care of things like byte endianness, combining multiple high and low bytes .etc, allowing applications to access things in terms of flutter native types. -class WorkoutSummary extends TimestampedData { - double workTime; - double workDistance; - int avgSPM; - int endHeartRate; - int avgHeartRate; - int minHeartRate; - int maxHeartRate; - int avgDragFactor; - late int recoveryHeartRate; - WorkoutType workoutType; - double avgPace; - - /// Construct a WorkoutSummary from the bytes returned from the erg - WorkoutSummary.fromBytes(Uint8List data) - : workTime = CsafeIntExtension.fromBytes(data.sublist(4, 7), - endian: Endian.little) / - 100, //divide by 100 to convert to seconds - workDistance = CsafeIntExtension.fromBytes(data.sublist(7, 10), - endian: Endian.little) / - 10, //divide by 10 to convert to meters - avgSPM = data.elementAt(10), - endHeartRate = data.elementAt(11), - avgHeartRate = data.elementAt(12), - minHeartRate = data.elementAt(13), - maxHeartRate = data.elementAt(14), - avgDragFactor = data.elementAt(15), - workoutType = WorkoutTypeExtension.fromInt(data.elementAt(17)), - avgPace = CsafeIntExtension.fromBytes(data.sublist(18, 20), - endian: Endian.little) / - 10, - super.fromBytes(data) { - //recovery heart rate here - int recHRVal = data.elementAt(16); - // 0 is not a valid value here according to the spec - if (recHRVal > 0) { - recoveryHeartRate = recHRVal; - } - } - - @override - String toString() => "WorkoutSummary (" - "Timestamp: $timestamp, " - "elapsedTime: $workTime, " - "distance: $workDistance, " - "avgSPM: $avgSPM)"; -} - -class WorkoutSummary2 extends TimestampedData { - IntervalType intervalType; - int intervalSize; - int intervalCount; - int totalCalories; - int watts; - int totalRestDistance; - int intervalRestTime; - int avgCalories; - - WorkoutSummary2.fromBytes(Uint8List data) - : - // if (data.length > 20) { - // var timestamp2 = Concept2DateExtension.fromBytes(data.sublist(20, 24)); - // if (timestamp != timestamp2) { - // throw ArgumentError( - // "Bytes passed to WorkoutSummary from multiple characteristics must have the same timestamp"); - // } - - intervalType = IntervalTypeExtension.fromInt(data.elementAt(4)), - intervalSize = CsafeIntExtension.fromBytes(data.sublist(5, 7), - endian: Endian.little), - intervalCount = data.elementAt(7), - totalCalories = CsafeIntExtension.fromBytes(data.sublist(8, 10), - endian: Endian.little), - watts = CsafeIntExtension.fromBytes(data.sublist(10, 12), - endian: Endian.little), - totalRestDistance = CsafeIntExtension.fromBytes(data.sublist(12, 15), - endian: Endian.little), - intervalRestTime = CsafeIntExtension.fromBytes(data.sublist(15, 17), - endian: Endian.little), - avgCalories = CsafeIntExtension.fromBytes(data.sublist(17, 19), - endian: Endian.little), - super.fromBytes(data); -} diff --git a/lib/enums.dart b/lib/enums.dart index 21269b3..b1fd795 100644 --- a/lib/enums.dart +++ b/lib/enums.dart @@ -34,10 +34,10 @@ enum MachineType { extension MachineTypeExtension on MachineType { static Map _machineTypeValues = { - MachineType.STATIC_D: 1, - MachineType.STATIC_C: 2, - MachineType.STATIC_A: 3, - MachineType.STATIC_B: 4, + MachineType.STATIC_D: 0, + MachineType.STATIC_C: 1, + MachineType.STATIC_A: 2, + MachineType.STATIC_B: 3, MachineType.STATIC_E: 5, MachineType.STATIC_SIMULATOR: 7, MachineType.STATIC_DYNAMIC: 8, @@ -105,8 +105,8 @@ enum DurationType { extension DurationTypeExtension on DurationType { static Map _durationTypes = { DurationType.TIME: 0x00, - DurationType.DISTANCE: 0x40, - DurationType.CALORIES: 0x80, + DurationType.CALORIES: 0x40, + DurationType.DISTANCE: 0x80, DurationType.WATTMIN: 0xC0, }; @@ -156,7 +156,6 @@ enum IntervalType { WATTMINUTE, WATTMINUTERESTUNDEFINED, NONE //overridden to 255 with the extenstion below - } extension IntervalTypeExtension on IntervalType { @@ -247,84 +246,124 @@ extension ScreenTypeExtension on ScreenType { enum WorkoutScreenValue { NONE, + /// None value (0). PREPARETOROWWORKOUT, + /// Prepare to workout type (1). TERMINATEWORKOUT, + /// Terminate workout type (2). REARMWORKOUT, + /// Rearm workout type (3). REFRESHLOGCARD, + /// Refresh local copies of logcard structures(4). PREPARETORACESTART, + /// Prepare to race start (5). GOTOMAINSCREEN, + /// Goto to main screen (6). LOGCARDBUSYWARNING, + /// Log device busy warning (7). LOGCARDSELECTUSER, + /// Log device select user (8). RESETRACEPARAMS, + /// Reset race parameters (9). CABLETESTSLAVE, + /// Cable test slave indication(10). FISHGAME, + /// Fish game (11). DISPLAYPARTICIPANTINFO, + /// Display participant info (12). DISPLAYPARTICIPANTINFOCONFIRM, + /// Display participant info w/ confirmation (13). CHANGEDISPLAYTYPETARGET, + /// Display type set to target (20). CHANGEDISPLAYTYPESTANDARD, + /// Display type set to standard (21). CHANGEDISPLAYTYPEFORCEVELOCITY, + /// Display type set to forcevelocity (22). CHANGEDISPLAYTYPEPACEBOAT, + /// Display type set to Paceboat (23). CHANGEDISPLAYTYPEPERSTROKE, + /// Display type set to perstroke (24). CHANGEDISPLAYTYPESIMPLE, + /// Display type set to simple (25). CHANGEUNITSTYPETIMEMETERS, + /// Units type set to timemeters (30). CHANGEUNITSTYPEPACE, + /// Units type set to pace (31). CHANGEUNITSTYPEWATTS, + /// Units type set to watts (32). CHANGEUNITSTYPECALORICBURNRATE, + /// Units type set to caloric burn rate(33). TARGETGAMEBASIC, + /// Gasic target game (34). TARGETGAMEADVANCED, + /// Advanced target game (35). DARTGAME, + /// Dart game (36). GOTOUSBWAITREADY, + /// USB wait ready (37). TACHCABLETESTDISABLE, + /// Tach cable test disable (38). TACHSIMDISABLE, + /// Tach simulator disable (39). TACHSIMENABLERATE1, + /// Tach simulator enable, rate = 1:12 (40). TACHSIMENABLERATE2, + /// Tach simulator enable, rate = 1:35 (41). TACHSIMENABLERATE3, + /// Tach simulator enable, rate = 1:42 (42). TACHSIMENABLERATE4, + /// Tach simulator enable, rate = 3:04 (43). TACHSIMENABLERATE5, + /// Tach simulator enable, rate = 3:14 (44). TACHCABLETESTENABLE, + /// Tach cable test enable (45). CHANGEUNITSTYPECALORIES, + /// Units type set to calories(46). VIRTUALKEY_A, + /// Virtual key select A (47). VIRTUALKEY_B, + /// Virtual key select B (48). VIRTUALKEY_C, + /// Virtual key select C (49). VIRTUALKEY_D, VIRTUALKEY_E, @@ -391,3 +430,74 @@ extension WorkoutScreenValueExtension on WorkoutScreenValue { static WorkoutScreenValue fromInt(int i) => _racingScreenValues.map((key, value) => MapEntry(value, key))[i]; } + +enum OperationalState { + /// Reset state (0). + OPERATIONALSTATE_RESET, + + /// Ready state (1). + OPERATIONALSTATE_READY, + + /// Workout state (2). + OPERATIONALSTATE_WORKOUT, + + /// Warm-up state (3). + OPERATIONALSTATE_WARMUP, + + /// Race state (4). + OPERATIONALSTATE_RACE, + + /// Power-off state (5). + OPERATIONALSTATE_POWEROFF, + + /// Pause state (6). + OPERATIONALSTATE_PAUSE, + + /// Invoke boot loader state (7). + OPERATIONALSTATE_INVOKEBOOTLOADER, + + /// Power-off ship state (8). + OPERATIONALSTATE_POWEROFF_SHIP, + + /// Idle charge state (9). + OPERATIONALSTATE_IDLE_CHARGE, + + /// Idle state (10). + OPERATIONALSTATE_IDLE, + + /// Manufacturing test state (11). + OPERATIONALSTATE_MFGTEST, + + /// Firmware update state (12). + OPERATIONALSTATE_FWUPDATE, + + /// Drag factor state (13). + OPERATIONALSTATE_DRAGFACTOR, + + /// Drag factor calibration state (100). + OPERATIONALSTATE_DFCALIBRATION // = 100 +} + +extension OperationalStateExtension on OperationalState { + static Map _operationalStateValues = { + OperationalState.OPERATIONALSTATE_RESET: 0, + OperationalState.OPERATIONALSTATE_READY: 1, + OperationalState.OPERATIONALSTATE_WORKOUT: 2, + OperationalState.OPERATIONALSTATE_WARMUP: 3, + OperationalState.OPERATIONALSTATE_RACE: 4, + OperationalState.OPERATIONALSTATE_POWEROFF: 5, + OperationalState.OPERATIONALSTATE_PAUSE: 6, + OperationalState.OPERATIONALSTATE_INVOKEBOOTLOADER: 7, + OperationalState.OPERATIONALSTATE_POWEROFF_SHIP: 8, + OperationalState.OPERATIONALSTATE_IDLE_CHARGE: 9, + OperationalState.OPERATIONALSTATE_IDLE: 10, + OperationalState.OPERATIONALSTATE_MFGTEST: 11, + OperationalState.OPERATIONALSTATE_FWUPDATE: 12, + OperationalState.OPERATIONALSTATE_DRAGFACTOR: 13, + OperationalState.OPERATIONALSTATE_DFCALIBRATION: 100, + }; + int get value => _operationalStateValues[this]; + //TODO: error if values not found + static OperationalState fromInt(int i) => + _operationalStateValues.map((key, value) => MapEntry(value, key))[i]; +} diff --git a/lib/exceptions/c2bluetooth_exceptions.dart b/lib/exceptions/c2bluetooth_exceptions.dart new file mode 100644 index 0000000..4aeb77c --- /dev/null +++ b/lib/exceptions/c2bluetooth_exceptions.dart @@ -0,0 +1,37 @@ +/// Public exceptions for c2bluetooth +abstract class C2BluetoothException implements Exception { + final String message; + final Object? cause; + + C2BluetoothException(this.message, [this.cause]); + + @override + String toString() { + return 'C2BluetoothException: $message' + '${cause != null ? ' (caused by $cause)' : ''}'; + } +} + +/// Error while connecting to and Ergometer +class C2ConnectionException extends C2BluetoothException { + C2ConnectionException(String message, [Object? cause]) + : super(message, cause); +} + +/// Error while subscribing to Bluetooth characteristics (Dataplex, etc.) +class DataSubscriptionException extends C2BluetoothException { + DataSubscriptionException(String message, [Object? cause]) + : super(message, cause); +} + +/// Error while configuring a workout +class WorkoutConfigurationException extends C2BluetoothException { + WorkoutConfigurationException(String message, [Object? cause]) + : super(message, cause); +} + +/// CSAFE communication error +class CsafeCommunicationException extends C2BluetoothException { + CsafeCommunicationException(String message, [Object? cause]) + : super(message, cause); +} diff --git a/lib/helpers.dart b/lib/helpers.dart index 86109f9..f09ce46 100644 --- a/lib/helpers.dart +++ b/lib/helpers.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; diff --git a/lib/models/c2datastreamcontroller.dart b/lib/models/c2datastreamcontroller.dart new file mode 100644 index 0000000..976ed07 --- /dev/null +++ b/lib/models/c2datastreamcontroller.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +/// A wrapper around StreamController to aid in delivering data from bluetooth to the user +/// +/// This is intended to add functionality enabling: +/// - filtering of data sent to the `add` function so this stream can only send datapoints that it was created to send +/// - caching of data in the event that users want a full set of data any time any value is updated +/// and possibly more. +class C2DataStreamController implements StreamController> { + StreamController> _controller; + + /// A list of the identifying strings for datapoints that this controller should pass on as stream updates. + Set datapoint_identifiers; + + ///called when the controller loses its last subscriber + ///https://dart.dev/articles/libraries/creating-streams#using-a-streamcontroller + @override + FutureOr Function()? get onCancel => _controller.onCancel; + + set onCancel(FutureOr Function()? newValue) { + _controller.onCancel = newValue; + } + + ///called when the stream gets its first subscriber + ///https://dart.dev/articles/libraries/creating-streams#using-a-streamcontroller + @override + void Function()? get onListen => _controller.onListen; + + set onListen(Function()? newValue) { + _controller.onListen = newValue; + } + + @override + void Function()? get onPause => _controller.onPause; + + set onPause(Function()? newValue) { + _controller.onPause = newValue; + } + + @override + void Function()? get onResume => _controller.onResume; + + set onResume(Function()? newValue) { + _controller.onResume = newValue; + } + + C2DataStreamController(this.datapoint_identifiers, + {void onListen()?, + void onPause()?, + void onResume()?, + FutureOr onCancel()?}) + : _controller = new StreamController>( + onListen: onListen, + onPause: onPause, + onResume: onResume, + onCancel: onCancel); + + @override + void add(Map event) { + //source: https://stackoverflow.com/a/21131220 + // filter keys so that only ones that affect this stream get added to the controller + final filteredMap = new Map.fromIterable( + event.keys.where((k) => datapoint_identifiers.contains(k)), + value: (k) => event[k]); + if (filteredMap.length > 0) { + _controller.add(filteredMap); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _controller.addError(error, stackTrace); + } + + @override + Future addStream(Stream> source, {bool? cancelOnError}) { + return _controller.addStream(source, cancelOnError: cancelOnError); + } + + @override + Future close() { + return _controller.close(); + } + + @override + Future get done => _controller.done; + + @override + bool get hasListener => _controller.hasListener; + + @override + bool get isClosed => _controller.isClosed; + + @override + bool get isPaused => _controller.isPaused; + + @override + StreamSink> get sink => _controller.sink; + + @override + Stream> get stream => _controller.stream; +} diff --git a/lib/models/ergblemanager.dart b/lib/models/ergblemanager.dart index 919e131..77127e3 100644 --- a/lib/models/ergblemanager.dart +++ b/lib/models/ergblemanager.dart @@ -1,32 +1,48 @@ +import 'dart:async'; + import 'package:c2bluetooth/constants.dart' as Identifiers; -import 'package:flutter_ble_lib_ios_15/flutter_ble_lib.dart'; +import 'package:c2bluetooth/exceptions/c2bluetooth_exceptions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'ergometer.dart'; class ErgBleManager { - BleManager _manager = BleManager(); + final FlutterReactiveBle _manager; + StreamSubscription? _bleStatus; + List _scannedErgometers = []; - /// perform set up to get the Bluetooth client ready to scan for devices - Future init() async { - await _manager.createClient(restoreStateIdentifier: "example-restore-state-identifier"); - } + ErgBleManager() : _manager = FlutterReactiveBle(); + + /// Allow [ErgBleManager] to be tested using a Mocked bluetooth client + @visibleForTesting + ErgBleManager.withDependency({FlutterReactiveBle? bleClient}) + : _manager = bleClient ?? FlutterReactiveBle(); /// Begin scanning for Ergs. /// + /// Monitor the device's bluetooth status and raise when the device is not ready anymore /// This begins a scan for bluetooth devices with a filter applied so that only Concept2 Performance Monitors show up. - /// Bluetooth must be on and adequate permissions must be granted to the app for this to work. + /// Bluetooth must be on and adequate permissions must be granted for this to work. Stream startErgScan() { - return _manager.startPeripheralScan(uuids: [ - Identifiers.C2_ROWING_BASE_UUID - ]).map((scanResult) => Ergometer(scanResult.peripheral)); - } - - /// Stops scanning for ergs - Future stopErgScan() { - return _manager.stopPeripheralScan(); + _bleStatus = _manager.statusStream.listen((bleStatus) { + if (bleStatus != BleStatus.ready) + throw C2ConnectionException('Bluetooth Error: device $bleStatus'); + }); + return _manager + .scanForDevices( + withServices: [Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)]) + .handleError((error) => + throw C2ConnectionException('Error when scanning', error)) + .map((scanResult) { + _scannedErgometers.add(Ergometer(scanResult, bleClient: _manager)); + return _scannedErgometers.last; + }); } /// Clean up/destroy/deallocate resources so that they are availalble again Future destroy() { - return _manager.destroyClient(); + _bleStatus?.cancel(); + _scannedErgometers.clear(); + return _manager.deinitialize(); } } diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 8beef74..2c1b94f 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -1,108 +1,130 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:c2bluetooth/c2bluetooth.dart'; -import '../internal/commands.dart'; -import '../internal/datatypes.dart'; +import 'package:c2bluetooth/exceptions/c2bluetooth_exceptions.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:c2bluetooth/src/commands.dart'; +import 'package:c2bluetooth/src/datatypes.dart'; +import 'package:c2bluetooth/src/dataplex.dart'; import 'package:csafe_fitness/csafe_fitness.dart'; -import '../helpers.dart'; +import 'package:c2bluetooth/helpers.dart'; import 'workout.dart'; import 'package:c2bluetooth/constants.dart' as Identifiers; -import 'package:flutter_ble_lib_ios_15/flutter_ble_lib.dart'; -import 'package:rxdart/rxdart.dart'; enum ErgometerConnectionState { connecting, connected, disconnected } class Ergometer { - Peripheral _peripheral; + final FlutterReactiveBle _flutterReactiveBle; + DiscoveredDevice _peripheral; Csafe? _csafeClient; + late final Dataplex _dataplex; + /// Get the name of this erg. i.e. "PM5" + serial number - /// - /// Returns "Unknown" if the erg does not report a name - String get name => _peripheral.name ?? "Unknown"; + String get name => _peripheral.name; + Stream? _connection; - /// Create an Ergometer from a FlutterBleLib peripheral object + /// Create an [Ergometer] from a discovered bluetooth device object /// - /// This is mainly intended for internal use - Ergometer(this._peripheral); + /// This is intended only for internal use by [ErgBleManager.startErgScan]. + /// Consider this method a private API that is subject to unannounced breaking + /// changes. There are likely much better methods to use for whatever you are trying to do. + Ergometer(this._peripheral, {required FlutterReactiveBle bleClient}) + : _flutterReactiveBle = bleClient; /// Connect to this erg and discover the services and characteristics that it offers - Future connectAndDiscover() async { - await _peripheral.connect(); - await _peripheral.discoverAllServicesAndCharacteristics(); - + /// this returns a stream of [ErgometerConnectionState] events to enable monitoring the erg's connection state and disconnecting. + Stream connectAndDiscover() { + //this may cause problems if the device goes out of range between scenning and trying to connect. maybe use connectToAdvertisingDevice instead to mitigate this and prevent a hang on android + + //if no services are specified in the `servicesWithCharacteristicsToDiscover` parameter, then full service discovery will be performed + _connection = _flutterReactiveBle + .connectToDevice(id: _peripheral.id) + .handleError((error) => + throw C2ConnectionException('Error while connecting', error)); + _dataplex = new Dataplex(_peripheral, _flutterReactiveBle); _csafeClient = Csafe(_readCsafe, _writeCsafe); + return getMonitorConnectionState; } - /// Disconnect from this erg or cancel the connection - Future disconnectOrCancel() async { - return _peripheral.disconnectOrCancelConnection(); + /// Deprecation notice: disconnect does not exists on FlutterReactiveBle library + @Deprecated("Destroy the Ergometer object to disconnect") + void disconnectOrCancel() { + throw NoSuchMethodError; } - /// Returns a stream of [WorkoutSummary] objects upon completion of any programmed piece or a "just row" piece that is longer than 1 minute. - Stream monitorForWorkoutSummary() { - Stream ws1 = _peripheral - .monitorCharacteristic(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID, - Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID) - .asyncMap((datapoint) => datapoint.read()); - - Stream ws2 = _peripheral - .monitorCharacteristic(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID, - Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID) - .asyncMap((datapoint) => datapoint.read()); - - return Rx.zip2(ws1, ws2, (Uint8List ws1Result, Uint8List ws2Result) { - List combinedList = ws1Result.toList(); - combinedList.addAll(ws2Result.toList()); - return WorkoutSummary.fromBytes(Uint8List.fromList(combinedList)); - }); + /// Subscribe to a stream of data from the erg + /// (ex: general.distance, stroke.drive_length, ...) + Stream> monitorForData( + Set datapointIdentifiers) { + return _dataplex.createStream(datapointIdentifiers); } - /// Expose a stream of events to enable monitoring the erg's connection state - /// This acts as a wrapper around the state provided by the internal bluetooth library to aid with swapping it out later. + // Ensure compatibility + @Deprecated("Use getMonitorConnectionState getter") Stream monitorConnectionState() { - return _peripheral.observeConnectionState().asyncMap((connectionState) { - switch (connectionState) { - case PeripheralConnectionState.connecting: - return ErgometerConnectionState.connecting; - case PeripheralConnectionState.connected: - return ErgometerConnectionState.connected; - case PeripheralConnectionState.disconnecting: - return ErgometerConnectionState.disconnected; - case PeripheralConnectionState.disconnected: - return ErgometerConnectionState.disconnected; - default: - return ErgometerConnectionState.disconnected; - } - }); + return getMonitorConnectionState; } - /// A read function for the PM over bluetooth. + /// Expose a stream of events to enable monitoring the erg's connection state + /// This acts as a wrapper around the state provided by the internal bluetooth library to aid with swapping it out later. + Stream get getMonitorConnectionState => + _connection!.asyncMap((connectionStateUpdate) { + switch (connectionStateUpdate.connectionState) { + case DeviceConnectionState.connecting: + return ErgometerConnectionState.connecting; + case DeviceConnectionState.connected: + return ErgometerConnectionState.connected; + case DeviceConnectionState.disconnecting: + return ErgometerConnectionState.disconnected; + default: + return ErgometerConnectionState.disconnected; + } + }); + + /// An internal read function for accessing the PM's CSAFE API over bluetooth. /// - /// Intended for passing to the csafe_fitness library to allow it to read data from the erg + /// Intended for passing to the csafe_fitness library to allow it to read response data from the erg Stream _readCsafe() { - return _peripheral - .monitorCharacteristic(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID, - Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID) + var csafeRxCharacteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), + characteristicId: + Uuid.parse(Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID), + deviceId: _peripheral.id); + + return _flutterReactiveBle + .subscribeToCharacteristic(csafeRxCharacteristic) + .asyncMap((datapoint) => Uint8List.fromList(datapoint)) .asyncMap((datapoint) { - print("reading data: ${datapoint.value}"); - return datapoint.value; + print("reading data: $datapoint"); + return datapoint; }); } - /// A write function for the PM over bluetooth. + /// An internal write function for accessing the PM's CSAFE API over bluetooth. /// - /// Intended for passing to the csafe_fitness library to allow it to write data to the erg - Future _writeCsafe(Uint8List value) { - return _peripheral.writeCharacteristic( - Identifiers.C2_ROWING_CONTROL_SERVICE_UUID, - Identifiers.C2_ROWING_PM_RECEIVE_CHARACTERISTIC_UUID, - value, - true); - //.asyncMap((datapoint) => datapoint.read()); + /// Intended for passing to the csafe_fitness library to allow it to write commands to the erg + void _writeCsafe(Uint8List value) { + var csafeTxCharacteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), + characteristicId: + Uuid.parse(Identifiers.C2_ROWING_PM_RECEIVE_CHARACTERISTIC_UUID), + deviceId: _peripheral.id); + + // return _peripheral.writeCharacteristic( + // Identifiers.C2_ROWING_CONTROL_SERVICE_UUID, + // Identifiers.C2_ROWING_PM_RECEIVE_CHARACTERISTIC_UUID, + // value, + // true); + // //.asyncMap((datapoint) => datapoint.read()); + + _flutterReactiveBle.writeCharacteristicWithResponse(csafeTxCharacteristic, + value: value); } - @Deprecated("This is a temporary function for development/experimentation and will be gone very soon") + @Deprecated( + "This is a temporary function for development/experimentation and will be gone very soon") void configure2kWorkout() async { //Workout workout await _csafeClient!.sendCommands([ @@ -141,7 +163,7 @@ class Ergometer { /// Program a workout into the PM with particular parameters /// - ///Currently only the more basic of workout types are supported, such as basic single intervals, single distance, and single time pieces + /// Currently only the more basic of workout types are supported, such as basic single intervals, single distance, and single time pieces void configureWorkout(Workout workout, [bool startImmediately = true]) async { //Workout workout diff --git a/lib/models/workout.dart b/lib/models/workout.dart index de58e0d..0d57996 100644 --- a/lib/models/workout.dart +++ b/lib/models/workout.dart @@ -1,4 +1,4 @@ -import '../internal/datatypes.dart'; +import '../src/datatypes.dart'; import 'package:csafe_fitness/csafe_fitness.dart'; import 'package:equatable/equatable.dart'; @@ -7,15 +7,14 @@ import 'package:c2bluetooth/enums.dart'; /// Represents a Workout that can be performed on a Concept2 Rowing machine class Workout { - //TODO: add a fromConcept2Type factory to take a concept2 workoutType enum and make a workout using it bool get hasSplits => splitLength != null && !isInterval; bool get hasTargetPace => targetPacePer500 != null; - /// Determine if this workout is an intervals workout or not. - /// + /// Determine if this workout is an intervals workout or not. + /// /// rests.length should be a mostly adequate test, but checking for goal length also helps fix the edge case of undefined rest intervals bool get isInterval => rests.length > 0 || goals.length > 1; diff --git a/lib/internal/commands.dart b/lib/src/commands.dart similarity index 99% rename from lib/internal/commands.dart rename to lib/src/commands.dart index 8727ac0..e4e2ad8 100644 --- a/lib/internal/commands.dart +++ b/lib/src/commands.dart @@ -101,6 +101,7 @@ class CsafePMSetIntervalType extends Concept2Command { // shouldThrow: true); } } + /// A CSAFE command to set a horizontal distance goal /// /// This extends upon the Csafe version of the command in order to add checks for Concept2-specified limits. diff --git a/lib/src/dataplex.dart b/lib/src/dataplex.dart new file mode 100644 index 0000000..cbffb13 --- /dev/null +++ b/lib/src/dataplex.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:c2bluetooth/models/c2datastreamcontroller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; + +import '../constants.dart' as Identifiers; +import './packets/statusdata.dart'; +import './packets/strokedata.dart'; +import './packets/segmentdata.dart'; +import './packets/workoutsummary.dart'; +import './packets/forcecurvepacket.dart'; + +import 'helpers.dart'; +import 'packets/base.dart'; + +/// Handles mapping between data coming from bluetooth notfications and the data the user requested. +/// This gives c2bluetooth a layer of flexibility and decouples the incoming bluetooth data from the output going to an application so that c2bluetooth has space to potentially optimize the data being used +class Dataplex { + // data access speed + + final FlutterReactiveBle _flutterReactiveBle; + + DiscoveredDevice _device; + final ParsePacketFn _parsePacket; + List outgoingStreams = []; + + /// Map of characteristic UUID's to the active subscription instance for that characteristic + Map currentSubscriptions = Map(); + + Set allDatapointIdentifiers = { + ...StatusData.datapointIdentifiers, + ...StatusData1.datapointIdentifiers, + ...StatusData2.datapointIdentifiers, + ...StrokeData.datapointIdentifiers, + ...StrokeData2.datapointIdentifiers, + ...SegmentData1.datapointIdentifiers, + ...SegmentData2.datapointIdentifiers, + ...WorkoutSummary.datapointIdentifiers, + ...WorkoutSummary2.datapointIdentifiers, + ...ForceCurveData.datapointIdentifiers + }; + + /// A map of incoming UUID's to the data keys they support. + Map> characteristicToDataKeyMap = { + Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID: + WorkoutSummary.datapointIdentifiers, + Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID: + WorkoutSummary2.datapointIdentifiers, + }; + + Dataplex( + this._device, + bleClient, { + @visibleForTesting ParsePacketFn? parsePacketFn, + }) : _flutterReactiveBle = bleClient, + _parsePacket = parsePacketFn ?? parsePacket {} + + ///Keeps track of how many characteristics we are currently receiving notifications for + int _currentSubscriptionCount = 0; + + /// Create and return a new stream that provides the requested data + Stream> createStream(Set keysRequested) { + C2DataStreamController controller = + new C2DataStreamController(keysRequested); + + //set up close listener. + controller.onCancel = _generateOutputCloseListener(controller); + + outgoingStreams.add(controller); + + // Multiplexed characteristic during initial stream creation + // TODO: This section will be removed later when _validateStreams is mature + if (currentSubscriptions.isEmpty) { + _addSubscription( + Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID, + Identifiers.C2_ROWING_MULTIPLEXED_INFORMATION_CHARACTERISTIC_UUID, + null); + } + + return controller.stream; + } + + /// Generates a function to remove the provided controller from the outgoing streams list + /// This is useful for handling when consumers of outging streams cloose the streams themselves + FutureOr Function()? _generateOutputCloseListener( + C2DataStreamController controller) { + FutureOr remove() { + outgoingStreams.remove(controller); + } + + return remove; + } + + /// set up a new subscription to data from an erg. + void _addSubscription( + String serviceUuid, String characteristicUuid, int? dataIdentifier) { + var characteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(serviceUuid), + characteristicId: Uuid.parse(characteristicUuid), + deviceId: _device.id); + + // this stream should get cancelled in [dispose] + // ignore: cancel_subscriptions + StreamSubscription sub = _flutterReactiveBle + .subscribeToCharacteristic(characteristic) + .asyncMap((datapoint) => Uint8List.fromList(datapoint)) + .listen((bytes) { + // manually insert an identification byte if this characteristic doesnt have one already + if (dataIdentifier != null) { + bytes.insert(0, dataIdentifier); + } + _readPacket(bytes); + }); + currentSubscriptions.addEntries({characteristicUuid: sub}.entries); + } + + /// Read a packet from an incoming stream (from the erg) and redistribute it to all outgoing streams + void _readPacket(Uint8List data) { + Concept2CharacteristicData? packet = _parsePacket(data); + + if (packet != null) { + //send the data to the outgoing streams + for (var stream in outgoingStreams) { + stream.add(packet.asMap()); + } + } else { + print("Couldnt parse packet from data"); + print( + "packet data: ${data.map((e) => e.toRadixString(16).padLeft(2, '0')).join(',')}"); + } + } + + /// Ensure that we have enough data coming in from the erg to satisfy all the currently requested data + /// + /// if there isnt enough data, set up some new subscriptions for data from the erg. + /// if we have too many subscriptions and the same data can be had with less, then readjust the streams so we are being efficient. + /// + /// For now this will likely just use the multiplexed data since that basically contains everything in one stream and will be easy to implement + void _validateStreams() { + //loop over all outgoingStreams and collect a set of every key that is requested + + Set requestedKeys = {}; + for (var outgoingStream in outgoingStreams) { + requestedKeys.addAll(outgoingStream.datapoint_identifiers.toSet()); + } + + //get a set of what keys are coming in from the active subscriptions + Set incomingUUIDs = currentSubscriptions.keys.toSet(); + + Set incomingKeys = {}; + + for (var uuid in incomingUUIDs) { + Set? identifiers = characteristicToDataKeyMap[uuid]; + if (identifiers != null) { + incomingKeys.addAll(identifiers); + } + } + + // dind the difference of the two to see what we are missing + Set missingKeys = requestedKeys.difference(incomingKeys); + + // do magic to figure out what characteristics to add to get those additional keys + + //make a list of those characteristics + + //find out if we have any unused characteristics + + // + } + + /// closes down this instance by cancelling all streams + void dispose() { + for (var sub in currentSubscriptions.values) { + // clear current subscriptions + sub.cancel(); + } + currentSubscriptions.clear(); + + for (var stream in outgoingStreams) { + // end all current output streams + stream.close(); + } + outgoingStreams.clear(); + } +} diff --git a/lib/internal/datatypes.dart b/lib/src/datatypes.dart similarity index 100% rename from lib/internal/datatypes.dart rename to lib/src/datatypes.dart diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart new file mode 100644 index 0000000..ead0377 --- /dev/null +++ b/lib/src/helpers.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import './packets/statusdata.dart'; +import './packets/strokedata.dart'; +import './packets/segmentdata.dart'; +import './packets/workoutsummary.dart'; +import './packets/forcecurvepacket.dart'; +import './packets/belt.dart'; + +import './packets/base.dart'; + +/// Simplify mocking on [Dataplex] +typedef ParsePacketFn = Concept2CharacteristicData? Function(Uint8List); + +// Allow mapping functions +typedef PacketParser = Concept2CharacteristicData Function(Uint8List data); + +/// Mapping of the first byte of the multiplexed packet to its parser function. +final Map _packetParsers = { + 0x31: (data) => StatusData.fromBytes(data), + 0x32: (data) => StatusData1.fromBytes(data), + 0x33: (data) => StatusData2.fromBytes(data), + 0x35: (data) => StrokeData.fromBytes(data), + 0x36: (data) => StrokeData2.fromBytes(data), + 0x37: (data) => SegmentData1.fromBytes(data), + 0x38: (data) => SegmentData2.fromBytes(data), + 0x39: (data) => WorkoutSummary.fromBytes(data), + 0x3A: (data) => WorkoutSummary1.fromBytes(data), + 0x3B: (data) => HeartRateBelt.fromBytes(data), + 0x3C: (data) => WorkoutSummary2.fromBytes(data), + 0x3D: (data) => ForceCurveData.fromBytes(data), + 0x3E: (data) => StatusData3.fromBytes(data), +}; + +/// Attempts to parse a multiplexed Concept2 data packet. +/// Returns `null` if the packet ID is unknown or the data is empty. +Concept2CharacteristicData? parsePacket(Uint8List data) { + if (data.isEmpty) return null; + final id = data[0]; + final parser = _packetParsers[id]; + debugPrint( + "packet data: ${data.map((e) => e.toRadixString(16).padLeft(2, '0')).join(',')}"); + return parser?.call(data.sublist(1)); +} diff --git a/lib/src/packets/base.dart b/lib/src/packets/base.dart new file mode 100644 index 0000000..bf7b674 --- /dev/null +++ b/lib/src/packets/base.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; +import 'package:c2bluetooth/extensions.dart'; + +import 'keys.dart'; + +/// An empty superclass to represent all types of data formats that come from Concept2 bluetooth characteristics +class Concept2CharacteristicData { + Map asMap() { + return {}; + } +} + +///Represents a data packet from Concept2 that is stamped with a date. +class TimestampedData extends Concept2CharacteristicData { + DateTime timestamp; + + static Set get datapointIdentifiers => + TimestampedData.zero().asMap().keys.toSet(); + + TimestampedData.zero() : this.fromBytes(Uint8List(20)); + + // DatetTime is a modified versions BCD scheme: + // https://github.com/MoralCode/c2-missing-spec/blob/main/concept2-the-missing-spec.md#date-and-time-formats + TimestampedData.fromBytes(Uint8List bytes) + : timestamp = DateTime( + 2000 + ((bytes[1] & 0xFE) >> 1), + bytes[0] & 0x0F, + ((bytes[1] & 0x01) << 4) + ((bytes[0] & 0xF0) >> 4), + bytes[3], + bytes[2]); + + Map asMap() { + Map map = super.asMap(); + map.addAll({Keys.WORKOUT_TIMESTAMP_KEY: timestamp}); + return map; + } +} + +///Represents a data packet from Concept2 that begins with the current elapsed time +class ElapsedtimeStampedData extends Concept2CharacteristicData { + Duration elapsedTime; + + static Set get datapointIdentifiers => + ElapsedtimeStampedData.zero().asMap().keys.toSet(); + + ElapsedtimeStampedData.zero() : this.fromBytes(Uint8List(20)); + + ElapsedtimeStampedData.fromBytes(Uint8List data) + : elapsedTime = Concept2DurationExtension.fromBytes(data.sublist(0, 3)); + + Map asMap() { + Map map = super.asMap(); + map.addAll({Keys.ELAPSED_TIME_KEY: elapsedTime}); + return map; + } +} diff --git a/lib/src/packets/belt.dart b/lib/src/packets/belt.dart new file mode 100644 index 0000000..537edce --- /dev/null +++ b/lib/src/packets/belt.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; + +import 'package:csafe_fitness/csafe_fitness.dart'; + +import './base.dart'; +import 'keys.dart'; + +/// Represents the heart rate belt packet +class HeartRateBelt extends Concept2CharacteristicData { + int manufacturerID; + int deviceType; + int beltID; + + static Set get datapointIdentifiers => + HeartRateBelt.zero().asMap().keys.toSet(); + + /// Construct a WorkoutSummary from the bytes returned from the erg + HeartRateBelt.fromBytes(Uint8List data) + : manufacturerID = data[0], + deviceType = data[1], + beltID = CsafeIntExtension.fromBytes(data.sublist(2, 6)); + + HeartRateBelt.zero() : this.fromBytes(Uint8List(19)); + + Map asMap() => { + Keys.BELT_MANUFACTURER_ID_KEY: manufacturerID, + Keys.BELT_DEVICE_TYPE_KEY: deviceType, + Keys.BELT_DEVICE_ID_KEY: beltID, + }; +} diff --git a/lib/src/packets/forcecurvepacket.dart b/lib/src/packets/forcecurvepacket.dart new file mode 100644 index 0000000..78d90f4 --- /dev/null +++ b/lib/src/packets/forcecurvepacket.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; +import './base.dart'; + +/// Represents a series of force curve data for a stroke +class ForceCurveData extends Concept2CharacteristicData { + List data; + + static Set get datapointIdentifiers => + ForceCurveData.zero().asMap().keys.toSet(); + + ForceCurveData.zero() : this.fromBytes(Uint8List(20)); + + /// Construct a set of ForceCurveData from the bytes returned from the erg + ForceCurveData.fromBytes(Uint8List data) : this.data = data.toList(); +} diff --git a/lib/src/packets/keys.dart b/lib/src/packets/keys.dart new file mode 100644 index 0000000..7d8f0a2 --- /dev/null +++ b/lib/src/packets/keys.dart @@ -0,0 +1,85 @@ +class Keys { + static const ELAPSED_TIME_KEY = "general.elapsed_time"; + static const ELAPSED_DISTANCE_KEY = "general.distance"; + + static const STATE_SEGMENT_TYPE_KEY = "state.interval.type"; + static const STATE_WORKOUT_KEY = "state.workout"; + static const STATE_WORKOUT_TYPE_KEY = "state.workout_type"; + static const STATE_ROWING_KEY = "state.rowing_activity"; + static const STATE_ROWING_STROKE_KEY = "state.rowing_stroke"; + static const STATE_OPERATIONAL_STATE_KEY = "state.operational_state"; + static const STATE_WORKOUT_VERIFICATION_KEY = "state.workout_verification"; + static const STATE_SCREEN_NUMBER_KEY = "state.screen.number"; + static const STATE_LAST_ERROR_KEY = "state.error.last"; + static const STATE_CALIBRATION_MODE_KEY = "state.calibration.mode"; + static const STATE_CALIBRATION_KEY = "state.calibration"; + static const STATE_CALIBRATION_STATUS_KEY = "state.calibration.status"; + static const STATE_GAME_ID_KEY = "state.game.id"; + static const STATE_GAME_SCORE_KEY = "state.game.score"; + static const SUMMARY_AVG_PACE_KEY = "summary.pace.average"; + + static const BELT_MANUFACTURER_ID_KEY = "belt.manufacturer"; + static const BELT_DEVICE_TYPE_KEY = "belt.type"; + static const BELT_DEVICE_ID_KEY = "belt.id"; + + static const WORKOUT_DURATION_UNIT_KEY = "workout_duration.type"; + static const WORKOUT_DURATION_KEY = "workout_duration"; + + static const WORKOUT_MACHINE_TYPE_KEY = "workout.machine_type"; + static const WORKOUT_DRAG_FACTOR_KEY = "workout.drag_factor"; + static const WORKOUT_DISTANCE_KEY = "workout.distance"; + static const WORKOUT_TOTAL_DISTANCE_KEY = "workout.distance.total"; + static const WORKOUT_TIMESTAMP_KEY = "workout.timestamp"; + static const WORKOUT_SPM_KEY = "workout.stroke_rate"; + static const WORKOUT_AVG_SPM_KEY = "workout.stroke_rate.average"; + static const WORKOUT_HR_KEY = "workout.heart_rate"; + static const WORKOUT_LAST_HR_KEY = "workout.heart_rate.last"; + static const WORKOUT_AVG_HR_KEY = "workout.heart_rate.average"; + static const WORKOUT_MIN_HR_KEY = "workout.heart_rate.min"; + static const WORKOUT_MAX_HR_KEY = "workout.heart_rate.max"; + static const WORKOUT_AVG_POWER_KEY = "workout.power.average"; + static const WORKOUT_AVG_PACE_KEY = "workout.pace.average"; + static const WORKOUT_AVG_DRAGFACTOR_KEY = "workout.drag_factor.average"; + static const WORKOUT_RECOVERY_HR_KEY = "workout.heart_rate.recovery"; + static const WORKOUT_PROJECTED_TIME_KEY = 'workout.projected_work.time'; + static const WORKOUT_PROJECTED_DISTANCE_KEY = + 'workout.projected_work.distance'; + + static const WORKOUT_SEGMENT_COUNT_KEY = "workout.segment_count"; + static const WORKOUT_SEGMENT_SIZE_KEY = "workout.segment_size"; + static const WORKOUT_CALORIES_KEY = "workout.calories"; + static const WORKOUT_SPEED_KEY = "workout.speed"; + static const WORKOUT_PACE_KEY = "workout.pace"; + static const WORKOUT_POWER_KEY = "workout.watts"; + static const STROKE_DRIVE_LENGTH_KEY = 'stroke.drive_length'; + static const STROKE_DRIVE_TIME_KEY = 'stroke.drive_time'; + static const STROKE_RECOVERY_TIME_KEY = 'stroke.recovery_time'; + static const STROKE_DISTANCE_KEY = 'stroke.stroke_distance'; + static const STROKE_PEAK_FORCE_KEY = 'stroke.drive_force.peak'; + static const STROKE_AVG_FORCE_KEY = 'stroke.drive_force.average'; + static const STROKE_COUNT_KEY = 'stroke.count'; + static const STROKE_ENERGY_KEY = 'stroke.energy'; + // rests are only applicable for intervals workouts + static const WORKOUT_REST_DISTANCE_KEY = "workout.rest_distance"; + static const WORKOUT_REST_TIME_KEY = "workout.rest_time"; + static const WORKOUT_AVG_CALORIES_KEY = "workout.calories.average"; + + static const SEGMENT_TIME_KEY = "segment.time"; + static const SEGMENT_DISTANCE_KEY = "segment.distance"; + static const SEGMENT_LAST_TIME_KEY = "segment.time.last"; + static const SEGMENT_LAST_DISTANCE_KEY = "segment.distance.last"; + static const SEGMENT_REST_TIME_KEY = "segment.interval.rest_time"; + static const SEGMENT_REST_DISTANCE_KEY = "segment.interval.rest_distance"; + static const SEGMENT_TYPE_KEY = "segment.type"; + static const SEGMENT_NUMBER_KEY = "segment.number"; + static const SEGMENT_AVG_SPM_KEY = "segment.stroke_rate.average"; + static const SEGMENT_WORK_HR_KEY = "segment.work_heart_rate"; + static const SEGMENT_REST_HR_KEY = "segment.rest_heart_rate"; + static const SEGMENT_AVG_PACE_KEY = "segment.pace.average"; + static const SEGMENT_CALORIES_KEY = "segment.calories"; + static const SEGMENT_AVG_CALORIES_KEY = "segment.calories.average"; + static const SEGMENT_SPEED_KEY = "segment.speed"; + static const SEGMENT_POWER_KEY = "segment.power"; + static const SEGMENT_AVG_POWER_KEY = "segment.power.average"; + static const SEGMENT_AVG_DRAGFACTOR_KEY = "segment.drag_factor.average"; +} diff --git a/lib/src/packets/segmentdata.dart b/lib/src/packets/segmentdata.dart new file mode 100644 index 0000000..0d1b05e --- /dev/null +++ b/lib/src/packets/segmentdata.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; +import 'package:c2bluetooth/enums.dart'; +import 'package:csafe_fitness/csafe_fitness.dart'; +import 'keys.dart'; +import './base.dart'; + +/// Represents a packet containing data for a "Segment" of a workout. +/// +/// Segment refers to the concept of "split or interval" from Concept2's specification since the two are mutually exclusive. +/// +/// Both segment data packets seem to start with the elapsed time have the [segmentNumber] stored at byte 18, so this class abstracts those two fields +class SharedSegmentData extends ElapsedtimeStampedData { + int segmentNumber; + SharedSegmentData.fromBytes(Uint8List data) + : segmentNumber = data.elementAt(17), + super.fromBytes(data); + + Map asMap() { + Map map = super.asMap(); + map.addAll({Keys.SEGMENT_NUMBER_KEY: segmentNumber}); + return map; + } +} + +/// Represents the first kind of [SegmentData] packet containing part of the full set of data about a segment of a workout +class SegmentData1 extends SharedSegmentData { + double elapsedDistance; + double segmentTime; + int segmentDistance; + int intervalRestTime; + int intervalRestDistance; + IntervalType segmentType; + + static Set get datapointIdentifiers => + SegmentData1.zero().asMap().keys.toSet(); + + SegmentData1.zero() : this.fromBytes(Uint8List(20)); + + SegmentData1.fromBytes(Uint8List data) + : elapsedDistance = CsafeIntExtension.fromBytes(data.sublist(3, 6), + endian: Endian.little) / + 10, + segmentTime = CsafeIntExtension.fromBytes(data.sublist(6, 9), + endian: Endian.little) / + 10, + segmentDistance = CsafeIntExtension.fromBytes(data.sublist(9, 12), + endian: Endian.little), + intervalRestTime = CsafeIntExtension.fromBytes(data.sublist(12, 14), + endian: Endian.little), + intervalRestDistance = CsafeIntExtension.fromBytes(data.sublist(14, 16), + endian: Endian.little), + segmentType = IntervalTypeExtension.fromInt(data.elementAt(16)), + super.fromBytes(data); + + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.ELAPSED_DISTANCE_KEY: elapsedDistance, + Keys.SEGMENT_TIME_KEY: segmentTime, + Keys.SEGMENT_DISTANCE_KEY: segmentDistance, + Keys.SEGMENT_TYPE_KEY: segmentType, + Keys.SEGMENT_REST_TIME_KEY: intervalRestTime + }); + return map; + } +} + +/// Represents the second kind of [SegmentData] packet containing the remaining part of the full set of data about a segment of a workout +class SegmentData2 extends SharedSegmentData { + int segmentAvgStrokeRate; + int segmentWorkHeartRate; + int segmentRestHeartRate; + double segmentAveragePace; + int segmentTotalCalories; + int segmentAverageCalories; + double segmentSpeed; + int segmentPower; + int splitAverageDragFactor; + MachineType machineType; + + static Set get datapointIdentifiers => + SegmentData2.zero().asMap().keys.toSet(); + + SegmentData2.zero() : this.fromBytes(Uint8List(20)); + + SegmentData2.fromBytes(Uint8List data) + : segmentAvgStrokeRate = data.elementAt(3), + segmentWorkHeartRate = data.elementAt(4), + segmentRestHeartRate = data.elementAt(5), + segmentAveragePace = CsafeIntExtension.fromBytes(data.sublist(6, 8), + endian: Endian.little) / + 10, + segmentTotalCalories = CsafeIntExtension.fromBytes(data.sublist(8, 10), + endian: Endian.little), + segmentAverageCalories = CsafeIntExtension.fromBytes( + data.sublist(10, 12), + endian: Endian.little), + segmentSpeed = CsafeIntExtension.fromBytes(data.sublist(12, 14), + endian: Endian.little) / + 1000, + segmentPower = CsafeIntExtension.fromBytes(data.sublist(14, 16), + endian: Endian.little), + splitAverageDragFactor = data.elementAt(16), + machineType = MachineTypeExtension.fromInt(data.elementAt(18)), + super.fromBytes(data); + + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.SEGMENT_AVG_SPM_KEY: segmentAvgStrokeRate, + Keys.SEGMENT_WORK_HR_KEY: segmentWorkHeartRate, + Keys.SEGMENT_REST_HR_KEY: segmentRestHeartRate, + Keys.SEGMENT_AVG_PACE_KEY: segmentAveragePace, + Keys.SEGMENT_CALORIES_KEY: segmentTotalCalories, + Keys.SEGMENT_AVG_CALORIES_KEY: segmentAverageCalories, + Keys.SEGMENT_SPEED_KEY: segmentSpeed, + Keys.SEGMENT_POWER_KEY: segmentPower, + Keys.SEGMENT_AVG_DRAGFACTOR_KEY: splitAverageDragFactor + }); + return map; + } +} diff --git a/lib/src/packets/statusdata.dart b/lib/src/packets/statusdata.dart new file mode 100644 index 0000000..55675fe --- /dev/null +++ b/lib/src/packets/statusdata.dart @@ -0,0 +1,216 @@ +import 'dart:typed_data'; +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:csafe_fitness/csafe_fitness.dart'; + +import './base.dart'; + +class StatusData extends ElapsedtimeStampedData { + final double distance; // 0x01 = 0.1 meters + final WorkoutType workoutType; + final IntervalType intervalType; + final WorkoutState workoutState; + final RowingState rowingState; + final StrokeState strokeState; + final int totalWorkDistance; // meters + final double workoutDuration; // FIXME: Can also be Duration + final DurationType durationType; + final int dragFactor; + + static Set get datapointIdentifiers => + StatusData.zero().asMap().keys.toSet(); + + StatusData.zero() : this.fromBytes(Uint8List(19)); + + StatusData.fromBytes(Uint8List data) + : distance = CsafeIntExtension.fromBytes(data.sublist(3, 6), + endian: Endian.little) / + 10.0, + workoutType = WorkoutTypeExtension.fromInt(data[6]), + intervalType = IntervalTypeExtension.fromInt(data[7]), + workoutState = WorkoutStateExtension.fromInt(data[8]), + rowingState = RowingStateExtension.fromInt(data[9]), + strokeState = StrokeStateExtension.fromInt(data[10]), + totalWorkDistance = CsafeIntExtension.fromBytes(data.sublist(11, 14), + endian: Endian.little), + durationType = DurationTypeExtension.fromInt(data[17]), + workoutDuration = CsafeIntExtension.fromBytes(data.sublist(14, 17), + endian: Endian.little) / + (DurationTypeExtension.fromInt(data[17]) == DurationType.TIME + ? 100.0 + : 1), + dragFactor = data[18], + super.fromBytes(data); + + @override + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.ELAPSED_DISTANCE_KEY: distance, + Keys.STATE_WORKOUT_TYPE_KEY: workoutType, + Keys.STATE_SEGMENT_TYPE_KEY: intervalType, + Keys.STATE_WORKOUT_KEY: workoutState, + Keys.STATE_ROWING_KEY: rowingState, + Keys.STATE_ROWING_STROKE_KEY: strokeState, + Keys.WORKOUT_TOTAL_DISTANCE_KEY: totalWorkDistance, + Keys.WORKOUT_DURATION_KEY: workoutDuration, + Keys.WORKOUT_DURATION_UNIT_KEY: durationType, + Keys.WORKOUT_DRAG_FACTOR_KEY: dragFactor, + }); + return map; + } +} + +class StatusData1 extends ElapsedtimeStampedData { + final double speed; // 0x01 = 0.001 m/s + final int strokeRate; // strokes/min + final int heartRate; // bpm, 255=invalid + final Duration currentPace; // 0x01 = 0.01 sec per 500m + final Duration averagePace; // 0x01 = 0.01 sec per 500m + final int restDistance; // meters + final Duration restTime; // 0x01 = 0.01 seconds + final int averagePower; // watts + final MachineType ergMachineType; + + static Set get datapointIdentifiers => + StatusData1.zero().asMap().keys.toSet(); + + StatusData1.zero() : this.fromBytes(Uint8List(19)); + + StatusData1.fromBytes(Uint8List data) + : speed = CsafeIntExtension.fromBytes(data.sublist(3, 5), + endian: Endian.little) / + 1000.0, + strokeRate = data[5], + heartRate = data[6], + currentPace = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(7, 9), + endian: Endian.little) * + 10), + averagePace = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(9, 11), + endian: Endian.little) * + 10), + restDistance = CsafeIntExtension.fromBytes(data.sublist(11, 13), + endian: Endian.little), + restTime = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(13, 16), + endian: Endian.little) * + 10), + averagePower = CsafeIntExtension.fromBytes(data.sublist(16, 18), + endian: Endian.little), + ergMachineType = MachineTypeExtension.fromInt(data[18]), + super.fromBytes(data); + + @override + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.WORKOUT_SPEED_KEY: speed, + Keys.WORKOUT_SPM_KEY: strokeRate, + Keys.WORKOUT_HR_KEY: heartRate, + Keys.WORKOUT_PACE_KEY: currentPace, + Keys.WORKOUT_AVG_PACE_KEY: averagePace, + Keys.WORKOUT_REST_DISTANCE_KEY: restDistance, + Keys.WORKOUT_REST_TIME_KEY: restTime, + Keys.WORKOUT_AVG_POWER_KEY: averagePower, + Keys.WORKOUT_MACHINE_TYPE_KEY: ergMachineType, + }); + return map; + } +} + +class StatusData2 extends ElapsedtimeStampedData { + final int intervalCount; + final int totalCalories; // cals + final Duration splitAvgPace; // 0x01 = 0.01 sec per 500m + final int splitAvgPower; // watts + final int splitAvgCalories; // cals + final Duration lastSplitTime; // 0x01 = 0.1 seconds + final int lastSplitDistance; // meters + + static Set get datapointIdentifiers => + StatusData2.zero().asMap().keys.toSet(); + + StatusData2.zero() : this.fromBytes(Uint8List(20)); + + StatusData2.fromBytes(Uint8List data) + : intervalCount = data[3], + totalCalories = CsafeIntExtension.fromBytes(data.sublist(4, 6), + endian: Endian.little), + splitAvgPace = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(6, 8), + endian: Endian.little) * + 10), + splitAvgPower = CsafeIntExtension.fromBytes(data.sublist(8, 10), + endian: Endian.little), + splitAvgCalories = CsafeIntExtension.fromBytes(data.sublist(10, 12), + endian: Endian.little), + lastSplitTime = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(12, 15), + endian: Endian.little) * + 100), + lastSplitDistance = CsafeIntExtension.fromBytes(data.sublist(15, 17), + endian: Endian.little), + super.fromBytes(data); + + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.SEGMENT_NUMBER_KEY: intervalCount, + Keys.WORKOUT_CALORIES_KEY: totalCalories, + Keys.SEGMENT_AVG_PACE_KEY: splitAvgPace, + Keys.SEGMENT_AVG_POWER_KEY: splitAvgPower, + Keys.SEGMENT_AVG_CALORIES_KEY: splitAvgCalories, + Keys.SEGMENT_LAST_TIME_KEY: lastSplitTime, + Keys.SEGMENT_LAST_DISTANCE_KEY: lastSplitDistance, + }); + return map; + } +} + +class StatusData3 extends Concept2CharacteristicData { + final OperationalState operationalState; + final int workoutVerificationState; + final int screenNumber; + final int lastError; + final int calibrationMode; + final int calibrationState; + final int calibrationStatus; + final GameId gameID; + final int gameScore; + + static Set get datapointIdentifiers => + StatusData2.zero().asMap().keys.toSet(); + + StatusData3.zero() : this.fromBytes(Uint8List(20)); + + StatusData3.fromBytes(Uint8List data) + : operationalState = OperationalStateExtension.fromInt(data[0]), + workoutVerificationState = data[1], + screenNumber = CsafeIntExtension.fromBytes(data.sublist(2, 4), + endian: Endian.little), + lastError = CsafeIntExtension.fromBytes(data.sublist(4, 6), + endian: Endian.little), + calibrationMode = data[6], + calibrationState = data[7], + calibrationStatus = data[8], + gameID = GameIdExtension.fromInt(data[9]), + gameScore = CsafeIntExtension.fromBytes(data.sublist(10, 12), + endian: Endian.little); + + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.STATE_OPERATIONAL_STATE_KEY: operationalState, + Keys.STATE_WORKOUT_VERIFICATION_KEY: workoutVerificationState, + Keys.STATE_SCREEN_NUMBER_KEY: screenNumber, + Keys.STATE_LAST_ERROR_KEY: lastError, + Keys.STATE_CALIBRATION_MODE_KEY: calibrationMode, + Keys.STATE_CALIBRATION_KEY: calibrationState, + Keys.STATE_CALIBRATION_STATUS_KEY: calibrationStatus, + Keys.STATE_GAME_ID_KEY: gameID, + Keys.STATE_GAME_SCORE_KEY: gameScore, + }); + return map; + } +} diff --git a/lib/src/packets/strokedata.dart b/lib/src/packets/strokedata.dart new file mode 100644 index 0000000..c6ef70e --- /dev/null +++ b/lib/src/packets/strokedata.dart @@ -0,0 +1,108 @@ +import 'dart:typed_data'; + +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:csafe_fitness/csafe_fitness.dart'; + +import './base.dart'; + +class StrokeData extends ElapsedtimeStampedData { + static Set get datapointIdentifiers => + StrokeData.zero().asMap().keys.toSet(); + final double distance; // 0x01 = 0.1 meters + final double driveLength; // 0x01 = 0.01 meters, max = 2.55m + final Duration driveTime; // 0x01 = 0.01 sec, max = 2.55 sec + final Duration recoveryTime; // 0x01 = 0.01 sec, max = 655.35 sec + final double strokeDistance; // 0x01 = 0.01 m, max=655.35m + final double peakForce; // 0x01 = 0.1 lbs of force, max=655.35 lbs + final double averageForce; // 0x01 = 0.1 lbs of force, max=655.35 lbs + final int strokeCount; + + StrokeData.zero() : this.fromBytes(Uint8List(20)); + + StrokeData.fromBytes(Uint8List data) + : distance = CsafeIntExtension.fromBytes(data.sublist(3, 6), + endian: Endian.little) / + 10.0, + driveLength = data[6] / 100.0, + driveTime = Duration( + seconds: data[7] ~/ 100, milliseconds: data[7].remainder(100)), + recoveryTime = Duration( + milliseconds: CsafeIntExtension.fromBytes(data.sublist(8, 10), + endian: Endian.little) * + 10), + strokeDistance = CsafeIntExtension.fromBytes(data.sublist(10, 12), + endian: Endian.little) / + 10.0, + peakForce = CsafeIntExtension.fromBytes(data.sublist(12, 14), + endian: Endian.little) / + 10.0, + averageForce = CsafeIntExtension.fromBytes(data.sublist(14, 16), + endian: Endian.little) / + 10.0, + strokeCount = CsafeIntExtension.fromBytes(data.sublist(16, 18), + endian: Endian.little), + super.fromBytes(data); + + @override + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.ELAPSED_DISTANCE_KEY: distance, + Keys.STROKE_DRIVE_LENGTH_KEY: driveLength, + Keys.STROKE_DRIVE_TIME_KEY: driveTime, + Keys.STROKE_RECOVERY_TIME_KEY: recoveryTime, + Keys.STROKE_DISTANCE_KEY: strokeDistance, + Keys.STROKE_PEAK_FORCE_KEY: peakForce, + Keys.STROKE_AVG_FORCE_KEY: averageForce, + Keys.STROKE_COUNT_KEY: strokeCount, + }); + + return map; + } +} + +/// Additional stroke data from characteristic 0x0036 +class StrokeData2 extends ElapsedtimeStampedData { + static Set get datapointIdentifiers => + StrokeData2.zero().asMap().keys.toSet(); + final int strokePower; // watts + final int strokeCalories; // cals/hr + final int strokeCount; + final Duration projectedWorkTime; // secs + final int projectedWorkDistance; // meters + final double workPerStroke; // 0x01 = 0.1 Joules, max=6553.5 Joules + + StrokeData2.zero() : this.fromBytes(Uint8List(20)); + + StrokeData2.fromBytes(Uint8List data) + : strokePower = CsafeIntExtension.fromBytes(data.sublist(3, 5), + endian: Endian.little), + strokeCalories = CsafeIntExtension.fromBytes(data.sublist(5, 7), + endian: Endian.little), + strokeCount = CsafeIntExtension.fromBytes(data.sublist(7, 9), + endian: Endian.little), + projectedWorkTime = Duration( + seconds: CsafeIntExtension.fromBytes(data.sublist(9, 12), + endian: Endian.little)), + projectedWorkDistance = CsafeIntExtension.fromBytes( + data.sublist(12, 15), + endian: Endian.little), + workPerStroke = CsafeIntExtension.fromBytes(data.sublist(15, 17), + endian: Endian.little) / + 10.0, + super.fromBytes(data); + + @override + Map asMap() { + Map map = super.asMap(); + map.addAll({ + Keys.WORKOUT_POWER_KEY: strokePower, + Keys.WORKOUT_CALORIES_KEY: strokeCalories, + Keys.STROKE_COUNT_KEY: strokeCount, + Keys.WORKOUT_PROJECTED_TIME_KEY: projectedWorkTime, + Keys.WORKOUT_PROJECTED_DISTANCE_KEY: projectedWorkDistance, + Keys.STROKE_ENERGY_KEY: workPerStroke, + }); + return map; + } +} diff --git a/lib/src/packets/workoutsummary.dart b/lib/src/packets/workoutsummary.dart new file mode 100644 index 0000000..ce75b97 --- /dev/null +++ b/lib/src/packets/workoutsummary.dart @@ -0,0 +1,166 @@ +import 'dart:typed_data'; + +import 'package:c2bluetooth/extensions.dart'; +import 'package:csafe_fitness/csafe_fitness.dart'; + +import 'package:c2bluetooth/enums.dart'; +import './base.dart'; +import 'keys.dart'; + +/// Represents a summary of a completed workout +/// +/// This takes care of processesing the raw byte data from workout summary characteristics into easily accessible fields. This class also takes care of things like byte endianness, combining multiple high and low bytes .etc, allowing applications to access things in terms of flutter native types. +class WorkoutSummary extends TimestampedData { + Duration elapsedTime; + double workDistance; + int avgSPM; + int endHeartRate; + int avgHeartRate; + int minHeartRate; + int maxHeartRate; + int avgDragFactor; + int recoveryHeartRate; + WorkoutType workoutType; + double avgPace; + + static Set get datapointIdentifiers => + WorkoutSummary.zero().asMap().keys.toSet(); + + /// Construct a WorkoutSummary from the bytes returned from the erg + WorkoutSummary.fromBytes(Uint8List data) + : elapsedTime = Concept2DurationExtension.fromBytes(data.sublist(4, 7)), + workDistance = CsafeIntExtension.fromBytes(data.sublist(7, 10), + endian: Endian.little) / + 10, + avgSPM = data.elementAt(10), + endHeartRate = data.elementAt(11), + avgHeartRate = data.elementAt(12), + minHeartRate = data.elementAt(13), + maxHeartRate = data.elementAt(14), + avgDragFactor = data.elementAt(15), + recoveryHeartRate = data.elementAt(16), + workoutType = WorkoutTypeExtension.fromInt(data.elementAt(17)), + avgPace = CsafeIntExtension.fromBytes(data.sublist(18), + endian: Endian.little) / + 10, + super.fromBytes(data); + + WorkoutSummary.zero() : this.fromBytes(Uint8List(20)); + + Map asMap() { + // workout.date + // workout.time + + // workout.heart_rate + // workout.spl-int_count + // workout.spl-int_size + // workout.calories + // workout.watts + // workout.rest_distance + // workout.interval_rest_distance + // workout.rest_time + // workout.calories.average + Map map = super.asMap(); + map.addAll({ + Keys.ELAPSED_TIME_KEY: elapsedTime, + Keys.WORKOUT_DISTANCE_KEY: workDistance, + Keys.WORKOUT_AVG_SPM_KEY: avgSPM, + Keys.WORKOUT_LAST_HR_KEY: endHeartRate, + Keys.WORKOUT_AVG_HR_KEY: avgHeartRate, + Keys.WORKOUT_MIN_HR_KEY: minHeartRate, + Keys.WORKOUT_MAX_HR_KEY: maxHeartRate, + Keys.WORKOUT_AVG_PACE_KEY: avgPace, + Keys.WORKOUT_AVG_DRAGFACTOR_KEY: avgDragFactor, + Keys.WORKOUT_RECOVERY_HR_KEY: recoveryHeartRate, + // workoutType, + // "something.something.average": + }); + return map; + } +} + +class WorkoutSummary1 extends TimestampedData { + int intervalSize; + int intervalCount; + int totalCalories; + int watts; + int totalRestDistance; + int intervalRestTime; + int avgCalories; + + static Set get datapointIdentifiers => + WorkoutSummary1.zero().asMap().keys.toSet(); + + WorkoutSummary1.fromBytes(Uint8List data) + : intervalSize = CsafeIntExtension.fromBytes(data.sublist(4, 6), + endian: Endian.little), + intervalCount = data.elementAt(6), + totalCalories = CsafeIntExtension.fromBytes(data.sublist(7, 9), + endian: Endian.little), + watts = CsafeIntExtension.fromBytes(data.sublist(9, 11), + endian: Endian.little), + totalRestDistance = CsafeIntExtension.fromBytes(data.sublist(11, 13), + endian: Endian.little), + intervalRestTime = CsafeIntExtension.fromBytes(data.sublist(13, 16), + endian: Endian.little), + avgCalories = CsafeIntExtension.fromBytes(data.sublist(16, 18), + endian: Endian.little), + super.fromBytes(data); + + WorkoutSummary1.zero() : this.fromBytes(Uint8List(18)); + + Map asMap() { + // workout.heart_rate + // workout.interval_rest_distance + Map map = super.asMap(); + map.addAll({ + Keys.WORKOUT_SEGMENT_COUNT_KEY: intervalSize, + Keys.WORKOUT_SEGMENT_SIZE_KEY: intervalCount, + Keys.WORKOUT_CALORIES_KEY: totalCalories, + Keys.WORKOUT_POWER_KEY: watts, + Keys.WORKOUT_REST_DISTANCE_KEY: totalRestDistance, + // "workout.interval_rest_distance": , + Keys.WORKOUT_REST_TIME_KEY: intervalRestTime, + Keys.WORKOUT_AVG_CALORIES_KEY: avgCalories + }); + return map; + } +} + +class WorkoutSummary2 extends TimestampedData { + int avgPace; + GameId gameID; + int verifier; + int gameScore; + MachineType machineType; + + static Set get datapointIdentifiers => + WorkoutSummary2.zero().asMap().keys.toSet(); + + WorkoutSummary2.zero() : this.fromBytes(Uint8List(10)); + + WorkoutSummary2.fromBytes(Uint8List data) + : avgPace = CsafeIntExtension.fromBytes(data.sublist(4, 6), + endian: Endian.little), + gameID = GameIdExtension.fromInt(data.elementAt(6) & 0x0F), + verifier = (data.elementAt(6) & 0xF0) >> 4, + gameScore = CsafeIntExtension.fromBytes(data.sublist(7, 9), + endian: Endian.little), + machineType = MachineTypeExtension.fromInt(data.elementAt(9)), + super.fromBytes(data); + + Map asMap() { + // workout.heart_rate + // workout.interval_rest_distance + Map map = super.asMap(); + map.addAll({ + Keys.SUMMARY_AVG_PACE_KEY: avgPace, + Keys.STATE_GAME_ID_KEY: gameID, + Keys.STATE_WORKOUT_VERIFICATION_KEY: verifier, + // "workout.interval_rest_distance": , + Keys.STATE_GAME_SCORE_KEY: gameScore, + Keys.WORKOUT_MACHINE_TYPE_KEY: machineType + }); + return map; + } +} diff --git a/lib/internal/validators.dart b/lib/src/validators.dart similarity index 77% rename from lib/internal/validators.dart rename to lib/src/validators.dart index 758394c..6b27319 100644 --- a/lib/internal/validators.dart +++ b/lib/src/validators.dart @@ -1,5 +1,5 @@ import 'package:c2bluetooth/c2bluetooth.dart'; -import '../internal/datatypes.dart'; +import '../src/datatypes.dart'; import 'package:csafe_fitness/csafe_fitness.dart'; //TODO: validate data as a big endian IntegerWithUnits-like type with DurationType as the unit @@ -11,5 +11,6 @@ Validator validateC2SplitGoal() { (data is Concept2IntegerWithUnits) && (data.unit == DurationType.DISTANCE || data.unit == DurationType.TIME), - (data) => ArgumentError("Value provided must be in either a distance or a time unit")); + (data) => ArgumentError( + "Value provided must be in either a distance or a time unit")); } diff --git a/pubspec.yaml b/pubspec.yaml index f945a96..1f1b92c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.1.6 repository: https://github.com/CrewLAB/c2bluetooth environment: - sdk: ">=2.15.0 <3.0.0" + sdk: '>=3.2.0 <4.0.0' flutter: ">=1.17.0" dependencies: @@ -12,13 +12,15 @@ dependencies: flutter: sdk: flutter - flutter_ble_lib_ios_15: ^2.5.2 csafe_fitness: ^0.1.0 rxdart: ^0.27.3 + flutter_reactive_ble: ^5.0.2 dev_dependencies: + csv: ^5.0.1 flutter_test: sdk: flutter + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/constants_test.dart b/test/constants_test.dart new file mode 100644 index 0000000..f162f69 --- /dev/null +++ b/test/constants_test.dart @@ -0,0 +1,20 @@ +import 'package:c2bluetooth/constants.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Map dummyDataKeyToCharacteristicMap = { + "something.something": 0xAB, + "something.something.average": 0xAB + }; + + Map> dummySwappedMap = { + 0xAB: {"something.something", "something.something.average"} + }; + + group("getCharacteristicToDataKeysMap - ", () { + test("test returns expected value", () { + expect(getCharacteristicToDataKeysMap(dummyDataKeyToCharacteristicMap), + dummySwappedMap); + }); + }); +} diff --git a/test/dataparsing_test.dart b/test/dataparsing_test.dart new file mode 100644 index 0000000..9306b0e --- /dev/null +++ b/test/dataparsing_test.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; +import 'dart:convert'; +import 'dart:io'; + +import '../lib/src/packets/statusdata.dart'; + +import '../lib/src/packets/base.dart'; +import '../lib/src/helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:csv/csv.dart'; +// import '../lib/src/helpers.dart'; +// import '../lib/models/ergblemanager.dart'; + +Future> getCsvData(String filename) async { + final input = new File(filename).openRead(); + final fields = await input + .transform(utf8.decoder) + .transform(new CsvToListConverter(eol: '\n')) + .toList(); + return fields; +} + +void main() { + group("test-multiplex-data6", () { + test('- print', () async { + // print(fields); + + final fields = await getCsvData('./test/test-multiplex-data6.csv'); + + for (List row in fields) { + List ints = row.cast(); + Uint8List data = Uint8List.fromList(ints); + Concept2CharacteristicData? packet = parsePacket(data); + if (packet != null) { + if (packet is ElapsedtimeStampedData) { + print("elapsed time :${packet.elapsedTime.toString()}"); + } + + if (packet is StatusData1) { + print("speed ${packet.speed.toString()}"); + } + + if (packet is StatusData2) { + print("intervalcount ${packet.intervalCount.toString()}"); + } + } + } + }); + }); +} diff --git a/test/ergblemanager_test.dart b/test/ergblemanager_test.dart deleted file mode 100644 index 279d749..0000000 --- a/test/ergblemanager_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -// import '../lib/models/ergblemanager.dart'; - -void main() { - test('can obtain stream of ergometers present', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); - - test('does not recognize non-concept2 devices', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); -} diff --git a/test/ergometer_test.dart b/test/ergometer_test.dart deleted file mode 100644 index f88884f..0000000 --- a/test/ergometer_test.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -// import '../lib/models/ergometer.dart'; - -void main() { - test('instantiate from a peripheral', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); - test('can provide WorkoutSummary data', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); -} diff --git a/test/helpers_test.dart b/test/helpers_test.dart index 8474a59..adbdf45 100644 --- a/test/helpers_test.dart +++ b/test/helpers_test.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:c2bluetooth/helpers.dart'; import 'package:c2bluetooth/models/workout.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/models/ergblemanager_test.dart b/test/models/ergblemanager_test.dart new file mode 100644 index 0000000..abe4d95 --- /dev/null +++ b/test/models/ergblemanager_test.dart @@ -0,0 +1,61 @@ +import 'dart:typed_data'; + +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:c2bluetooth/constants.dart' as Identifiers; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterReactiveBle extends Mock implements FlutterReactiveBle {} + +void main() { + setUp(() {}); + test('translate the stream of discovered devices as ergometers', () { + /// The whole purpose of the startErgScan method is to translate + /// FlutterReactiveBle stream of DiscoveredDevice into Ergometer objects. + /// + /// - non-PM5 devices are already filtered-out by FlutterReactiveBle + /// - during subscribing we return a fake status data + + /// declare ErgBleManager with a mocked Reactive Ble + final mockReactive = MockFlutterReactiveBle(); + final ble = ErgBleManager.withDependency(bleClient: mockReactive); + + /// create a fake stream of Discovered devices matching C2_ROWING_BASE_UUID service + final fakePM_1 = DiscoveredDevice( + id: 'xxxx', + name: 'PM5_1', + serviceUuids: [Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)], + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 0, 0]), + rssi: 10); + final fakePM_2 = DiscoveredDevice( + id: 'yyyy', + name: 'PM5_2', + serviceUuids: [Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)], + serviceData: {}, + manufacturerData: Uint8List.fromList([2, 0, 0]), + rssi: 10); + final fakeScan = Stream.fromIterable([fakePM_1, fakePM_2]); + + /// Adding mock answer from the [FlutterReactiveBle] + when(() => mockReactive.scanForDevices( + withServices: any( + named: "withServices", + that: predicate>((services) => services + .contains(Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)))))) + .thenAnswer((_) => fakeScan); + when(() => mockReactive.statusStream) + .thenAnswer((_) => Stream.value(BleStatus.ready)); + + /// Ensure DiscoveredDevice events are translated as Ergometer events + /// we expect only them in matching order + expect( + ble.startErgScan(), + emitsInOrder([ + predicate((e) => e.name == fakePM_1.name), + predicate((e) => e.name == fakePM_2.name), + emitsDone, + ])); + }); +} diff --git a/test/models/ergometer_test.dart b/test/models/ergometer_test.dart new file mode 100644 index 0000000..8ca31f6 --- /dev/null +++ b/test/models/ergometer_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:c2bluetooth/constants.dart' as Identifiers; +import 'package:c2bluetooth/src/dataplex.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterReactiveBle extends Mock implements FlutterReactiveBle {} + +class FakeQualifiedCharacteristic extends Fake + implements QualifiedCharacteristic {} + +class FakeDataplex extends Fake implements Dataplex {} + +late MockFlutterReactiveBle mockBle; +late StreamController> characteristicController; +late StreamController deviceConnectionController; +late DiscoveredDevice device = DiscoveredDevice( + id: 'deviceId', + name: 'deviceName', + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 1, 1, 1]), + rssi: 90, + serviceUuids: []); +void main() { + group('Bluetooth tests', () { + setUpAll(() { + // Fallback values + registerFallbackValue(QualifiedCharacteristic( + characteristicId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a1'), + serviceId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a2'), + deviceId: device.id)); + }); + setUp(() { + mockBle = MockFlutterReactiveBle(); + // Mock ReactiveBle methods using streamcontrollers + characteristicController = + StreamController>.broadcast(sync: true); + deviceConnectionController = + StreamController.broadcast(sync: true); + when( + () => mockBle.connectToDevice( + id: any(named: 'id'), + connectionTimeout: any(named: 'connectionTimeout'), + ), + ).thenAnswer((c) { + debugPrint("connectToDevice(${c.namedArguments})"); + return deviceConnectionController.stream; + }); + when(() => mockBle.connectedDeviceStream) + .thenAnswer((d) => deviceConnectionController.stream); + when(() => + mockBle.subscribeToCharacteristic(any())) + .thenAnswer((q) { + debugPrint("subscribeToCharacteristic(${q.positionalArguments})"); + return characteristicController.stream; + }); + }); + tearDownAll(() { + deviceConnectionController.close(); + characteristicController.close(); + }); + + test('Ensure DeviceConnectionState to ErgometerConnectionState translation', + () { + final fakeConnectionUpdates = Stream.fromIterable([ + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connecting, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connected, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.disconnecting, + failure: null), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.disconnected, + failure: null) + ]); + final erg = Ergometer(device, bleClient: mockBle); + fakeConnectionUpdates.forEach(deviceConnectionController.add); + StreamSubscription _connection = + erg.connectAndDiscover().listen((_) {}); + expect( + erg.getMonitorConnectionState, + emitsInOrder([ + ErgometerConnectionState.connecting, + ErgometerConnectionState.connected, + ErgometerConnectionState.disconnected, + ErgometerConnectionState.disconnected + ])); + _connection.cancel(); + }); + test('Retrieve ErgometerConnectionState status during connection', () { + final fakeSubscriptionChar = Stream>.fromIterable([ + // Each StatusData1 is 18 bytes + [0x31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 0 m + [0x31, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 1 m + [0x31, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 2 m + ]); + final fakeConnectionUpdates = Stream.fromIterable([ + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connecting, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connected, + failure: null, + ) + ]); + final erg = Ergometer(device, bleClient: mockBle); + fakeConnectionUpdates.forEach(deviceConnectionController.add); + fakeSubscriptionChar.forEach(characteristicController.add); + expect( + erg.connectAndDiscover(), + emitsInOrder([ + ErgometerConnectionState.connecting, + ErgometerConnectionState.connected + ])); + // Connection should happen once + verify(() => mockBle.connectToDevice( + id: device.id, + connectionTimeout: any(named: 'connectionTimeout'), + )).called(1); + // Subscribed only to this subscription: + // - Identifiers.C2_ROWING_CONTROL_SERVICE_UUID + verify(() => mockBle.subscribeToCharacteristic(any( + that: isA().having( + (e) => e.characteristicId, + 'characteristicId', + Uuid.parse( + Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID))))) + .called(1); + }); + test('Monitor for distance data', () async { + List Function(int) distance_packet_fn = (distance) => [ + 0x31, + 0, + 0, + 0, + distance * 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]; + final fakeData = Stream>.fromIterable([ + // Each StatusData1 is 18 bytes, + distance_packet_fn(3), // 3 m + distance_packet_fn(4), // 4 m + distance_packet_fn(5), // 5 m + ]); + final erg = Ergometer(device, bleClient: mockBle); + final StreamSubscription connection = + erg.connectAndDiscover().listen((_) {}); + Stream.value(ConnectionStateUpdate( + deviceId: device.id, + connectionState: DeviceConnectionState.connected, + failure: null)) + .forEach(deviceConnectionController.add); + characteristicController.addStream(fakeData); + final result = expectLater( + erg.monitorForData({Keys.ELAPSED_DISTANCE_KEY}), + emitsInOrder([ + Map.from({Keys.ELAPSED_DISTANCE_KEY: 3}), + Map.from({Keys.ELAPSED_DISTANCE_KEY: 4}), + Map.from({Keys.ELAPSED_DISTANCE_KEY: 5}), + ])); + await result; + await connection.cancel(); + // Subscribed only to the subscriptions: + // - (connect) Identifiers.C2_ROWING_CONTROL_SERVICE_UUID + // - (createStream) Identifiers.C2_ROWING_MULTIPLEXED_INFORMATION_CHARACTERISTIC_UUID + final subscribedCharacteristics = + verify(() => mockBle.subscribeToCharacteristic(captureAny())) + .captured; + expect(subscribedCharacteristics[0].characteristicId, + Uuid.parse(Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID)); + expect( + subscribedCharacteristics[1].characteristicId, + Uuid.parse(Identifiers + .C2_ROWING_MULTIPLEXED_INFORMATION_CHARACTERISTIC_UUID)); + }); + }); +} diff --git a/test/src/dataplex_test.dart b/test/src/dataplex_test.dart new file mode 100644 index 0000000..0244685 --- /dev/null +++ b/test/src/dataplex_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:c2bluetooth/src/dataplex.dart'; +import 'package:c2bluetooth/src/packets/base.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBle extends Mock implements FlutterReactiveBle {} + +class FakeDevice extends Fake implements DiscoveredDevice { + @override + String get id => '01:23:45:67:89:AB'; +} + +class MockParse extends Mock { + Concept2CharacteristicData? call(Uint8List bytes); +} + +class FakePacket extends Fake implements Concept2CharacteristicData { + @override + Map asMap() => {'foo': 42}; +} + +void main() { + setUpAll(() { + registerFallbackValue(QualifiedCharacteristic( + serviceId: Uuid.parse('00000000-0000-0000-0000-000000000000'), + characteristicId: Uuid.parse('ce060030-43e5-11e4-916c-0800200c9a66'), + deviceId: 'fallback', + )); + registerFallbackValue(Uint8List(0)); + }); + + group('Dataplex', () { + // FIXME: Remove Dataplex subscription at declaration when _validateStreams is ready + late MockBle ble; + late DiscoveredDevice device; + + setUp(() { + ble = MockBle(); + device = FakeDevice(); + + when(() => ble.subscribeToCharacteristic(any())) + .thenAnswer((_) => const Stream.empty()); + }); + + test('dataplex construction sanity check', () { + // Dataplex construction follows Ergometer one + // We are not connected to the machine at this stage + // No ble action should be triggered + Dataplex(device, ble); + verifyNever(() => ble.connectToDevice(id: any(named: 'id'))); + verifyNever(() => ble.subscribeToCharacteristic( + any( + that: isA().having( + (c) => c.deviceId, + 'device ID', + equals(device.id), + )), + )); + }); + + test('forwards packet maps to outgoing streams', () async { + // Ensure subscribed characteristic is parsed and channeled + // into outgoing streams + final mockParse = MockParse(); + final fakePacket = FakePacket(); + when(() => mockParse(any())).thenReturn(fakePacket); + + final bleStream = StreamController>(); + when(() => ble.subscribeToCharacteristic(any())) + .thenAnswer((_) => bleStream.stream); + + final dataplex = Dataplex( + device, + ble, + parsePacketFn: mockParse, // inject mock parser + ); + + final out = dataplex.createStream({'foo'}.toSet()); + + // Add fake bytes to simulate notification + bleStream.add([0x00]); + + // Wait for output and verify + final result = await out.first; + expect(result, equals(fakePacket.asMap())); + + verify(() => mockParse(any())).called(1); + + await bleStream.close(); + }); + + test('dispose cancels BLE subscriptions and closes outgoing streams', + () async { + final bleStream = StreamController(); + when(() => ble.subscribeToCharacteristic(any())) + .thenAnswer((_) => bleStream.stream); + + final dataplex = Dataplex(device, ble); + + final s1 = dataplex.createStream({'a'}.toSet()).listen((_) {}); + final s2 = dataplex.createStream({'b'}.toSet()).listen((_) {}); + verify(() => ble.subscribeToCharacteristic( + any( + that: isA().having( + (c) => c.deviceId, + 'device ID', + equals(device.id), + )), + )).called(1); + dataplex.dispose(); + + await expectLater(s1.asFuture(), completes); + await expectLater(s2.asFuture(), completes); + + expect(() => bleStream.add(Uint8List.fromList([0x00])), returnsNormally); + }); + }); +} diff --git a/test/internal/datatypes_test.dart b/test/src/datatypes_test.dart similarity index 98% rename from test/internal/datatypes_test.dart rename to test/src/datatypes_test.dart index a947c4a..3d1d931 100644 --- a/test/internal/datatypes_test.dart +++ b/test/src/datatypes_test.dart @@ -2,7 +2,7 @@ import 'dart:typed_data'; import 'package:c2bluetooth/c2bluetooth.dart'; import 'package:c2bluetooth/enums.dart'; -import '../../lib/internal/datatypes.dart'; +import '../../lib/src/datatypes.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/src/helpers_test.dart b/test/src/helpers_test.dart new file mode 100644 index 0000000..9b53c59 --- /dev/null +++ b/test/src/helpers_test.dart @@ -0,0 +1,202 @@ +import 'dart:typed_data'; + +import 'package:c2bluetooth/enums.dart'; +import 'package:c2bluetooth/src/helpers.dart'; +import 'package:c2bluetooth/src/packets/keys.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Handle multiplexed data', () { + test('parsePacket handles 0x31 StatusData', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x31, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0xE8, 0x03, 0x00, // distance: 1000 (0x0003E8) => 100 meters + 0x08, // workout type: 0x08 => VARIABLE_INTERVAL + 0x00, // interval type: 0x00 => TIME + 0x03, // workout state: 0x03 => INTERVALREST + 0x00, // rowing state: 0x00 => INACTIVE + 0x00, // stroke state: 0x00 => WAITING_FOR_WHEEL_TO_REACH_MIN_SPEED_STATE + 0xE8, 0x03, 0x00, // distance: 1000 meters + 0x02, 0x03, 0x00, // workout duration: 770 cals + 0x40, // duration type: 0x40 => calories + 0xFF, // drag factor: 0x55 => 255 + ]); + expect(data.lengthInBytes, equals(20)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.ELAPSED_DISTANCE_KEY], equals(100.0)); + expect(map[Keys.STATE_WORKOUT_TYPE_KEY], + equals(WorkoutType.VARIABLE_INTERVAL)); + expect(map[Keys.STATE_SEGMENT_TYPE_KEY], equals(IntervalType.TIME)); + expect(map[Keys.STATE_WORKOUT_KEY], equals(WorkoutState.INTERVALREST)); + expect(map[Keys.STATE_ROWING_KEY], equals(RowingState.INACTIVE)); + expect(map[Keys.WORKOUT_DURATION_KEY], equals(770.0)); + expect( + map[Keys.WORKOUT_DURATION_UNIT_KEY], equals(DurationType.CALORIES)); + expect(map[Keys.STATE_ROWING_STROKE_KEY], + equals(StrokeState.WAITING_FOR_WHEEL_TO_REACH_MIN_SPEED_STATE)); + expect(map[Keys.WORKOUT_TOTAL_DISTANCE_KEY], equals(1000)); + expect(map[Keys.WORKOUT_DRAG_FACTOR_KEY], equals(255)); + }); + test('parsePacket handles 0x32 StatusData1', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x32, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0xE8, 0x03, // speed: 1000 (0x03E8) => 1 m/s + 0x2A, // stroke rate: (0x2A) => 42 strokes/min + 0xC4, // heart rate: (0xC4) => 196 bpm + 0xE0, 0x2E, // current pace: 12000 (0x2EE0) => 120 seconds + 0xE5, 0x2E, // average pace: 12005 (0x2EE5) => 120,05 seconds + 0xE5, 0x00, // rest distance: 229 (0x00E5) => 229 meters + 0xB8, 0x0B, 0x00, // rest time: 3000 (0x000BB8) => 30 seconds + 0xE6, 0x00, // average power: 230 (0x00E6) => 230 watts + 0x13, // erg machine type: 19 (0x13) => SLIDES_D + ]); + expect(data.lengthInBytes, equals(20)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.WORKOUT_SPEED_KEY], equals(1.0)); + expect(map[Keys.WORKOUT_SPM_KEY], equals(42)); + expect(map[Keys.WORKOUT_HR_KEY], equals(196)); + expect(map[Keys.WORKOUT_PACE_KEY], equals(Duration(minutes: 2))); + expect(map[Keys.WORKOUT_AVG_PACE_KEY], + equals(Duration(minutes: 2, milliseconds: 50))); + expect(map[Keys.WORKOUT_REST_DISTANCE_KEY], equals(229)); + expect(map[Keys.WORKOUT_REST_TIME_KEY], equals(Duration(seconds: 30))); + expect(map[Keys.WORKOUT_AVG_POWER_KEY], equals(230)); + expect(map[Keys.WORKOUT_MACHINE_TYPE_KEY], equals(MachineType.SLIDES_D)); + }); + test('parsePacket handles 0x33 StatusData2', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x33, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0x10, // interval count: (0x10) => 16 interval + 0x2D, 0x00, // total calories: (0x002D) => 45 calories + 0x80, 0x3E, // segment avg pace: 16000 (0x03E80) => 160 seconds + 0x0E, 0x01, // segment avg power: (0x010E) => 270 watts + 0x10, 0x00, // segment avg calories: (0x10) => 16 calories + 0x4A, 0x06, 0x00, // last split time: 1610 (0x0000A1) => 161 seconds + 0xF4, 0x01, 0x00, // last split distance: 500 (0x0001F4) => 500 meters + ]); + expect(data.lengthInBytes, equals(19)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.SEGMENT_NUMBER_KEY], equals(16)); + expect(map[Keys.WORKOUT_CALORIES_KEY], equals(45)); + expect(map[Keys.SEGMENT_AVG_PACE_KEY], equals(Duration(seconds: 160))); + expect(map[Keys.SEGMENT_AVG_POWER_KEY], equals(270)); + expect(map[Keys.SEGMENT_AVG_CALORIES_KEY], equals(16)); + expect(map[Keys.SEGMENT_LAST_TIME_KEY], equals(Duration(seconds: 161))); + expect(map[Keys.SEGMENT_LAST_DISTANCE_KEY], equals(500)); + }); + test('parsePacket handles 0x3E StatusData3', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x3E, // packet ID + 0x0A, // operational state: 10 (0x0A) => Idle + 0x00, // workout verification: 0 => ? + 0x01, 0x00, // screen number: 1 => HOME SCREEN + 0x02, 0x00, // last error: 2 ? + 0x00, // calibration mode: 0 + 0x00, // calibration state: 0 + 0x00, // calibration status: 0 + 0x01, // game id: 1 => Fish + 0x02, 0x00, // game score: 2 + ]); + expect(data.lengthInBytes, equals(13)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.STATE_OPERATIONAL_STATE_KEY], + equals(OperationalState.OPERATIONALSTATE_IDLE)); + expect(map[Keys.STATE_WORKOUT_VERIFICATION_KEY], equals(0)); + expect(map[Keys.STATE_SCREEN_NUMBER_KEY], equals(1)); + expect(map[Keys.STATE_LAST_ERROR_KEY], equals(2)); + expect(map[Keys.STATE_CALIBRATION_MODE_KEY], equals(0)); + expect(map[Keys.STATE_CALIBRATION_KEY], equals(0)); + expect(map[Keys.STATE_CALIBRATION_STATUS_KEY], equals(0)); + expect(map[Keys.STATE_GAME_ID_KEY], equals(GameId.FISH)); + expect(map[Keys.STATE_GAME_SCORE_KEY], equals(2)); + }); + test('parsePacket handles 0x35 StrokeData', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x35, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0xE8, 0x03, 0x00, // distance: 1000 (0x0003E8) => 100 meters + 0xC8, // drive length: 200 (0x00C8) => 2 meters + 0x64, // drive time: 100 (0x64) => 1 second + 0xE8, 0x03, // recovery time: 1000 (0x03E8) => 10 seconds + 0xE8, 0x03, // stroke distance: 1000 (0x03E8) => 100 meters + 0xA0, 0x0F, // peak force: 4000 (0x0FA0) => 400 lbs + 0xE8, 0x03, // avg force: 1000 (0x3E8) => 100 lbs + 0xE8, 0x03, // stroke count: 1000 (0x3E8) + ]); + expect(data.lengthInBytes, equals(19)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.ELAPSED_DISTANCE_KEY], equals(100.0)); + expect(map[Keys.STROKE_DRIVE_LENGTH_KEY], equals(2.0)); + expect(map[Keys.STROKE_DRIVE_TIME_KEY], equals(Duration(seconds: 1))); + expect(map[Keys.STROKE_RECOVERY_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.STROKE_DISTANCE_KEY], equals(100.0)); + expect(map[Keys.STROKE_PEAK_FORCE_KEY], equals(400)); + expect(map[Keys.STROKE_AVG_FORCE_KEY], equals(100)); + expect(map[Keys.STROKE_COUNT_KEY], equals(1000)); + }); + test('parsePacket handles 0x36 StrokeData2', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x36, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0x54, 0x01, // stroke power: 340 watts + 0x02, 0x00, // stroke calories: 2 cal/hr + 0xE8, 0x03, // stroke count: 1000 strokes + 0x64, 0x00, 0x00, // projected work time: 100 seconds + 0xE8, 0x03, 0x00, // projected work distance: 1000 meters + 0xE8, 0x03, // work per stroke: 1000 (0x03E8) => 100 Joules + ]); + expect(data.lengthInBytes, equals(18)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.WORKOUT_POWER_KEY], equals(340)); + expect(map[Keys.WORKOUT_CALORIES_KEY], equals(2.0)); + expect(map[Keys.STROKE_COUNT_KEY], equals(1000)); + expect( + map[Keys.WORKOUT_PROJECTED_TIME_KEY], equals(Duration(seconds: 100))); + expect(map[Keys.WORKOUT_PROJECTED_DISTANCE_KEY], equals(1000)); + expect(map[Keys.STROKE_ENERGY_KEY], equals(100.0)); + }); + test('parsePacket handles 0x37 SegmentData1', () { + // Construct a byte array representing a packet. + final data = Uint8List.fromList([ + 0x37, // packet ID + 0xE8, 0x03, 0x00, // elapsedTime: 1000 (0x0003E8) => 10 seconds + 0xE8, 0x03, 0x00, // elapsedDistance: 1000 (0x0003E8) => 100 meters + 0x88, 0x13, 0x00, // segment time: + 0x88, 0x13, 0x00, // segment distance: + 0x88, 0x13, // interval rest time: + 0x88, 0x13, // interval rest distance: + 0x05, // segment type: rest undefined + 0x03, // segment number: + ]); + expect(data.lengthInBytes, equals(19)); + final status = parsePacket(data); + final map = status!.asMap(); + expect(map[Keys.ELAPSED_TIME_KEY], equals(Duration(seconds: 10))); + expect(map[Keys.ELAPSED_DISTANCE_KEY], equals(100.0)); + expect(map[Keys.SEGMENT_TIME_KEY], equals(500)); + expect(map[Keys.SEGMENT_DISTANCE_KEY], equals(5000)); + expect(map[Keys.SEGMENT_TYPE_KEY], equals(IntervalType.RESTUNDEFINED)); + expect(map[Keys.SEGMENT_NUMBER_KEY], equals(3)); + }); + }); +} diff --git a/test/test-multiplex-data6.csv b/test/test-multiplex-data6.csv new file mode 100644 index 0000000..881f8a6 --- /dev/null +++ b/test/test-multiplex-data6.csv @@ -0,0 +1,221 @@ +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +49, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 75, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 75, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 127, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 127, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 127, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 176, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 176, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 176, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 225, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 225, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 225, 0, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 19, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 19, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 19, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 68, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 68, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 68, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 118, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 118, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 118, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 167, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 167, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 167, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +53, 205, 1, 0, 111, 0, 0, 106, 108, 69, 1, 2, 1, 165, 2, 196, 1, 1, 0 +51, 220, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 220, 1, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 15, 2, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 15, 2, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 15, 2, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 64, 2, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 64, 2, 0, 0, 0, 0, 72, 82, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0 +54, 95, 2, 0, 41, 0, 183, 1, 2, 0, 0, 0, 0, 48, 0, 0, 172, 7 +54, 95, 2, 0, 41, 0, 183, 1, 2, 0, 0, 0, 0, 48, 0, 0, 172, 7 +51, 115, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 115, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 163, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 163, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 163, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 215, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 215, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 215, 2, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 7, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 7, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 7, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +50, 57, 3, 0, 132, 9, 12, 255, 87, 80, 139, 80, 0, 0, 0, 0, 0, 40, 0, 0 +51, 57, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 57, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 57, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 105, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 105, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 156, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 156, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 156, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 208, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 208, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 208, 3, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 2, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 2, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 2, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 50, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 50, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 50, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 99, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 99, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 99, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 150, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 150, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51, 150, 4, 0, 0, 0, 0, 139, 80, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0 +49, 200, 4, 0, 15, 1, 0, 5, 0, 1, 0, 1, 0, 0, 0, 208, 7, 0, 0, 124 +51, 200, 4, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 200, 4, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 250, 4, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 250, 4, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 250, 4, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 44, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 44, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 44, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 97, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 97, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 97, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 148, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 148, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 148, 5, 0, 0, 1, 0, 64, 88, 30, 0, 1, 0, 0, 0, 0, 27, 0, 0 +54, 199, 5, 0, 23, 0, 122, 1, 3, 0, 0, 0, 0, 40, 0, 0, 22, 1 +54, 199, 5, 0, 23, 0, 122, 1, 3, 0, 0, 0, 0, 40, 0, 0, 22, 1 +54, 199, 5, 0, 23, 0, 122, 1, 3, 0, 0, 0, 0, 40, 0, 0, 22, 1 +51, 247, 5, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 247, 5, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 247, 5, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 42, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 42, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 42, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 42, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 90, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 90, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 140, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 140, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 140, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 188, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 188, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 188, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 241, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 241, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 241, 6, 0, 0, 1, 0, 198, 96, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +54, 46, 7, 0, 22, 0, 118, 1, 4, 0, 0, 0, 0, 40, 0, 0, 6, 3 +54, 46, 7, 0, 22, 0, 118, 1, 4, 0, 0, 0, 0, 40, 0, 0, 6, 3 +51, 85, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 85, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 85, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 133, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 133, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 133, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 133, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +51, 182, 7, 0, 0, 1, 0, 43, 97, 23, 0, 1, 0, 0, 0, 0, 27, 0, 0 +56, 208, 7, 0, 12, 0, 0, 196, 9, 2, 0, 121, 1, 208, 7, 22, 0, 122, 1, 0 +56, 208, 7, 0, 12, 0, 0, 196, 9, 2, 0, 121, 1, 208, 7, 22, 0, 122, 1, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +60, 133, 44, 14, 14, 4, 10, 0, 0, 0, 0 +60, 133, 44, 14, 14, 4, 10, 0, 0, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +50, 208, 7, 0, 184, 7, 17, 255, 255, 98, 237, 97, 0, 0, 0, 0, 0, 22, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +49, 208, 7, 0, 142, 1, 0, 5, 0, 12, 0, 1, 0, 0, 0, 208, 7, 0, 0, 120 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 +51, 208, 7, 0, 1, 2, 0, 237, 97, 22, 0, 2, 0, 0, 0, 0, 40, 0, 0 \ No newline at end of file diff --git a/test/workoutsummary_test.dart b/test/workoutsummary_test.dart index 6fdf89e..e063fed 100644 --- a/test/workoutsummary_test.dart +++ b/test/workoutsummary_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:c2bluetooth/src/packets/workoutsummary.dart'; import 'package:flutter_test/flutter_test.dart'; void main() {