PineTime Companion App running on an Android phone, fetching the firmware versions from PineTime Smart Watch wirelessly
📝 27 Jun 2020
Flutter is awesome for creating mobile apps for Android and iOS... The programming language is modern (Dart), the debugging tools are excellent (VSCode and Dart DevTools).
That's why we have selected Flutter for creating the open source Android and iOS Companion App for PineTime Smart Watch: For updating firmware, syncing date/time, pushing mobile notifications, controlling our smart home gadgets, ... and so much more!
In the previous article "Convert Go to Flutter and Dart for PineTime Companion App" we have built a technically functional (but barely human) app that sends Bluetooth Low Energy commands to PineTime.
Today we'll recode the app so that it's more human-friendly, like this...
Read on to learn how we do this with the Bloc Library for State Management...
It's easy to build a mobile app for Android and iOS with Flutter... What could go wrong?
A mobile app is a simple thing... It just reacts to our tapping and updates the display...
Is it really so simple?
Nope! Behind the scenes, the app could be calling some REST API on the web. Or talking to Bluetooth gadgets like PineTime...
What happens when the app loses track of its state?
Things can become really complicated...
There's a good way to handle this messy state in Flutter apps... Bloc Library for State Management!
Read on to learn how.
(If you're familiar with React Redux: Yep Bloc sounds a lot like React Redux, because they are both State Management Frameworks)
Our PineTime Companion App calls the Bloc Library to connect our Flutter Widgets with our application data and processing logic.
Let's look at three Flutter Widgets that we have created for the app...
Device Firmware Widget (Stateless): Shows firmware version numbers
Device Summary Widget (Stateless): Summarises the PineTime info
Device Widget (Stateful): The entire PineTime Companion screen
We'll learn why the widgets are Stateless / Stateful in a while.
Our Flutter App talks to PineTime over Bluetooth LE (Low Energy) to fetch the firmware version numbers and display them. Here's how it looks...
(PineTime contains two firmware images: Active and Standby. If the Active Firmware fails to start, PineTime rolls back to the Standby Firmware)
The Device Firmware Widget that displays the firmware version numbers is really simple: widgets/device_firmware.dart
/// Widget to display firmware versions fetched from PineTime
class DeviceFirmware extends StatelessWidget {
final String activeFirmwareVersion; // Version number of firmware that's running on PineTime (e.g. '1.0.0')
final String standbyFirmwareVersion; // Version number of firmware that's in external flash memory (e.g. '1.1.0')
/// Construct the widget with the active and standby firmware version numbers
DeviceFirmware({
Key key,
this.activeFirmwareVersion,
this.standbyFirmwareVersion
}) : super(key: key);
/// Render the widget UI with two lines of text
@override
Widget build(BuildContext context) {
return Column(
children: [
// Show active firmware version number
Text(
'Active Firmware: $activeFirmwareVersion',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w100,
color: Colors.white,
),
),
// Show standby firmware version number
Text(
'Standby Firmware: $standbyFirmwareVersion',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w100,
color: Colors.white,
),
)
],
);
}
}
DeviceFirmware
contains two fields activeFirmwareVersion
and standbyFirmwareVersion
, that store the version numbers of the Active and Standby Firmware on PineTime.
DeviceFirmware
is a Stateless Widget because its State (activeFirmwareVersion
and standbyFirmwareVersion
) doesn't change.
What happens if PineTime gets updated with new firmware?
Our Flutter App shall create a new instance of DeviceFirmware
with new values for activeFirmwareVersion
and standbyFirmwareVersion
.
That's why the Device Firmware widget will never change its State... Though the widget may get replaced altogether.
The Device Firmware widget we've seen is wrapped into a Device Summary Widget like this...
DeviceSummary
is defined in widgets/device_summary.dart
...
/// Widget to display PineTime summary
class DeviceSummary extends StatelessWidget {
/// Data Model that contains PineTime info and Bluetooth device
final model.Device device;
/// Render the PineTime summary
@override
Widget build(BuildContext context) {
return Column(
children: [
...,
// Construct a DeviceFirmware Widget to show the firmware versions
DeviceFirmware(
activeFirmwareVersion: device.activeFirmwareVersion,
standbyFirmwareVersion: device.standbyFirmwareVersion,
)
What's with the Data Model named Device
?
/// Data Model that contains PineTime info and Bluetooth device
final model.Device device;
To render the Device Summary, this widget needs to know everything about our PineTime gadget... That's why the widget keeps a copy of the PineTime info inside the Data Model named Device
.
Note that the Device Summary Widget passes two fields from the Device
Data Model to the Device Firmware Widget: activeFirmwareVersion
and standbyFirmwareVersion
.
DeviceSummary
is another Stateless Widget that doesn't change its State (i.e. the Device
Data Model). If the device info changes, our app creates a new DeviceSummary
widget to replace the old one.
The Data Model is a core concept in the Bloc Library. More about this later.
The Device Summary Widget above is wrapped into a Device Widget that renders the entire screen...
Device Widget is a Stateful Widget that has some interesting code inside: widgets/device.dart
/// Widget for the PineTime Companion screen
class Device extends StatefulWidget {
/// Construct the Stateful Widget with an initial state
@override
State<Device> createState() => _DeviceState();
}
/// Implement the Stateful Widget for the PineTime Companion screen
class _DeviceState extends State<Device> {
/// Render the PineTime Companion screen
@override
Widget build(BuildContext context) {
// Render the screen with Button Bar above, followed by the Body
return Scaffold(
// Button Bar for the screen (omitted)
appBar: ...,
// Body for the screen
body:
...
// Construct a BlocConsumer to listen for updates to the state and rebuild the widget
BlocConsumer<DeviceBloc, DeviceState>(
// Listen for updates to the state
listener: ...,
// Rebuild the widget when the state has been updated
builder: (context, state) {
// When we have loaded the device info...
if (state is DeviceLoadSuccess) {...
// Get the device info from the new state
final device = state.device;
// Construct the Device Summary with the device info
return
...
DeviceSummary(
device: device,
),
...
Why do we need createState()
in the Device Widget?
Device Widget is a Stateful Widget, so it needs to be created with an initial state, like this...
/// Widget for the PineTime Companion screen
class Device extends StatefulWidget {
/// Construct the Stateful Widget with an initial state
@override
State<Device> createState() => _DeviceState();
Why is the Device Widget Stateful, unlike the other Widgets?
Because the Device Widget will magically transform itself when something happens!
The code below says that the Device Widget will rebuild its Device Summary Widget when the State has changed to DeviceLoadSuccess
...
// Rebuild the widget when we receive an event
builder: (context, state) {
// When we have loaded the device info...
if (state is DeviceLoadSuccess) {...
// Get the device info from the new state
final device = state.device;
// Construct the Device Summary with the device info
return
...
DeviceSummary(
device: device,
),
...
The above Bloc Widget Builder (exposed by BlocConsumer
) takes the updated Device
Data Model from the new State, and creates a new Device Summary Widget...
This explains why the Device Widget is Stateful while the Device Summary Widget (and Device Firmware Widget) is Stateless... Because Device Widget will replace the Device Summary Widget when there are updates.
How do we trigger the DeviceLoadSuccess
State?
This State is triggered when we have loaded the device info from PineTime over Bluetooth LE.
That's how widgets get updated in Bloc: The widget listens for State updates and rebuilds itself with a Bloc Widget Builder.
We'll see in a while how the DeviceLoadSuccess
State is generated in Bloc.
(The code in this article was derived from the excellent Weather App Tutorial from the Bloc Library)
The Data Model is important in Bloc apps... It gets passed to widgets for rendering the user interface.
Let's look at the Data Model for our PineTime Device, as defined in lib/models/device.dart
...
import 'package:equatable/equatable.dart'; // Object Equality Helper from https://pub.dev/packages/equatable
import 'package:flutter_blue/flutter_blue.dart'; // Bluetooth LE API from https://github.com/pauldemarco/flutter_blue
/// Data Model for PineTime Device
class Device extends Equatable {
final BluetoothDevice bluetoothDevice; // Bluetooth device for connecting to PineTime, from flutter_blue library
final String activeFirmwareVersion; // Version number of firmware that's running on PineTime (e.g. '1.0.0')
final String standbyFirmwareVersion; // Version number of firmware that's in external flash memory (e.g. '1.1.0')
/// Constructor for PineTime Device
const Device({
this.bluetoothDevice,
this.activeFirmwareVersion,
this.standbyFirmwareVersion
});
/// Return the properties of PineTime Device
@override
List<Object> get props => [
bluetoothDevice,
activeFirmwareVersion,
standbyFirmwareVersion
];
}
The Device
Data Model for PineTime contains two fields activeFirmwareVersion
and standbyFirmwareVersion
, that store the version numbers of the Active and Standby Firmware on PineTime.
The two fields are rendered by the Device Firmware Widget that we have seen earlier.
What's BluetoothDevice
?
That's the Bluetooth Device returned by the flutter_blue
library for Bluetooth LE networking.
In a while we'll see how our Flutter App stores BluetoothDevice
into the Device
Data Model. And how our app calls BluetoothDevice
to send Bluetooth LE requests to PineTime.
To recap, the Device
Data Model contains everything we know about PineTime, and provides the means to access PineTime (through BluetoothDevice
).
Why does Device
inherit from the Equatable
class?
Equatable
is a helper library that lets us compare two objects for equality.
Two Device
Data Models are deemed equivalent if the fields have identical values. This checking for equality is required by Bloc.
Let's find how out BluetoothDevice
is used to fetch activeFirmwareVersion
and standbyFirmwareVersion
from PineTime.
Sending a Bluetooth LE command to PineTime is remarkably simple, straightforward, top to bottom... Thanks to Dart's support for Asynchronous Programming!
Here are the steps...
Connect to PineTime over Bluetooth LE
Discover the GATT Services exposed by PineTime
Find the right GATT Characteristic exposed by PineTime
Transmit a Write Request to the GATT Characteristic
Receive the response via a GATT Notification
Decode the CBOR response
Let's start by connecting to PineTime over Bluetooth LE: repositories/device_api_client.dart
class DeviceApiClient {
/// Connect to the PineTime device and query the firmare inside
Future<Device> fetchDevice(BluetoothDevice bluetoothDevice) async {
// Connect to PineTime
await bluetoothDevice.connect();
...
DeviceApiClient
is the Data Repository class that we expose to our Flutter App for sending Bluetooth LE commands to PineTime.
(Yes the name DeviceApiClient
is rather odd... It shall be renamed!)
fetchDevice()
is the method that sends the Bluetooth LE command to query PineTime's firmware images.
The method returns a Device
Data Model that contains the Active and Standby Firmware version numbers.
bluetoothDevice
is the Bluetooth interface returned by the flutter_blue
library for Bluetooth LE networking. (More about this later)
Why do we use await
when connecting to PineTime?
// Connect to PineTime
await bluetoothDevice.connect();
Connecting to PineTime over Bluetooth LE may take a while... And we should wait for it to complete before proceeding.
But we can't let the rest of the app freeze while waiting... What if the human taps the Cancel
button!
await
is exactly what we need. It waits for the connect()
method to complete before proceeding to the next step... While keeping the user interface responsive!
That's the beauty of Asynchronous Programming in Dart... No more Deeply Nested Callbacks and Promises! (Yep React Native gets really messy because of this)
Why is the fetchDevice()
method declared async
?
/// Connect to the PineTime device and query the firmare inside
Future<Device> fetchDevice(BluetoothDevice bluetoothDevice) async {
To use await
we need two things...
Declare the method as async
(like above)
Instead of returning a plain Device
object, the method now returns a Future<Device>
(like above)
Later we'll see that we may simply return a Device
object as Future<Device>
...
// Construct a Device object
final device = Device( ... );
// Return it as Future<Device>
return device;
Let's move on to discover GATT Services and Characteristics exposed by PineTime.
In the previous article we learnt about the Simple Management Protocol that's exposed by PineTime for querying and updating firmware. We'll be sending the Query Firmware Command to PineTime through this protocol.
(Simple Management Protocol is supported today on Mynewt and Zephyr open source embedded operating systems)
The Simple Management Protocol is implemented over Bluetooth LE as a GATT Service. Thus to query the firmware on PineTime, we need to discover the GATT Services exposed by PineTime: repositories/device_api_client.dart
// Discover the services on PineTime
List<BluetoothService> services = await bluetoothDevice.discoverServices();
discoverServices()
talks to PineTine over Bluetooth LE and returns a list of GATT Services exposed by PineTime.
We use await
to discover GATT Services, so that the app won't freeze while waiting for the Bluetooth LE response.
The GATT Service for Simple Management Protocol has a UUID (unique ID) of 8D53DC1D-1DB7-4CD3-868B-8A527460AA84
...
// Look for Simple Mgmt Protocol Service
for (BluetoothService service in services) {
if (!listEquals(
service.uuid.toByteArray(),
[0x8d,0x53,0xdc,0x1d,0x1d,0xb7,0x4c,0xd3,0x86,0x8b,0x8a,0x52,0x74,0x60,0xaa,0x84]
)) { continue; }
That's how we hunt for the GATT Service.
Next we hunt for the GATT Characteristic (within the GATT Service) for Simple Management Protocol.
To transmit a command to PineTime, we shall write a request message (in CBOR format) to the GATT Characteristic.
Here's how we find the GATT Characteristic DA2E7828-FBCE-4E01-AE9E-261174997C48
for the Simple Management Protocol: repositories/device_api_client.dart
// Look for Simple Mgmt Protocol Characteristic
var smpCharac;
var characteristics = service.characteristics;
for (BluetoothCharacteristic charac in characteristics) {
if (!listEquals(
charac.uuid.toByteArray(),
[0xda,0x2e,0x78,0x28,0xfb,0xce,0x4e,0x01,0xae,0x9e,0x26,0x11,0x74,0x99,0x7c,0x48]
)) { continue; }
// Found the characteristic
smpCharac = charac;
break;
}
If we can't find the GATT Service or the GATT Characteristic, we throw an exception...
// If Simple Mgmt Protocol Service or Characteristic not found...
if (smpCharac == null) {
bluetoothDevice.disconnect();
throw new Exception('Device doesn\'t support Simple Management Protocol. You may need to flash a suitable firmware.');
}
Now that we have the GATT Characteristic for the Simple Management Protocol, let's talk to the characteristic to send PineTime our Query Firmware Command.
First we compose a request message in CBOR (that includes an 8-byte header): repositories/device_api_client.dart
// Compose the Query Firmware request (Simple Mgmt Protocol)
final request = composeRequest();
composeRequest()
has been documented in our previous article. It sets request
to a byte buffer that contains our request message for the Query Firmware Command...
00 00 00 01 00 01 3f 00 a0
(8 bytes for the SMP Message Header, 1 byte for the CBOR Message Body, total 9 bytes)
To transmit the request message to PineTime, we write to the GATT Characteristic for the Simple Management Protocol...
// Transmit the Query Firmware request by writing to the SMP charactertistic
await smpCharac.write(request, withoutResponse: true);
Again we use await
so that the app won't freeze while waiting for the writing to complete.
withoutResponse
is set to true
because we don't expect a synchronous response from the GATT Write operation... Instead we expect the response to be delivered via a GATT Notification. (More about this later)
Yay we have completed 67% of the work needed to send a Bluetooth LE command to PineTime!
Let's move on to receive the Bluetooth LE response from PineTime and decode the response.
Our story thus far...
We have connected to PineTime over Bluetooth LE
We have discovered the GATT Service for the Simple Management Protocol
We have located the GATT Characteristic for the Simple Management Protocol
We have transmitted our Query Firmware Request Message to PineTime... By writing the message to the GATT Characteristic
Now let's get the Query Firmware Response from PineTime in a special way...
Will PineTime return the response immediately after writing to the GATT Characteristic?
Will PineTime let us read the response from the GATT Characteristic?
Surprisingly, no and no!
PineTime delivers the response via a GATT Notification. (Somewhat similar to Push Notifications on Android and iOS)
The response is Asynchronous. Which is probably good for PineTime because it gives PineTime's Firmware more time to prepare and deliver the response. (Remember: PineTime isn't as powerful as a mobile phone)
First we subscribe to GATT Notifications like this: repositories/device_api_client.dart
class DeviceApiClient {
/// Connect to the PineTime device and query the firmare inside
Future<Device> fetchDevice(BluetoothDevice bluetoothDevice) async {
// Omitted: Transmit the Query Firmware request by writing to the SMP charactertistic
...
// Subscribe to GATT Notifications from PineTime
await smpCharac.setNotifyValue(true);
setNotifyValue(true)
tells PineTime that we would like to receive GATT Notifications from our GATT Characteristic (the one from Simple Management Protocol).
// Create a completer to wait for response from PineTime
final completer = Completer<typed.Uint8Buffer>();
// Create a byte buffer for the response
final response = typed.Uint8Buffer();
Next we create a Completer
and a byte buffer to hold the response.
What's a Completer
?
It's something that we may await
while waiting for our response to be received.
(If you're familiar with JavaScript: A Completer
is equivalent to a Promise
)
Why can't we just await
the response without a Completer
?
The Bluetooth LE API is somewhat quirky: To receive response bytes we need to use a Callback Function like this...
// Receive response bytes, chunk by chunk
smpCharac.value.listen((value) {
// Add the chunk to our response buffer
response.addAll(value);
// Get the expected message length
if (response.length < 4) { return; } // Length field not available
final len = (response[2] << 8) + response[3]; // Length field in bytes 2 and 3
final responseLength = len + 8; // Response includes 8 bytes for header
// If the received response length is already the expected response length, mark response as complete
if (response.length >= responseLength && !completer.isCompleted) {
completer.complete(response);
}
});
The code in the above Callback Function will be called every time our Flutter App receives a chunk of GATT Notification bytes (value
) from PineTime. We append each chunk to our response buffer.
When we have received the final chunk of response bytes, we call Completer.complete()
like this...
completer.complete(response);
This signals to await
that the response is complete...
// Wait for the completer to finish receiving the entire response
final response2 = await completer.future;
And that's how await
and Completer
work together to simplify Callback Functions!
(If you're familiar with JavaScript: Completer.complete()
is equivalent to Promise.resolve()
)
How will we know when we have received the final chunk of response bytes?
Earlier we saw this code for handling the response bytes...
// Get the expected message length
if (response.length < 4) { return; } // Length field not available
final len = (response[2] << 8) + response[3]; // Length field in bytes 2 and 3
final responseLength = len + 8; // Response includes 8 bytes for header
The response message length is stored in bytes 2 and 3 of the response message.
That's how we know when there are no more bytes to be received, and trigger Completer.complete()
.
Finally we come to the last piece of the puzzle... How BluetoothDevice
is used to fetch activeFirmwareVersion
and standbyFirmwareVersion
from PineTime.
Earlier we have obtained a byte buffer response2
that contains the response bytes:
repositories/device_api_client.dart
class DeviceApiClient {
/// Connect to the PineTime device and query the firmare inside
Future<Device> fetchDevice(BluetoothDevice bluetoothDevice) async {
// Omitted: Transmit request to PineTime over Bluetooth LE
...
// Wait for the completer to finish receiving the entire response
final response2 = await completer.future;
Before decoding response2
, let's disconnect the Bluetooth LE connection to PineTime (and conserve battery power)...
// Disconnect the PineTime device
bluetoothDevice.disconnect();
What's inside response2
?
response2
contains...
Response Message Header: 8 bytes, followed by...
Response Message Body: 244 bytes, encoded in CBOR
It looks like this...
00000000 01 00 00 f4 00 01 3f 00 bf 66 69 6d 61 67 65 73 |.........fimages|
00000010 9f bf 64 73 6c 6f 74 00 67 76 65 72 73 69 6f 6e |..dslot.gversion|
00000020 65 31 2e 30 2e 30 64 68 61 73 68 58 20 ea bc 3a |e1.0.0dhashX ..:|
00000030 ce 74 a8 28 4c 6f 78 c2 bc ad 3a e1 8d 39 26 75 |.t.(Lox...:..9&u|
00000040 c7 66 c5 1f 95 23 0f 13 39 3f 08 1c 5d 68 62 6f |.f...#..9?..]hbo|
00000050 6f 74 61 62 6c 65 f5 67 70 65 6e 64 69 6e 67 f4 |otable.gpending.|
00000060 69 63 6f 6e 66 69 72 6d 65 64 f5 66 61 63 74 69 |iconfirmed.facti|
00000070 76 65 f5 69 70 65 72 6d 61 6e 65 6e 74 f4 ff bf |ve.ipermanent...|
00000080 64 73 6c 6f 74 01 67 76 65 72 73 69 6f 6e 65 31 |dslot.gversione1|
00000090 2e 31 2e 30 64 68 61 73 68 58 20 0d 78 49 f7 fe |.1.0dhashX .xI..|
000000a0 43 92 7a 87 d7 b4 d5 54 f8 43 08 82 33 d8 02 d5 |C.z....T.C..3...|
000000b0 09 0c 20 da a1 e6 a7 77 72 99 6e 68 62 6f 6f 74 |.. ....wr.nhboot|
000000c0 61 62 6c 65 f5 67 70 65 6e 64 69 6e 67 f4 69 63 |able.gpending.ic|
000000d0 6f 6e 66 69 72 6d 65 64 f4 66 61 63 74 69 76 65 |onfirmed.factive|
000000e0 f4 69 70 65 72 6d 61 6e 65 6e 74 f4 ff ff 6b 73 |.ipermanent...ks|
000000f0 70 6c 69 74 53 74 61 74 75 73 00 ff |plitStatus..|
What's in the Response Message Header?
Let's decode the first 8 bytes above according to the Simple Management Protocol message header definition in mynewt-mcumgr/mgmt.h
...
Header Field | Value | Description |
---|---|---|
Op | 01 | Operation Code (1 for Read Response) |
Flags | 00 | Unused |
Len | 00 f4 | Length of Message Body (244 bytes) |
Group | 00 01 | Group ID (1 for Image Management) |
Seq | 3f | Message Sequence Number (should match the request message) |
Id | 00 | Message ID (0 for Image Listing) |
Yep, this confirms that we have received a response to our Query Firmware (Image Listing) Command.
What's all the mumbo jumbo in the Response Message Body?
The Response Message Body is encoded in CBOR, a compact binary form of JSON.
We decode the message body like this...
// Extract the CBOR message body
final body = typed.Uint8Buffer();
body.addAll(response2.sublist(8)); // Remove the 8-byte header
// Decode the CBOR message body
final decodedBody = decodeCBOR(body);
decodeCBOR()
calls the CBOR Library to decode the CBOR data into JSON...
/// Decode the CBOR message body
List<dynamic> decodeCBOR(typed.Uint8Buffer payload) {
// Get our CBOR instance. Always do this, it correctly initialises the decoder.
final inst = cbor.Cbor();
// Decode from the buffer
inst.decodeFromBuffer(payload);
return inst.getDecodedData();
}
The decoded JSON in decodedBody
looks like this...
{
"images": [
{
"slot": 0,
"version": "1.0.0",
"hash": [
234,188,58,206,116,168,40,76,111,120,194,188,173,58,225,141,57,38,117,199,102,197,31,149,35,15,19,57,63,8,28,93
],
"bootable": true,
"pending": false,
"confirmed": true,
"active": true,
"permanent": false
},
{
"slot": 1,
"version": "1.1.0",
"hash": [
13,120,73,247,254,67,146,122,135,215,180,213,84,248,67,8,130,51,216,2,213,9,12,32,218,161,230,167,119,114,153,110
],
"bootable": true,
"pending": false,
"confirmed": false,
"active": false,
"permanent": false
}
],
"splitStatus": 0
}
Is PineTime telling us what it's hiding?
Yes, our PineTime is keeping two firmware images...
Active Firmware: Version 1.0.0 (Slot 0, in Internal Flash ROM)
Standby Firmware: Version 1.1.0 (Slot 1, in External SPI Flash)
Remember that PineTime always boots from the Active Firmware Image. The Standby Firmware Image is used when updating or rolling back the firmware.
The hash values of the firmware images will be used later when we update the PineTime firmware.
How shall we extract the Active and Standby Firmware Versions from the JSON?
By accessing the Dynamic List like this...
// Get the list of firmware images
final images = decodedBody[0]['images'] as List<dynamic>;
// Construct the Device Data Model with the firmware versions
final device = Device(
bluetoothDevice: bluetoothDevice,
activeFirmwareVersion: (images.length >= 1) ? images[0]['version'] : '',
standbyFirmwareVersion: (images.length >= 2) ? images[1]['version'] : '',
);
// Return the Device Data Model
return device;
And that's how we fetch the the Active and Standby Firmware Versions to construct the Device
Data Model!
Besides fetching the firmware versions, what can else we do with Bluetooth LE commands?
Plenty! We may update PineTime's firmware over Bluetooth LE, sync the date and time, push mobile notifications, capture our heart rate and even control smart home gadgets!
PineTime firmware exposes the Simple Management Protocol over Bluetooth LE. The protocol supports a rich set of commands for updating PineTime firmware, accessing the PineTime Flash filesystem, debug logs, runtime statistics, ...
We shall be adding these commands to the PineTime Companion App.
Now back to State Management with Bloc... The right way to manage our complex Flutter App is to use a Business Logic Class (Bloc), to drive the Event Transitions between the States of the app...
A shown above, our Device Bloc contains the Business Logic that manages three Device States (ovals) and two Device Events (arrows).
Each Device State corresponds to a screen in our Flutter App...
DeviceInitial
: Initial screen of our app
DeviceLoadInProgress
: PineTime selection screen
DeviceLoadSuccess
: PineTime summary screen
In Bloc, a Flutter App changes its State (i.e. moves from one screen to the next), when an Event is triggered (like DeviceRequested
)
How shall we code the States and Event Transitions in Bloc?
With a Bloc Class like so: blocs/device_bloc.dart
/// Device Bloc that manages the Device States and Device Events
class DeviceBloc extends Bloc<DeviceEvent, DeviceState> {
/// Data Repository that will be used to fetch data from PineTime
final DeviceRepository deviceRepository;
/// Construct a Device Bloc. Data Repository is mandatory.
DeviceBloc({@required this.deviceRepository})
: assert(deviceRepository != null);
/// Return the initial Device State (which corresponds to the initial screen)
@override
DeviceState get initialState => DeviceInitial();
/// When a Device Event is triggered, move to a new Device State (and a new screen)
@override
Stream<DeviceState> mapEventToState(DeviceEvent event) async* {
if (event is DeviceRequested) {
// Handle the DeviceRequested Event by loading data from PineTime
yield* _mapDeviceRequestedToState(event);
} else if (event is DeviceRefreshRequested) {
// Handle the Refresh button by updating the Device Widget
yield* _mapDeviceRefreshRequestedToState(event);
}
}
/// Handle the DeviceRequested Event by loading data from PineTime
Stream<DeviceState> _mapDeviceRequestedToState(
DeviceRequested event,
) async* {
// Notify the Device Widget that we are loading data
yield DeviceLoadInProgress();
try {
// Load data from PineTime over Bluetooth LE
final Device device = await deviceRepository.getDevice(event.device);
// Move to the DeviceLoadSuccess State, which renders the Device Summary Widget
yield DeviceLoadSuccess(device: device);
} catch (_) {
// In case of error, move to the DeviceLoadFailure State
yield DeviceLoadFailure();
}
}
DeviceBloc
contains the Business Logic that interprets the triggered Events and drives the States in our app. Let's inspect the code in DeviceBloc
.
How are Events defined in Bloc?
We define the DeviceRequested
Event like so: blocs/device_bloc.dart
/// Device Requested Event that will shift the Device States
class DeviceRequested extends DeviceEvent {
/// Bluetooth API for connecting to PineTime, from flutter_blue library
final BluetoothDevice device;
/// Construct a Device Requested Event. Bluetooth Device is mandatory.
const DeviceRequested({@required this.device}) :
assert(device != null);
/// Return the properties of the Device Requested Event
@override
List<Object> get props => [
device
];
}
How are Events triggered in Bloc?
Let's look at the first DeviceRequested
Event. It moves the app from DeviceInitial
State to DeviceLoadInProgress
State...
The DeviceRequested
Event is triggered when the human presses the Search Button 🔍 (At top right of the screen)
Here's the code for the Search Button that triggers the DeviceRequested
Event: widgets/device.dart
/// Implement the Stateful Widget for the PineTime Companion screen
class _DeviceState extends State<Device> {
/// Render the PineTime Companion screen
@override
Widget build(BuildContext context) {
// Render the screen with Button Bar above, followed by the Body
return Scaffold(
// Button Bar for the screen
appBar: AppBar(
// Buttons for the Button Bar
actions: <Widget>[
...
// Search Button
IconButton(
...
// When the Search Button is pressed...
onPressed: () async {
// Navigate to a new screen...
final device = await Navigator.push(
context,
MaterialPageRoute(
// For browsing Bluetooth LE devices
builder: (context) => FindDevice(),
),
);
// When the Bluetooth LE browser returns the PineTime Bluetooth Device...
if (device != null) {
// Get the Bloc that handles Device Events...
BlocProvider
.of<DeviceBloc>(context)
// Trigger the DeviceRequested Event...
.add(
// With the PineTime Bluetooth Device inside
DeviceRequested(
device: device
)
);
}
FindDevice()
shows the widget for browsing Bluetooth LE devices. FindDevice()
is defined here: widgets/find_device.dart
What does FindDevice()
return?
It returns the flutter_blue
Bluetooth Device (i.e. PineTime) that was selected by the human.
The Bluetooth Device shall be used in the next step for reading the firmware versions from PineTime.
Loading data over the web or Bluetooth shouldn't cause our Flutter App to freeze. Bloc has a clever solution: We trigger an Event asynchronously to load data, so that our app remains responsive!
Earlier we have triggered the DeviceRequested
Event upon pressing the Search Button: widgets/device.dart
// When Search Button has been pressed and Bluetooth Device has been selected...
// Get the Bloc that handles Device Events...
BlocProvider
.of<DeviceBloc>(context)
// Trigger the DeviceRequested Event...
.add(
// With the PineTime Bluetooth Device inside
DeviceRequested(
device: device
)
);
We handle the DeviceRequested
Event in our Device Bloc (Business Logic) like so...
Here's how we code this in DeviceBloc
: blocs/device_bloc.dart
/// Device Bloc that manages the Device States and Device Events
class DeviceBloc extends Bloc<DeviceEvent, DeviceState> {
...
/// When a Device Event is triggered, move to a new Device State (and a new screen)
@override
Stream<DeviceState> mapEventToState(DeviceEvent event) async* {
if (event is DeviceRequested) {
// Handle the DeviceRequested Event by loading data from PineTime
yield* _mapDeviceRequestedToState(event);
...
mapEventToState()
delegates the handling of the DeviceRequested
Event to the function _mapDeviceRequestedToState()
, which we'll see in a while.
Why is mapEventToState()
marked as async*
instead of async
?
mapEventToState()
responds to an Event by returning one State... Or a delayed sequence of States (which we'll see in _mapDeviceRequestedToState()
).
To return a delayed sequence of States, we declare the method as async*
.
Also note that instead of returning Future<DeviceState>
(a single delayed Device State), we now return Stream<DeviceState>
(a delayed sequence of Device States).
And instead of using yield
, we use yield*
to return a delayed sequence of States.
Why does _mapDeviceRequestedToState()
return a delayed sequence of States, instead of a single State?
Because it returns two States: DeviceLoadInProgress
first, followed by DeviceLoadSuccess
a short while later.
_mapDeviceRequestedToState()
responds to the DeviceRequested
Event by first returning the DeviceLoadInProgress
State: blocs/device_bloc.dart
/// Handle the DeviceRequested Event by loading data from PineTime
Stream<DeviceState> _mapDeviceRequestedToState(
DeviceRequested event,
) async* {
// Notify the Device Widget that we are loading data
yield DeviceLoadInProgress();
...
When the Device Widget sees the DeviceLoadInProgress
State, it renders a Loading Animation to keep the human entertained: widgets/device.dart
// If Device is loading, show the Loading Animation
builder: (context, state) {
if (state is DeviceLoadInProgress) {
return Center(child: CircularProgressIndicator());
}
Next _mapDeviceRequestedToState()
performs the actual loading of data: blocs/device_bloc.dart
// After notifying the Device Widget that we are loading data...
// Load data from PineTime over Bluetooth LE
final Device device = await deviceRepository.getDevice(event.device);
It calls the Device Repository to fetch the firmware versions from PineTime over Bluetooth LE.
getDevice()
(defined in repositories/device_repository.dart
) calls fetchDevice()
.
We have previously seen fetchDevice()
in repositories/device_api_client.dart
... It talks to PineTime over Bluetooth LE to fetch the firmware versions.
Finally _mapDeviceRequestedToState()
returns the DeviceLoadSuccess
State...
// Move to the DeviceLoadSuccess State, which renders the Device Summary Widget
yield DeviceLoadSuccess(device: device);
The DeviceLoadSuccess
State contains a Device
Data Model that has the firmware versions inside.
Asynchronous data loading accomplished!
How are Widgets updated in Bloc?
In Bloc, widgets listen for State updates and redraw themselves.
In the previous section we have loaded the Device
Data Model from PineTime and updated the State to DeviceLoadSuccess
. Now let's listen for State updates and re-render the Device Widget...
The Device Widget listens for the DeviceLoadSuccess
State with a BlocConsumer
and rebuilds itself like so: widgets/device.dart
/// Implement the Stateful Widget for the PineTime Companion screen
class _DeviceState extends State<Device> {
/// Render the PineTime Companion screen
@override
Widget build(BuildContext context) {
// Render the screen with Button Bar above, followed by the Body
return Scaffold(
// Button Bar for the screen (omitted)
appBar: ...,
// Body for the screen
body:
...
// Construct a BlocConsumer to listen for updates to the state and rebuild the widget
BlocConsumer<DeviceBloc, DeviceState>(
// Listen for updates to the state (omitted)
listener: ...
// Rebuild the widget when the state is updated
builder: (context, state) {
// When we have loaded the device info...
if (state is DeviceLoadSuccess) {
// Get the device info from the new state
final device = state.device;
// Construct the Device Summary with the device info
return
...
DeviceSummary(
device: device,
),
...
Can we use multiple Blocs?
Yes we can! There are two Blocs in our Flutter App...
DeviceBloc
: For loading data in our app
ThemeBloc
: For updating the UI theme in our app
The Device Widget uses a Theme Bloc nested inside a Device Bloc like this: widgets/device.dart
/// Implement the Stateful Widget for the PineTime Companion screen
class _DeviceState extends State<Device> {
/// Render the PineTime Companion screen
@override
Widget build(BuildContext context) {
// Render the screen with Button Bar above, followed by the Body
return Scaffold(
// Button Bar for the screen (omitted)
appBar: ...,
// Body for the screen
body:
...
// Construct a Device BlocConsumer to listen for updates to the Device State and rebuild the widget
BlocConsumer<DeviceBloc, DeviceState>(
// Listen for updates to the Device State
listener: (context, state) {
// If the device has been loaded successfully...
if (state is DeviceLoadSuccess) {
// Get the Theme Bloc that handles Theme States...
BlocProvider
.of<ThemeBloc>(context)
// Trigger a DeviceChanged Event to the Theme Bloc
.add(
DeviceChanged(
condition: state.device.condition
),
...
In the code above, the DeviceLoadSuccess
State triggers a DeviceChanged
Theme Event (defined in blocs/theme_bloc.dart
).
We handle the DeviceChanged
Theme Event in the Theme Bloc like so: blocs/theme_bloc.dart
/// Theme Bloc that manages the Theme States and Theme Events
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
/// When a Theme Event is triggered, move to a new Theme State
@override
Stream<ThemeState> mapEventToState(ThemeEvent event) async* {
if (event is DeviceChanged) {
yield _mapDeviceConditionToTheme(event.condition);
}
}
/// Return the UI Theme based on the current condition
ThemeState _mapDeviceConditionToTheme(DeviceCondition condition) {
ThemeState theme;
// Return the UI Theme with indigo colours
theme = ThemeState(
theme: ThemeData(
primaryColor: Colors.indigoAccent,
),
color: Colors.indigo,
);
return theme;
}
How do we verify that States and Events are working correctly in Bloc?
In VSCode, look at the Debug Console.
It shows the Events triggered and the transitions between States...
(...Search button pressed...)
onEvent DeviceRequested
Fetching device...
onTransition Transition {
currentState: DeviceInitial,
event: DeviceRequested,
nextState: DeviceLoadInProgress
}
(...Transmit Bluetooth LE Request to PineTime...)
(...Receive Bluetooth LE Response from PineTime...)
onTransition Transition {
currentState: DeviceLoadInProgress,
event: DeviceRequested,
nextState: DeviceLoadSuccess
}
onEvent DeviceChanged
(...Render DeviceSummary widget...)
The messages are generated by the SimpleBlocDelegate
class in simple_bloc_delegate.dart
Install VSCode and Flutter SDK according to the instructions here...
Then proceed to the next section to download and debug the PineTime Companion App.
The source code for our PineTime Companion App is located here...
github.com/lupyuen/pinetime-companion
In VSCode, click View → Command Palette
Enter Git Clone
Enter https://github.com/lupyuen/pinetime-companion
Select a folder to download the source code
When prompted to open the cloned repository, click Open
When prompted to get missing packages, click Get Packages
We're now ready to debug our PineTime Companion App on a real Android or iOS phone!
In VSCode, click Run → Start Debugging
Select the Dart & Flutter
debugger
Wait for the Flutter app to be compiled and deployed to our phone (May take a minute for the first time)
For iOS: Check the next section for additional Xcode steps
When the Flutter app starts, we'll be able to connect to PineTime to retrieve and display the firmware versions like this...
Check the Sample Android and iOS Debug Logs at the end of this article.
VSCode Debugger has many useful features for debugging Flutter apps. Here's what we see when we hit an unhandled exception in our Flutter app...
Dev Tools: Shows the widgets rendered in our app
Variables: Shows the local and global variables and their values
Call Stack: Function calls leading to the exception or breakpoint
Debug Console: Compilation, deployment and runtime messages
Source Code: Shows the line of code for the exception or breakpoint
Debug Toolbar: Resume execution, step into functions, step over code, hot reload, restart execution... More about debugging
See this article for more details on building Flutter apps with VSCode, including cool features like Hot Reload...
(Skip this section if you're building for Android)
This message appears when we debug our iOS app...
Here's what we need to do for iOS...
In VSCode, click Terminal → New Terminal
At the Terminal prompt, enter...
open ios/Runner.xcworkspace
In Xcode, click Runner → Targets Runner → Signing & Capabilities
Set Team
to our Apple Developer Account
Set Bundle Identifier
to a unique name
On our iPhone, click Settings → General → Device Management
Set the Trust Settings like this...
We should be able to launch and debug our Flutter app using the instructions from the previous section.
Check the Sample iOS Debug Log at the end of this article.
PineTime Companion App on iPhone
The code in this article is part of the upcoming open source PineTime Companion App for Android and iOS. So that we can update the firmware on our PineTime Smart Watches wirelessly, sync the date and time, show notifications from our phone, chart our heart rate, ... Maybe even control our smart home gadgets!
We'll be doing lots more coding...
Handle Other PineTime Commands: Update firmware, sync date and time, show mobile notifications, control smart home gadgets (via IFTTT and MQTT), ...
We shall do this by taking the Newt Manager code in Go and converting it to Flutter and Dart, as explained here...
Companion App for Linux Phones (like PinePhone): We shall take the Newt Manager code in Go and wrap it into a GTK3 app, using the gotk3
library...
PineTime Firmware Support: Today our PineTime Companion App talks to Mynewt and Zephyr operating systems on PineTime. We hope to implement the same Bluetooth LE protocol (Simple Management Protocol) on other operating systems, so that they may also enjoy wireless firmware updates...
"Firmware Update over Bluetooth Low Energy on PineTime Smart Watch"
Why are we maintaining two code bases: Flutter (for Android and iOS) and Go (for Linux phones)?
Because Flutter is probably the best way to build mobile apps... But it's not officially supported for Linux phones. The flutter_blue
plugin doesn't support Linux either.
So we need to stick with Go for Linux phones.
We're now exploring go-flutter
for porting the Flutter App to Linux. And recode flutter_blue
via FFI to a Linux Bluetooth LE library (in Go or C).
(Maybe someday when Flutter is officially supported on Linux phones, we can scrap the Go version!)
If you're keen to help out with the PineTime Companion App (or anything else in PineTime), come chat with the PineTime FOSS Community (and me) in the PineTime Chatroom!
PineTime Chatroom on Matrix / Discord / Telegram / IRC
Got a question, comment or suggestion? Create an Issue or submit a Pull Request here...
pinetime-rust-mynewt/rust/ app/src/bloc.md
"Convert Go to Flutter and Dart for PineTime Companion App"
"Your First Bluetooth Low Energy App with Flutter"
"Your First GTK App with Go and VSCodium"
"MCUBoot Bootloader for PineTime Smart Watch (nRF52)"
"Firmware Update over Bluetooth Low Energy on PineTime Smart Watch"
"Wireless Firmware Update In Action on PineTime Smart Watch (nRF52)"
Launching lib/main.dart on Pixel 4 XL in debug mode...
✓ Built build/app/outputs/apk/debug/app-debug.apk.
I/FlutterBluePlugin(20366): setup
Connecting to VM Service at ws://127.0.0.1:56153/XI6AjAwoNUM=/ws
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getLong(Ljava/lang/Object;J)J (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->arrayBaseOffset(Ljava/lang/Class;)I (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->copyMemory(JJJ)V (greylist, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->objectFieldOffset(Ljava/lang/reflect/Field;)J (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getByte(J)B (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getByte(Ljava/lang/Object;J)B (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getLong(J)J (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->putByte(JB)V (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->putByte(Ljava/lang/Object;JB)V (greylist,core-platform-api, linking, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getLong(Ljava/lang/Object;J)J (greylist,core-platform-api, reflection, allowed)
W/er_blue_exampl(20366): Accessing hidden method Lsun/misc/Unsafe;->getLong(Ljava/lang/Object;J)J (greylist,core-platform-api, reflection, allowed)
W/er_blue_exampl(20366): Accessing hidden field Ljava/nio/Buffer;->address:J (greylist, reflection, allowed)
D/FlutterBluePlugin(20366): mDevices size: 0
D/FlutterBluePlugin(20366): mDevices size: 0
D/BluetoothAdapter(20366): isLeEnabled(): ON
D/BluetoothLeScanner(20366): onScannerRegistered() - status=0 scannerId=8 mScannerId=0
D/FlutterBluePlugin(20366): mDevices size: 0
I/flutter (20366): onEvent DeviceRequested
I/flutter (20366): Fetching device...
I/flutter (20366): onTransition Transition { currentState: DeviceInitial, event: DeviceRequested, nextState: DeviceLoadInProgress }
D/BluetoothGatt(20366): connect() - device: E8:C1:1A:12:BA:89, auto: true
D/BluetoothGatt(20366): registerApp()
D/BluetoothGatt(20366): registerApp() - UUID=e0c4eada-3709-4f5c-80c4-2d39b4cc0309
D/BluetoothGatt(20366): onClientRegistered() - status=0 clientIf=9
D/FlutterBluePlugin(20366): mDevices size: 1
D/BluetoothGatt(20366): onClientConnectionState() - status=0 clientIf=9 device=E8:C1:1A:12:BA:89
D/FlutterBluePlugin(20366): [onConnectionStateChange] status: 0 newState: 2
I/flutter (20366): Device: BluetoothDevice{id: E8:C1:1A:12:BA:89, name: pinetime, type: BluetoothDeviceType.le, isDiscoveringServices: false, _services: []
D/BluetoothGatt(20366): discoverServices() - device: E8:C1:1A:12:BA:89
D/BluetoothAdapter(20366): isLeEnabled(): ON
D/BluetoothGatt(20366): onConnectionUpdated() - Device=E8:C1:1A:12:BA:89 interval=6 latency=0 timeout=500 status=0
D/BluetoothGatt(20366): onSearchComplete() = Device=E8:C1:1A:12:BA:89 Status=0
D/FlutterBluePlugin(20366): [onServicesDiscovered] count: 6 status: 0
D/BluetoothGatt(20366): setCharacteristicNotification() - uuid: da2e7828-fbce-4e01-ae9e-261174997c48 enable: true
D/FlutterBluePlugin(20366): [onDescriptorWrite] uuid: 00002902-0000-1000-8000-00805f9b34fb status: 0
I/flutter (20366): Encoded {NmpBase:{hdr:{Op:0 Flags:0 Len:0 Group:1 Seq:63 Id:0}}} {} to:
I/flutter (20366): a0
I/flutter (20366): Encoded:
I/flutter (20366): 00 00 00 01 00 01 3f 00 a0
I/flutter (20366): Notify:
D/BluetoothGatt(20366): onConnectionUpdated() - Device=E8:C1:1A:12:BA:89 interval=36 latency=0 timeout=500 status=0
D/FlutterBluePlugin(20366): [onCharacteristicWrite] uuid: da2e7828-fbce-4e01-ae9e-261174997c48 status: 0
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/chatty (20366): uid=10302(com.pauldemarco.flutter_blue_example) Binder:20366_2 identical 2 lines
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/flutter (20366): Notify: 01 00 00 f4 00 01 3f 00 bf 66 69 6d 61 67 65 73 9f bf 64 73
I/flutter (20366): Notify: 6c 6f 74 00 67 76 65 72 73 69 6f 6e 65 31 2e 30 2e 30 64 68
I/flutter (20366): Notify: 61 73 68 58 20 ea bc 3a ce 74 a8 28 4c 6f 78 c2 bc ad 3a e1
I/flutter (20366): Notify: 8d 39 26 75 c7 66 c5 1f 95 23 0f 13 39 3f 08 1c 5d 68 62 6f
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/flutter (20366): Notify: 6f 74 61 62 6c 65 f5 67 70 65 6e 64 69 6e 67 f4 69 63 6f 6e
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/chatty (20366): uid=10302(com.pauldemarco.flutter_blue_example) Binder:20366_2 identical 2 lines
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/flutter (20366): Notify: 66 69 72 6d 65 64 f5 66 61 63 74 69 76 65 f5 69 70 65 72 6d
I/flutter (20366): Notify: 61 6e 65 6e 74 f4 ff bf 64 73 6c 6f 74 01 67 76 65 72 73 69
I/flutter (20366): Notify: 6f 6e 65 31 2e 31 2e 30 64 68 61 73 68 58 20 0d 78 49 f7 fe
I/flutter (20366): Notify: 43 92 7a 87 d7 b4 d5 54 f8 43 08 82 33 d8 02 d5 09 0c 20 da
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/chatty (20366): uid=10302(com.pauldemarco.flutter_blue_example) Binder:20366_2 identical 1 line
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/flutter (20366): Notify: a1 e6 a7 77 72 99 6e 68 62 6f 6f 74 61 62 6c 65 f5 67 70 65
I/flutter (20366): Notify: 6e 64 69 6e 67 f4 69 63 6f 6e 66 69 72 6d 65 64 f4 66 61 63
I/flutter (20366): Notify: 74 69 76 65 f4 69 70 65 72 6d 61 6e 65 6e 74 f4 ff ff 6b 73
D/FlutterBluePlugin(20366): [onCharacteristicChanged] uuid: da2e7828-fbce-4e01-ae9e-261174997c48
I/flutter (20366): Notify: 70 6c 69 74 53 74 61 74 75 73 00 ff
I/flutter (20366): Response Length: 252 vs 252
D/BluetoothGatt(20366): cancelOpen() - device: E8:C1:1A:12:BA:89
D/BluetoothGatt(20366): onClientConnectionState() - status=0 clientIf=9 device=E8:C1:1A:12:BA:89
D/FlutterBluePlugin(20366): [onConnectionStateChange] status: 0 newState: 0
D/BluetoothGatt(20366): close()
D/BluetoothGatt(20366): unregisterApp() - mClientIf=9
I/flutter (20366): Decoded CBOR:
I/flutter (20366): Entry 0 : Value is => {images: [{slot: 0, version: 1.0.0, hash: [234, 188, 58, 206, 116, 168, 40, 76, 111, 120, 194, 188, 173, 58, 225, 141, 57, 38, 117, 199, 102, 197, 31, 149, 35, 15, 19, 57, 63, 8, 28, 93], bootable: true, pending: false, confirmed: true, active: true, permanent: false}, {slot: 1, version: 1.1.0, hash: [13, 120, 73, 247, 254, 67, 146, 122, 135, 215, 180, 213, 84, 248, 67, 8, 130, 51, 216, 2, 213, 9, 12, 32, 218, 161, 230, 167, 119, 114, 153, 110], bootable: true, pending: false, confirmed: false, active: false, permanent: false}], splitStatus: 0}
I/flutter (20366): {"images":[{"slot":0,"version":"1.0.0","hash":[234,188,58,206,116,168,40,76,111,120,194,188,173,58,225,141,57,38,117,199,102,197,31,149,35,15,19,57,63,8,28,93],"bootable":true,"pending":false,"confirmed":true,"active":true,"permanent":false},{"slot":1,"version":"1.1.0","hash":[13,120,73,247,254,67,146,122,135,215,180,213,84,248,67,8,130,51,216,2,213,9,12,32,218,161,230,167,119,114,153,110],"bootable":true,"pending":false,"confirmed":false,"active":false,"permanent":false}],"splitStatus":0}
I/flutter (20366): Decoded Response: [{images: [{slot: 0, version: 1.0.0, hash: [234, 188, 58, 206, 116, 168, 40, 76, 111, 120, 194, 188, 173, 58, 225, 141, 57, 38, 117, 199, 102, 197, 31, 149, 35, 15, 19, 57, 63, 8, 28, 93], bootable: true, pending: false, confirmed: true, active: true, permanent: false}, {slot: 1, version: 1.1.0, hash: [13, 120, 73, 247, 254, 67, 146, 122, 135, 215, 180, 213, 84, 248, 67, 8, 130, 51, 216, 2, 213, 9, 12, 32, 218, 161, 230, 167, 119, 114, 153, 110], bootable: true, pending: false, confirmed: false, active: false, permanent: false}], splitStatus: 0}]
I/flutter (20366): onTransition Transition { currentState: DeviceLoadInProgress, event: DeviceRequested, nextState: DeviceLoadSuccess }
I/flutter (20366): onEvent DeviceChanged
I/flutter (20366): onTransition Transition { currentState: ThemeState, event: DeviceChanged, nextState: ThemeState }
Application finished.
Exited (sigterm)
Launching lib/main.dart on iPhone 6 Plus in debug mode...
Warning: Missing build name (CFBundleShortVersionString).
Warning: Missing build number (CFBundleVersion).
Action Required: You must set a build name and number in the pubspec.yaml file version field before submitting to the App Store.
Automatically signing iOS for device deployment using specified development team in Xcode project:
Running pod install... 3.0s
Xcode build done. 131.8s
Installing and launching... 24.7s
Connecting to VM Service at ws://localhost:1024/ws
getConnectedDevices periphs size: 0
getConnectedDevices periphs size: 0
flutter: onEvent DeviceRequested
flutter: Fetching device...
flutter: onTransition Transition { currentState: DeviceInitial, event: DeviceRequested, nextState: DeviceLoadInProgress }
didConnectPeripheral
flutter: Device: BluetoothDevice{id: CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC, name: pinetime, type: BluetoothDeviceType.le, isDiscoveringServices: false, _services: []
didDiscoverServices
Found service: 1811
Found service: 180A
Found service: 8D53DC1D-1DB7-4CD3-868B-8A527460AA84
Found service: 59462F12-9543-9999-12C8-58B459A2712D
didDiscoverCharacteristicsForService
didDiscoverCharacteristicsForService
didDiscoverCharacteristicsForService
didDiscoverCharacteristicsForService
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
didDiscoverDescriptorsForCharacteristic
peripheral uuid:CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
service uuid:00001811-0000-1000-8000-00805f9b34fb
uuid: 00002a47-0000-1000-8000-00805f9b34fb value: (null)
uuid: 00002a46-0000-1000-8000-00805f9b34fb value: (null)
uuid: 00002a48-0000-1000-8000-00805f9b34fb value: (null)
uuid: 00002a45-0000-1000-8000-00805f9b34fb value: (null)
uuid: 00002a44-0000-1000-8000-00805f9b34fb value: (null)
peripheral uuid:CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
service uuid:0000180a-0000-1000-8000-00805f9b34fb
uuid: 00002a24-0000-1000-8000-00805f9b34fb value: (null)
uuid: 00002a26-0000-1000-8000-00805f9b34fb value: (null)
peripheral uuid:CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
service uuid:8d53dc1d-1db7-4cd3-868b-8a527460aa84
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: (null)
peripheral uuid:CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
service uuid:59462f12-9543-9999-12c8-58b459a2712d
uuid: 5c3a659e-897e-45e1-b016-007107c96df6 value: (null)
uuid: 5c3a659e-897e-45e1-b016-007107c96df7 value: (null)
didUpdateNotificationStateForCharacteristic
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: (null)
flutter: Encoded {NmpBase:{hdr:{Op:0 Flags:0 Len:0 Group:1 Seq:18 Id:0}}} {} to:
a0
flutter: Encoded:
00 00 00 01 00 01 12 00 a0
flutter: Notify:
didUpdateValueForCharacteristic CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: <010000f4 00011200 bf66696d 61676573 9fbf6473 6c6f7400 67766572 73696f6e 65312e30 2e306468 61736858 20eabc3a ce74a828 4c6f78c2 bcad3ae1 8d392675 c766c51f 95230f13 393f081c 5d68626f 6f746162 6c65f567 70656e64 696e67f4 69636f6e 6669726d 6564f566 61637469 7665f569 7065726d 616e656e 74f4ffbf 64736c6f 74016776 65727369 6f6e6531 2e312e30 64686173 6858200d 7849f7fe 43927a87 d7b4d554 f8430882 33d802d5 090c20da a1e6>
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: <010000f4 00011200 bf66696d 61676573 9fbf6473 6c6f7400 67766572 73696f6e 65312e30 2e306468 61736858 20eabc3a ce74a828 4c6f78c2 bcad3ae1 8d392675 c766c51f 95230f13 393f081c 5d68626f 6f746162 6c65f567 70656e64 696e67f4 69636f6e 6669726d 6564f566 61637469 7665f569 7065726d 616e656e 74f4ffbf 64736c6f 74016776 65727369 6f6e6531 2e312e30 64686173 6858200d 7849f7fe 43927a87 d7b4d554 f8430882 33d802d5 090c20da a1e6>
didUpdateValueForCharacteristic CED8A589-7B65-508A-F6B6-F0B3EFE3DFFC
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: <a7777299 6e68626f 6f746162 6c65f567 70656e64 696e67f4 69636f6e 6669726d 6564f466 61637469 7665f469 7065726d 616e656e 74f4ffff 6b73706c 69745374 61747573 00ff>
uuid: da2e7828-fbce-4e01-ae9e-261174997c48 value: <a7777299 6e68626f 6f746162 6c65f567 70656e64 696e67f4 69636f6e 6669726d 6564f466 61637469 7665f469 7065726d 616e656e 74f4ffff 6b73706c 69745374 61747573 00ff>
flutter: Notify: 01 00 00 f4 00 01 12 00 bf 66 69 6d 61 67 65 73 9f bf 64 73 6c 6f 74 00 67 76 65 72 73 69 6f 6e 65 31 2e 30 2e 30 64 68 61 73 68 58 20 ea bc 3a ce 74 a8 28 4c 6f 78 c2 bc ad 3a e1 8d 39 26 75 c7 66 c5 1f 95 23 0f 13 39 3f 08 1c 5d 68 62 6f 6f 74 61 62 6c 65 f5 67 70 65 6e 64 69 6e 67 f4 69 63 6f 6e 66 69 72 6d 65 64 f5 66 61 63 74 69 76 65 f5 69 70 65 72 6d 61 6e 65 6e 74 f4 ff bf 64 73 6c 6f 74 01 67 76 65 72 73 69 6f 6e 65 31 2e 31 2e 30 64 68 61 73 68 58 20 0d 78 49 f7 fe 43 92 7a 87 d7 b4 d5 54 f8 43 08 82 33 d8 02 d5 09 0c 20 da a1 e6
flutter: Notify: a7 77 72 99 6e 68 62 6f 6f 74 61 62 6c 65 f5 67 70 65 6e 64 69 6e 67 f4 69 63 6f 6e 66 69 72 6d 65 64 f4 66 61 63 74 69 76 65 f4 69 70 65 72 6d 61 6e 65 6e 74 f4 ff ff 6b 73 70 6c 69 74 53 74 61 74 75 73 00 ff
flutter: Response Length: 252 vs 252
didDisconnectPeripheral
flutter: Decoded CBOR:
Entry 0 : Value is => {images: [{slot: 0, version: 1.0.0, hash: [234, 188, 58, 206, 116, 168, 40, 76, 111, 120, 194, 188, 173, 58, 225, 141, 57, 38, 117, 199, 102, 197, 31, 149, 35, 15, 19, 57, 63, 8, 28, 93], bootable: true, pending: false, confirmed: true, active: true, permanent: false}, {slot: 1, version: 1.1.0, hash: [13, 120, 73, 247, 254, 67, 146, 122, 135, 215, 180, 213, 84, 248, 67, 8, 130, 51, 216, 2, 213, 9, 12, 32, 218, 161, 230, 167, 119, 114, 153, 110], bootable: true, pending: false, confirmed: false, active: false, permanent: false}], splitStatus: 0}
flutter: {"images":[{"slot":0,"version":"1.0.0","hash":[234,188,58,206,116,168,40,76,111,120,194,188,173,58,225,141,57,38,117,199,102,197,31,149,35,15,19,57,63,8,28,93],"bootable":true,"pending":false,"confirmed":true,"active":true,"permanent":false},{"slot":1,"version":"1.1.0","hash":[13,120,73,247,254,67,146,122,135,215,180,213,84,248,67,8,130,51,216,2,213,9,12,32,218,161,230,167,119,114,153,110],"bootable":true,"pending":false,"confirmed":false,"active":false,"permanent":false}],"splitStatus":0}
flutter: Decoded Response: [{images: [{slot: 0, version: 1.0.0, hash: [234, 188, 58, 206, 116, 168, 40, 76, 111, 120, 194, 188, 173, 58, 225, 141, 57, 38, 117, 199, 102, 197, 31, 149, 35, 15, 19, 57, 63, 8, 28, 93], bootable: true, pending: false, confirmed: true, active: true, permanent: false}, {slot: 1, version: 1.1.0, hash: [13, 120, 73, 247, 254, 67, 146, 122, 135, 215, 180, 213, 84, 248, 67, 8, 130, 51, 216, 2, 213, 9, 12, 32, 218, 161, 230, 167, 119, 114, 153, 110], bootable: true, pending: false, confirmed: false, active: false, permanent: false}], splitStatus: 0}]
flutter: onTransition Transition { currentState: DeviceLoadInProgress, event: DeviceRequested, nextState: DeviceLoadSuccess }
flutter: onEvent DeviceChanged
flutter: onTransition Transition { currentState: ThemeState, event: DeviceChanged, nextState: ThemeState }
Lost connection to device.
Exited (sigterm)