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 8e7c583..2bb2675 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,21 @@ Once you have the `Ergometer` instance for the erg you want to connect to, you c ```dart 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 - } - }); -} + 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, disconnect from your erg by cancelling the stream: diff --git a/docs/API.md b/docs/API.md index adad656..242b123 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,9 +3,9 @@ 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 @@ -13,8 +13,19 @@ This document is the starting point for learning more about the c2bluetooth API "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. +## Core values -## Overall API design +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). @@ -38,20 +49,13 @@ Since a lot of the architecture is already provided by FlutterBleLib and will li -## 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, such as the WorkoutSummary class, are essentially wrappers around data exposed by the PM (Performance Monitor)'s bluetooth interface. This makes it easier for applications to access this data by providing a more object-oriented interface. - -Data objects are primarily a form of one-way communication from a PM to your application. - -Data objects are located in the `data` directory and represent the parts of c2bluetooth's public API that are most likely to be useful to an application. - #### Model Objects 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. 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/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 index bb7f2a9..1532305 100644 Binary files a/docs/images/demo/pre-scan.png 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 399e8b4..9c7a693 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1,7 +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. + +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). + + +## 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 @@ -29,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 be615e6..371934b 100644 --- a/example/README.md +++ b/example/README.md @@ -1,12 +1,10 @@ -# 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 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 @@ -16,9 +14,9 @@ This is a relatively old screenshot of the included example app using an older v 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 "Start scan" button. ![A demo screenshot showing the start scan button](../docs/images/demo/pre-scan.png) -6. Press this "start 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 "setting up streams". -7. Use the back button on the erg to go back and set up a piece. 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. ![A demo screenshot showing the results of a piece](../docs/images/demo/completed.png) +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 df2e886..7ca87b8 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -5,7 +5,7 @@ plugins { } android { - namespace "com.example.fresh_example" + namespace "com.example.c2bluetooth_example" // Any value starting with "flutter." get its value from // the Flutter Gradle plugin. compileSdk flutter.compileSdkVersion @@ -14,7 +14,7 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - + kotlinOptions { jvmTarget = '17' } @@ -24,7 +24,7 @@ android { 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 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 0ab90d8..d50c81e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,23 +1,22 @@ + package="com.example.c2bluetooth_example"> + If your app doesn't use Bluetooth scan results to derive physical location information, + you can strongly assert that your app doesn't derive physical location. --> - - - + + package="com.example.c2bluetooth_example"> 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/Runner/Info.plist b/example/ios/Runner/Info.plist index 672d7e3..e820de9 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -11,7 +11,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - fresh_example + c2bluetooth_example CFBundlePackageType APPL CFBundleShortVersionString diff --git a/example/lib/main.dart b/example/lib/main.dart index 63c24d5..bd02ef3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,218 +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(); - //startScan(); + _initBle(); } - startScan() async { - await Future.wait([ - Permission.location.request(), - Permission.locationWhenInUse.request() - ]).then((results) { - PermissionStatus locationPermission = results[0]; - PermissionStatus finePermission = results[1]; - if (Platform.isAndroid) { - if (locationPermission == PermissionStatus.granted && - finePermission == PermissionStatus.granted) { - return true; - } - } else if (Platform.isIOS) { - return true; - } - return false; - }).then((result) { - if (result) { - setState(() { - displayText = "Start Scanning"; - }); - - scanSub = bleManager.startErgScan().listen((erg) { - //Scan one peripheral and stop scanning - print("Scanned Peripheral ${erg.name}"); - - stopScan(); - targetDevice = erg; - connectToDevice(); - }); - } else { - print( - 'Your device is experiencing a permission issue. Make sure you allow location services.'); - setState(() { - displayText = "Permission Issue Stopped Scanning"; - }); - } - }); - } - - stopScan() { - scanSub?.cancel(); - scanSub = null; + /// 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; + } } - connectToDevice() async { - if (targetDevice == null) return; - - setState(() { - displayText = "Device Connecting"; - }); - - targetDevice!.connectAndDiscover().listen((event) { - if (event == ErgometerConnectionState.connected) { - subscribeToStreams(); + 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]; - setup2kH() async { - if (targetDevice == null) return; - - // ignore: deprecated_member_use - targetDevice?.configure2kWorkout(); - } - - setup10kH() async { - if (targetDevice == null) return; - - // ignore: deprecated_member_use - 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); } - subscribeToStreams() async { - if (targetDevice == null) return; - + void _disconnectBle() { + _connection?.cancel(); 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: [ - Visibility( - visible: scanSub == null && targetDevice == null, - child: ElevatedButton( - onPressed: () { - startScan(); - }, - child: Text("Start Scan"), - ), - ), - 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 5edaba1..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. @@ -28,10 +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: @@ -47,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 6fc3ca9..77127e3 100644 --- a/lib/models/ergblemanager.dart +++ b/lib/models/ergblemanager.dart @@ -1,22 +1,48 @@ +import 'dart:async'; + import 'package:c2bluetooth/constants.dart' as Identifiers; +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 { - final _manager = FlutterReactiveBle(); + final FlutterReactiveBle _manager; + StreamSubscription? _bleStatus; + List _scannedErgometers = []; + + 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 for this to work. Stream startErgScan() { - return _manager.scanForDevices(withServices: [ - Uuid.parse(Identifiers.C2_ROWING_BASE_UUID) - ]).map((scanResult) => Ergometer(scanResult)); + _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() { + _bleStatus?.cancel(); + _scannedErgometers.clear(); return _manager.deinitialize(); } } diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 5b0e4fe..2c1b94f 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -1,85 +1,102 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:c2bluetooth/exceptions/c2bluetooth_exceptions.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; -import '../internal/commands.dart'; -import '../internal/datatypes.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:rxdart/rxdart.dart'; enum ErgometerConnectionState { connecting, connected, disconnected } class Ergometer { - final _flutterReactiveBle = FlutterReactiveBle(); + final FlutterReactiveBle _flutterReactiveBle; DiscoveredDevice _peripheral; Csafe? _csafeClient; + late final Dataplex _dataplex; + /// Get the name of this erg. i.e. "PM5" + serial number String get name => _peripheral.name; + Stream? _connection; /// Create an [Ergometer] from a discovered bluetooth device object /// /// 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); + Ergometer(this._peripheral, {required FlutterReactiveBle bleClient}) + : _flutterReactiveBle = bleClient; /// Connect to this erg and discover the services and characteristics that it offers /// this returns a stream of [ErgometerConnectionState] events to enable monitoring the erg's connection state and disconnecting. Stream connectAndDiscover() { - //having this first might cause problems - _csafeClient = Csafe(_readCsafe, _writeCsafe); - //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 - return _flutterReactiveBle.connectToDevice(id: _peripheral.id).asyncMap((connectionStateUpdate) { - switch (connectionStateUpdate.connectionState) { - case DeviceConnectionState.connecting: - return ErgometerConnectionState.connecting; - case DeviceConnectionState.connected: - return ErgometerConnectionState.connected; - case DeviceConnectionState.disconnecting: - return ErgometerConnectionState.disconnected; - case DeviceConnectionState.disconnected: - return ErgometerConnectionState.disconnected; - default: - return ErgometerConnectionState.disconnected; - } - }); - + _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; } - /// Returns a stream of [WorkoutSummary] objects upon completion of any workout that would normally be saved to the Erg's memory. This includes any pre-programmed piece and any "just row" pieces longer than 1 minute. - @Deprecated("This API is being deprecated in an upcoming version") - Stream monitorForWorkoutSummary() { - - var workoutSummaryCharacteristic1 = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID), deviceId: _peripheral.id); - - var workoutSummaryCharacteristic2 = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID), deviceId: _peripheral.id); - - Stream ws1 = _flutterReactiveBle.subscribeToCharacteristic(workoutSummaryCharacteristic1).asyncMap((datapoint) => Uint8List.fromList(datapoint)); - + /// Deprecation notice: disconnect does not exists on FlutterReactiveBle library + @Deprecated("Destroy the Ergometer object to disconnect") + void disconnectOrCancel() { + throw NoSuchMethodError; + } - Stream ws2 = _flutterReactiveBle.subscribeToCharacteristic(workoutSummaryCharacteristic2).asyncMap((datapoint) => Uint8List.fromList(datapoint)); + /// Subscribe to a stream of data from the erg + /// (ex: general.distance, stroke.drive_length, ...) + Stream> monitorForData( + Set datapointIdentifiers) { + return _dataplex.createStream(datapointIdentifiers); + } - return Rx.zip2(ws1, ws2, (Uint8List ws1Result, Uint8List ws2Result) { - List combinedList = ws1Result.toList(); - combinedList.addAll(ws2Result.toList()); - return WorkoutSummary.fromBytes(Uint8List.fromList(combinedList)); - }); + // Ensure compatibility + @Deprecated("Use getMonitorConnectionState getter") + Stream monitorConnectionState() { + return getMonitorConnectionState; } + /// 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 response data from the erg Stream _readCsafe() { - 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) { + 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"); return datapoint; }); @@ -89,7 +106,11 @@ class Ergometer { /// /// 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); + 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, @@ -98,10 +119,12 @@ class Ergometer { // true); // //.asyncMap((datapoint) => datapoint.read()); - _flutterReactiveBle.writeCharacteristicWithResponse(csafeTxCharacteristic, value: value); + _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([ 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 b1f4ab4..1f1b92c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,8 +17,10 @@ dependencies: 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/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() {