Commit 111853cb authored by Nicolas Richard Walter Boeckh's avatar Nicolas Richard Walter Boeckh 💬

Fix to everything

Removed statefulness, fixed handler, POST requests, added map, fixed user interaction, fixed my own stupidity, etc.
parent cbd50456
# Collection of thoughts and general development process
## [0.0.3] - current
## [0.0.6] - Ongoing
## COVID
## [0.0.5] - 18/03/2020
Barebones API setup in `Node.js + Express + CORS` environment.
## [0.0.4] - 13/03/2020
Receive data server side, store in `PostGreSQL` database.
Server is at `129.194.69.37` (UNIGE internal).
### [0.0.4] Sanity and Sanitation
Add incremental backup to database. Add trimestrial maintenance (after 6 months data, clone 3 first months to other table, purge from primary table, API table routing). Also decentralized backup (independant infrastructure concerns).
## [0.0.3] - 08/03/2020
Build upon data to transmit to the server, then implement post request to transmit data every minute if possible, also not exceed a certain amount, also also cache data.
......@@ -13,12 +31,12 @@ Build upon data to transmit to the server, then implement post request to transm
The idea for BLE is to use Platform specific code, ie `Platform channels`...
| Platform Specific | Cross OS |
| ---------------------------- | ----------------------------:|
| Stay Alive ✅ | Scan environment ✅ |
| | Scan named MAC addresses ✅ |
| | Connect to device ✅ |
| Get Battery level ✅ | Read data stream ✅ |
| Platform Specific | Cross OS |
| ------------------------------- | ----------------------------:|
| Stay Alive ✅ (Android only) | Scan environment ✅ |
| | Scan named MAC addresses ✅ |
| | Connect to device ✅ |
| Get Battery level ✅ | Read data stream ✅ |
Testing procedure for background will include the backup system as well (ie. writing to files).
......@@ -46,19 +64,19 @@ Use instances of Stream, gotten from the betterment of my understanding of strea
This is a tricky one.
Sadly, because Apple doesn't care about it's users, we will only be able to support devices running `iOS >= 7.X` (100% of iPad 1, 1.5% of iPad 2, 100% of iPhone 3GS, 8.3% of iPhone 4 and 100% of iPod Touch 4 ~= 0.1% of the ecosystem, allegedly, although the iPhone 4 is barely 7 years old).
We will only be able to support devices running `iOS >= 7.X` (100% of iPad 1, 1.5% of iPad 2, 100% of iPhone 3GS, 8.3% of iPhone 4 and 100% of iPod Touch 4 ~= 0.1% of the ecosystem, although the iPhone 4 is barely 7 years old).
Secondly, we have the issue of ios's bricky situation. As they have very strenouos limits as to what can and can't be used in background, and the notion of a background service is foreign to them, we can't have our app run as a background service like for Android. The rest of the app would work, just not the core part that transmits from point A to point B.
Secondly, iOS has very strenuous limits as to what can and can't be used in background, and the notion of a background service is foreign in a consumer environment, we can't have our app run as a background service like for Android. The rest of the app would work, just not the core part that transmits from point A to point B.
Another solution would be to add a counter to the firmware, store everything in a file, and then do a `sync` but that would imply more control on the hardware side.
Despite the development environment being a pain to work with, we can't even just distribute the app as a 3rd party, as Apple doesn't allow that (although Europe is throwing them judicial flak for it).
Despite the development environment being complex, we can't even just distribute the app as a 3rd party, as Apple doesn't allow that (Europe is challenging these decisions legally).
TestFlight is not a "viable" option, only allows 10000 beta testers and required the guarantee of it being beta (no production stuff).
TestFlight is not a "viable" option, only allows 10000 beta testers and requires the guarantee of it being beta (no production stuff), and has the same strenuous limits as the AppStore.
Will require discussion obviously.
This will require discussion obviously.
Current idea is to use a enterprise app profile and to automate the install via batch script / iTunes.
Current idea is to use a enterprise app profile and to automate the install via a batch script / iTunes.
### [0.0.2] Resources
......@@ -70,7 +88,7 @@ Current idea is to use a enterprise app profile and to automate the install via
- [Platform Channels](https://flutter.dev/docs/development/platform-integration/platform-channels?tab=ios-channel-swift-tab) \[[Code](https://github.com/flutter/flutter/tree/master/examples/platform_channel_swift)\]
- [Networking in the background](https://flutter.dev/docs/cookbook/networking/background-parsing)
- [Singletons](https://medium.com/flutter-community/flutter-design-patterns-1-singleton-437f04e923ce)
- [Streams Channel](https://pub.dev/packages/streams_channel)
- [Streams Channel](https://pub.de1v/packages/streams_channel)
- [More Channels](https://programming.vip/docs/flutter-learning-notes-29-how-flutter-communicates-with-native.html)
- [More Channels - SO](https://stackoverflow.com/questions/56170451/what-is-the-difference-between-methodchannel-eventchannel-basicmessagechannel)
- [Gist with multiple Flutter projects](https://solido.github.io/awesome-flutter/)
......
import 'package:flutter/material.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:latlong/latlong.dart';
import 'package:logair_application/handlers/data_handler.dart';
import 'package:logair_application/logic/data_packet.dart';
import 'package:logair_application/handlers/position_handler.dart';
/// The [MapDisplayController] is a [Singleton] that handles the mapping of information.
class MapDisplayController {
/// Whether or not the user has assumed manual control of the [FlutterMap],
bool _manualMotion = false;
/// The current [Position] of the device.
LatLng _currentPos;
/// The previous [Position] of the device, for drawing lines.
LatLng _lastPos;
/// The [FlutterMap]'s zoom level, used to keep centering stable.
/// TODO Modify zoom action buttons.
double _zoom;
/// The [FlutterMap]s [MapController], kept unique in order to avoid incessant reloading of resources.
MapController _controller;
/// [List] of [Polyline] representing the evolution of the PM2.5 value.
/// TODO Discuss [Polyline] efficiency vs. 3px [Marker], especially considering navigation.
List<Polyline> _polylines = [];
/// [Marker] representing the user's current [Position].
Marker _marker;
/// [List] of [Color]s to be smudged as a gradient and cherry picked based on a value (lerped).
/// @see [_colorizeValue]
List<Color> _colors = [ Color.fromARGB(0, 0, 0xcc, 0), Color.fromARGB(0, 0, 0xcc, 0), Color.fromARGB(0, 0xff, 0xff, 0), Color.fromARGB(0, 0xeb, 0x8a, 0x14), Color.fromARGB(0, 0xff, 0, 0), Color.fromARGB(0, 0xa1, 0x06, 0x49), Color.fromARGB(0, 0x7e, 0, 0x23) ];
/// [List] of stops for the lerping to occur.
/// @see [_colorizeValue]
List<double> _stops = [0, 12, 35.4, 55.4, 150.4, 250.4, 500.4];
/// [Singleton]() instantiation factory.
factory MapDisplayController() => _singleton;
/// This function is executed on first initialization of the [MapDisplayController]
/// TODO: Throttle to 1 in 5 updates for efficiency ?
MapDisplayController._internal() {
/// A new [MapController] is used as the global Controller.
/// This avoids Garbage Collection rules and enables the [FlutterMap] to survive [State] changes, and other such events.
this._controller = new MapController();
/// This function is hooked to the [PositionHandler]s progress, ie. when a new [Position] is acquired (once per second), it is broadcasted across the app and can be listened to.
PositionHandler().getCurrentOrLastPosition().listen((posChanged) {
/// If a [BluetoothDevice] is connected, then the [DataHandler] will have a [DataPacket] available.
DataPacket latest = DataHandler().getLatestData();
/// If the [DataPacket] is older than 5 seconds we invalidate it.
if (latest != null && latest.timestamp() <= (DateTime.now().millisecondsSinceEpoch - 5000))
latest = null;
/// Keep track of the last [Position] to draw [Polyline]s.
this._lastPos = this._currentPos;
/// Convert the coorodinates to a [LatLng] structure that can be understood by [FlutterMap].
this._currentPos = LatLng(posChanged.latitude, posChanged.longitude);
/// If the user has not acquired manual control, then center the map onto the newer position.
if (!this._manualMotion && this._controller != null && this._controller.ready)
this._controller.move(this._currentPos, this._zoom);
/// Move the [Marker] to the current [Position].
this._marker = this._buildMarker(this._currentPos);
if (this._lastPos != null && this._currentPos != null && this._distance.as(LengthUnit.Meter, this._lastPos, this._currentPos) >= 2) {
// TODO Remove "|| true" to allow PM dependent gradient colors.
if (latest == null || true) {
if (this._polylines.length == 0)
this._polylines.add(
Polyline(
points: [this._lastPos, this._currentPos],
color: Colors.blue
)
);
else if (this._polylines.last.color == Colors.blue)
this._polylines.last.points.add(this._currentPos);
} else {
this._polylines.add(
Polyline(
points: [this._lastPos, this._currentPos],
color: _colorizeValue(latest.pm2_5())
)
);
}
}
});
}
/// Internal static [Singleton]() reference.
static final MapDisplayController _singleton = new MapDisplayController._internal();
/// Object used to calculate the real-life distance of two [Position]s
final Distance _distance = new Distance();
/// @Getter for [_currentPos]
LatLng currentPosition() => _currentPos != null ? _currentPos : LatLng(0, 0);
/// @Getter for [_controller]
MapController controller() => _controller;
/// Function called whenever the user interacts with the [FlutterMap], setting the [FlutterMap]'s viewport under their control.
void setControllerChange(LatLng position, double zoom) {
/// Avoid triggering full user control when only the zoom value changes.
if (this._currentPos != null && this._distance.as(LengthUnit.Meter, position, this._currentPos) >= 10)
this._manualMotion = true;
this._zoom = zoom;
}
/// Make the map respond to automatic events once more.
/// TODO Possibly remove the one tick delay.
void setControllerAutomatic() => this._manualMotion = false;
/// Enables the user to have a controlled zoom in, zoom out experience.
void setControllerZoom(bool zoomIn) {
/// Bound the zoomability.
if (zoomIn && this._zoom <= 19) this._zoom += 0.5;
else if (!zoomIn && this._zoom >= 1) this._zoom -= 0.5;
this._controller.move(this.controller().center, this._zoom);
}
/// Stream whether or not the [FlutterMap] is under full user control,
/// Prompt re-center on true.
Stream<bool> isManualMotionUsed() async* {
while (true) {
yield this._manualMotion;
await Future.delayed(Duration(milliseconds: 500));
}
}
/// @Getter for [_polylines].
List<Polyline> polylines() => this._polylines;
/// @Getter for [_marker].
Marker marker() => this._marker != null ? this._marker : _buildMarker(LatLng(0, 0));
/// Gets the lerped [Color] value at a for a given PM value.
Color _colorizeValue(double pmValue) {
for (int stop = 0; stop < _stops.length - 1; stop++) {
final double left = _stops[stop], right = _stops[stop + 1];
final Color colorLeft = _colors[stop], colorRight = _colors[stop + 1];
if (pmValue <= left)
return colorLeft;
else if (pmValue < right) {
final section = (pmValue - left) / (right - left);
return Color.lerp(colorLeft, colorRight, section);
}
}
return _colors.last;
}
/// Wrapper function to build a [Marker], with default values.
Marker _buildMarker(LatLng position) => Marker(
width: 20.0,
height: 20.0,
point: position,
builder: (ctx) => Container(
child: Icon(Icons.location_on, color: Colors.red,),
),
);
}
\ No newline at end of file
......@@ -35,7 +35,6 @@ class BTLEHandler {
BluetoothConnectionStatus _bluetoothConnectionStatus = BluetoothConnectionStatus.BTSTATUS_NOT_STREAMING;
/// [Stream] that tracks when the app is connected to the [BluetoothDevice]
Stream<bool> isDeviceConnected() async* {
while (true) {
......@@ -50,19 +49,19 @@ class BTLEHandler {
bool success = false;
if (this._device != null) {
services = await this._device.services.first;
services = services.where((s) => s.uuid.toString() == bluetoothLeCc254xServiceUUID).toList();
if (services.length > 0) {
characteristics = services[0].characteristics;
characteristics = characteristics.where((c) => c.uuid.toString() == bluetoothLeCc254xReadUUID).toList();
if (characteristics.length > 0) {
if (!characteristics[0].isNotifying)
await characteristics[0].setNotifyValue(true);
_characteristic = characteristics[0];
success = true;
}
services = await this._device.services.first;
services = services.where((s) => s.uuid.toString() == bluetoothLeCc254xServiceUUID).toList();
if (services.length > 0) {
characteristics = services[0].characteristics;
characteristics = characteristics.where((c) => c.uuid.toString() == bluetoothLeCc254xReadUUID).toList();
if (characteristics.length > 0) {
if (!characteristics[0].isNotifying)
await characteristics[0].setNotifyValue(true);
_characteristic = characteristics[0];
success = true;
}
}
}
return success;
}
......
import 'package:logair_application/handlers/bluetooth_le_handler.dart';
import 'package:logair_application/logic/data_header.dart';
import 'package:logair_application/logic/data_packet.dart';
class DataHandler {
factory DataHandler() => _singleton;
DataHandler._internal();
static const HEADER_SIZE = 11;
static final DataHandler _singleton = new DataHandler._internal();
......@@ -11,32 +14,63 @@ class DataHandler {
List<int> _data = [];
/// [List] containing separate packet instances received by this.
List<List<int>> _sortedData = [];
List<DataPacket> _sortedData = [];
/// Allows the [BTLEHandler] to add acquired information to this, and effects
/// a primary triage.
void addData(List<int> data) {
void addData(List<int> data) {
_data.addAll(data);
// Index of the packet termination symbol in ASCII.
int index = _data.indexWhere((e) => e == 36);
if (index >= 0 && _data.length > index + 1 && _data[index + 1] == 13 && _data[index + 2] == 10) {
// TODO if starts with `{` then use data header comparison, else use DataPacket constructor
_sortedData.add(_data.getRange(0, index + 1).where((e) => e != 10 && e != 13).toList());
_data.removeRange(0, index + 2);
int index = _data.indexWhere((e) => e == 91 || e == 123);
if (index == -1)
return;
_data.removeRange(0, index);
int termIndex = _data.indexWhere((e) => e == 36);
if (termIndex == -1)
return;
List<int> packet = _data.sublist(index, termIndex + 1);
_data.removeRange(0, termIndex);
if (packet.sublist(1).contains((e) => e == 91 || e == 123)) {
print('True ${packet.sublist(1).indexWhere((e) => e == 91 || e == 123)}');
int startIndex = packet.sublist(1).indexWhere((e) => e == 91 || e == 123);
packet.removeRange(0, startIndex);
}
if (packet[0] == 123) {
print('HEADER ${String.fromCharCodes(packet)}');
DataHeader().setHeader(packet);
} else if (packet[0] == 91) {
_sortedData.add(DataPacket(packet));
print('PACKET ${String.fromCharCodes(packet)}');
}
}
@Deprecated("USED FOR DEBUG")
doPrint() {
print("");
_sortedData.map((x) => String.fromCharCodes(x)).forEach(print);
print("Size : ${_sortedData.length}");
void printLatest() {
print(_sortedData.last);
}
/// Stream the latest confirmed data packet received by the device.
Stream<List<int>> getLatestData() async* {
List<DataPacket> getData() => _sortedData;
List<DataPacket> getDataRange(int range) => (range > _sortedData.length) ? _sortedData : _sortedData.sublist(0, range);
void clearData() {
_sortedData.removeWhere((element) => true);
}
void pop() {
_sortedData.removeAt(0);
}
DataPacket getLatestData() => (_sortedData.length > 0) ? _sortedData.last : null;
/// Stream the latest confirmed [DataPacket] received by the device.
Stream<DataPacket> getDataStream() async* {
while(true) {
yield (_sortedData.length > 0) ? _sortedData.last : [0];
yield (_sortedData.length > 0) ? _sortedData.last : null;
await Future.delayed(Duration(milliseconds: 500));
}
}
......
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:http/http.dart' as http;
import 'package:logair_application/logic/data_header.dart';
import 'package:logair_application/logic/data_packet.dart';
import 'package:logair_application/handlers/data_handler.dart';
/// Singleton data structure that contains services enabling the application to send data to a webserver.
/// TODO Make URL user configurable.
class NetworkHandler {
factory NetworkHandler() => _singleton;
NetworkHandler._internal() {
/// Define the interval between attempts to send the data to the server. (default: 1 minute).
const duration = const Duration(minutes: 1);
new Timer.periodic(duration, (Timer t) => this._sendDataToServer());
}
static final NetworkHandler _singleton = new NetworkHandler._internal();
/// Method to send acquired data packets by using a POST requests.
void _sendDataToServer() async {
/// The default endpoint.
String url = 'https://api.logair.unige.ch/v1/service';
/// Break conditions ([DataHeader] unset or no [DataPacket] available).
if (!DataHeader().isHeaderSet() || DataHandler().getData().length == 0)
return;
/// Get the map representation of a [DataHeader].
Map<String, dynamic> headerData = DataHeader().jsonify();
/// Get all the [DataPacket]s to be sent (maximum 100).
List<DataPacket> packets = DataHandler().getDataRange(100);
/// Convert the [DataPacket] list to a [Map] of their [List] representations.
Map<String, List<List<dynamic>>> packetList = {
'data' : LinkedHashSet<List<dynamic>>.from(packets.map((e) => e.jsonify()).toList()).toList()
};
/// Merge all of the [Map]s into 1.
Map<String, dynamic> postData = new Map<String, dynamic>();
postData.addAll(headerData);
postData.addAll(packetList);
/// Attempt to send the data to the endpoint via HTTP POST request.
http.Response response = await http.post(
url,
headers: { "accept": "application/json", "content-type": "application/json" },
body: json.encode(postData)
)
/// On error, the error should be ignored and not break the thread.
.catchError((e) => print('COULDN\'T CONNECT'));
/// If the response is defined as an instance of [http.Response], check it's status code and remove all of the packets from the log.
if (response != null && response.statusCode == 200) {
for (int i = 0; i < packets.length; i++) {
DataHandler().pop();
}
print('RESPONSE ${response.statusCode}');
}
}
}
\ No newline at end of file
......@@ -31,18 +31,22 @@ class PositionHandler {
Position getLastPosition() => _lastPosition;
/*
void _getGeolocationPermission() async {
this._geolocationStatus = await _geolocator.checkGeolocationPermissionStatus();
if (this._geolocationStatus != GeolocationStatus.granted) {
await PermissionService().requestLocationPermission();
await PermissionService().requestBackgroundLocationPermission();
await PermissionService().requestFineLocationPermission();
}
this._geolocationStatus = await _geolocator.checkGeolocationPermissionStatus();
}
// TODO Deprecate Last Known Position after <n> seconds
Stream<Position> getCurrentOrLastPosition() async* {
while (true) {
Position currentPosition;
if (!_permissionChecked) {
_geolocationStatus = await _geolocator.checkGeolocationPermissionStatus();
if (_geolocationStatus != GeolocationStatus.granted) {
await PermissionService().requestLocationPermission();
await PermissionService().requestBackgroundLocationPermission();
await PermissionService().requestFineLocationPermission();
}
_geolocationStatus = await _geolocator.checkGeolocationPermissionStatus();
this._getGeolocationPermission();
_permissionChecked = true;
}
if (_geolocationStatus == GeolocationStatus.granted)
......@@ -58,10 +62,7 @@ class PositionHandler {
else
yield null;
await Future.delayed(Duration(milliseconds: 900));
await Future.delayed(Duration(milliseconds: 1000));
}
}
*/
}
\ No newline at end of file
import 'package:collection/collection.dart';
/// Describes the contents of the data packets
/// @ToDeprecate Describes the contents of the data packets
/// @ReplaceBy Includes the device name and a config_file URL
class DataHeader {
/// Singleton factory
factory DataHeader() => _singleton;
final List<String> _default = ["lat", "long", "alt", "spd", "hed", "temp", "relHum", "pres", "pm1", "pm2_5", "pm4", "pm10"];
DataHeader._internal() {
_headerData = [];
_headerSet = false;
}
static final DataHeader _singleton = new DataHeader._internal();
List<int> _headerData;
List<String> _headerData;
bool _headerSet;
/// Defines whether this has been set, to determine whether it needs to be set.
/// {@value false} on d
void setHeader(List<int> header) {
this._headerData = String.fromCharCodes(header.sublist(1, header.length - 1)).split(',');
this._headerSet = true;
}
/// @Getter for _headerSet;
bool isHeaderSet() => _headerSet;
bool isEqual(List<int> that) => ListEquality().equals(_headerData, that);
Map<dynamic, dynamic> getJson() {
return {};
}
// TODO
Map<String, dynamic> jsonify() => { 'device_id' : this._headerData[0], 'url': '000000000000' };
}
\ No newline at end of file
/**Standalone piece of code, supposed to take in a String of values obtained via Bluetooth.
* The string should be of type [lat, long, alt, hed, spd, temp, pres, rhum, pm1, pm2_5, pm4, pm10, CRC_8]
*
*/
import 'package:geolocator/geolocator.dart';
import 'package:logair_application/handlers/position_handler.dart';
/// Represents 1 second of data streamed from LogAir's devices,
/// and contains methods for:
/// quick lookup,
/// conversion to POST format,
class DataPacket {
String _data;
int _timeStamp;
List<int> _data;
int _timestamp;
/* Location variables */
double _latitude;
......@@ -28,16 +26,79 @@ class DataPacket {
double _pm2_5;
double _pm4;
double _pm10;
int timestamp() => _timestamp;
double latitude() => _latitude;
double longitude() => _longitude;
int altitude() => _altitude;
double heading() => _heading;
double speed() => _speed;
double temperature() => _temperature;
double pressure() => _pressure;
double relativeHumidity() => _relativeHumidity;
double pm1() => _pm1;
double pm2_5() => _pm2_5;
double pm4() => _pm4;
double pm10() => _pm10;
DataPacket(this._data) {
_timeStamp = DateTime.now().millisecondsSinceEpoch;
double parseDoubleWrapper(String data) {
double result = double.tryParse(data);
if (result == null) {
int result2 = int.tryParse(data);
if (result2 == null)
return null;
else
result = result2.toDouble();
}
return result;
}
append(String dataFragment) {
_data += dataFragment;
DataPacket(this._data) {
_timestamp = DateTime.now().millisecondsSinceEpoch;
List<String> acquiredData = String.fromCharCodes(this._data.sublist(1, this._data.length - 1)).split(',');
this._latitude = (acquiredData[0] != '') ? parseDoubleWrapper(acquiredData[0]) : null;
this._longitude = (acquiredData[1] != '') ? parseDoubleWrapper(acquiredData[1]) : null;
this._altitude = (acquiredData[2] != '') ? int.tryParse(acquiredData[2]) : null;
this._heading = (acquiredData[3] != '') ? parseDoubleWrapper(acquiredData[3]) : null;
this._speed = (acquiredData[4] != '') ? parseDoubleWrapper(acquiredData[4]) : null;
this._temperature = (acquiredData[5] != '') ? parseDoubleWrapper(acquiredData[5]) : null;
this._pressure = (acquiredData[6] != '') ? parseDoubleWrapper(acquiredData[6]) : null;
this._relativeHumidity = (acquiredData[7] != '') ? parseDoubleWrapper(acquiredData[7]) : null;
this._pm1 = (acquiredData[8] != '') ? parseDoubleWrapper(acquiredData[8]) : null;
this._pm2_5 = (acquiredData[9] != '') ? parseDoubleWrapper(acquiredData[9]) : null;
this._pm4 = (acquiredData[10] != '') ? parseDoubleWrapper(acquiredData[10]) : null;
this._pm10 = (acquiredData[11] != '') ? parseDoubleWrapper(acquiredData[11]) : null;
if (this._latitude == null || this._longitude == null) {
Position position = PositionHandler().getLastPosition();
if (position != null) {
this._latitude = position.latitude;
this._longitude = position.longitude;
this._altitude = position.altitude.toInt();
this._heading = position.heading;
this._speed = position.speed;
}
}
}
jsonify() => [
1
String stringify() => '${this._latitude}, ${this._longitude}, ${this._temperature}, ${this._pressure}, ${this._relativeHumidity}, ${this._pm2_5}, ${this._pm10}';