Skip to content

Commit

Permalink
Move database parsing logic out of Controller
Browse files Browse the repository at this point in the history
This separates business logic better and prepares for support of multiple
devices.  Logic is now handled in "Device" classes.
  • Loading branch information
dmeltzer committed May 24, 2020
1 parent 54a7277 commit 05fd676
Show file tree
Hide file tree
Showing 12 changed files with 476 additions and 291 deletions.
333 changes: 42 additions & 291 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,22 @@

namespace OCA\GadgetBridge\Controller;

use Doctrine\DBAL\DBALException;
use OC\DB\ConnectionFactory;
use OCA\GadgetBridge\ActivityKind;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\Files\File;
use OCP\IUserSession;
use OCP\IDBConnection;
use OCP\AppFramework\Http;
use OCP\Files\IRootFolder;
use OCA\GadgetBridge\Database;
use OCP\Files\NotFoundException;
use OCP\AppFramework\OCSController;
use OCP\Files\InvalidPathException;
use OCP\AppFramework\Http\DataResponse;
use OCA\GadgetBridge\InvalidDatabaseException;

class ApiController extends OCSController {
const TYPE_UNSET = -1;
const TYPE_NO_CHANGE = 0;
const TYPE_ACTIVITY = 1;
const TYPE_RUNNING = 2;
const TYPE_NONWEAR = 3;
const TYPE_CHARGING = 6;
const TYPE_LIGHT_SLEEP = 9;
const TYPE_IGNORE = 10;
const TYPE_DEEP_SLEEP = 11;
const TYPE_WAKE_UP = 12;


/** @var IDBConnection */
protected $connection;
Expand Down Expand Up @@ -97,20 +86,12 @@ public function selectDatabase($path) {
$storage = $dataToImport->getStorage();
$tmpPath = $storage->getLocalFile($dataToImport->getInternalPath());

// Doctrine doesn't throw an error if the db is invalid.
// A lower level check: let's see if the first 16 bytes say "SQLite Format"
// Taken from https://stackoverflow.com/questions/22275154/check-if-a-file-is-a-valid-sqlite-database
$handle = fopen($tmpPath, "r");
$contents = fread($handle, 15);
fclose($handle);
if($contents !== "SQLite format 3") {
// throw new \InvalidArgumentException('Unprocessable entity', Http::STATUS_UNPROCESSABLE_ENTITY);
return new DataResponse([], Http::STATUS_UNPROCESSABLE_ENTITY);
try {
$database = new Database($tmpPath);
} catch(InvalidDatabaseException $e) {
return new DataResponse(['error'=>$e], Http::STATUS_UNPROCESSABLE_ENTITY);
}

$connection = $this->getDatabaseConnection($tmpPath);

$connection->close();

$this->config->setUserValue($user->getUID(), 'gadgetbridge', 'database_file', $fileId);

Expand All @@ -125,32 +106,9 @@ public function selectDatabase($path) {
* @return DataResponse
*/
public function getDevices($databaseId) {
try {
$connection = $this->getConnection($databaseId);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}

$query = $connection->getQueryBuilder();
$query->automaticTablePrefix(false);
$query->select('*')
->from('DEVICE');
$database = $this->getDatabase($databaseId);

$result = $query->execute();
$devices = $result->fetchAll();
foreach ($devices as &$device) {
// This covers the MI bands that I know about. TODO expand to cover other devices possibly
if (intval($device['TYPE']) === 14 || intval($device['TYPE']) === 11) {
$newQuery = $connection->getQueryBuilder();
$newQuery->select('TIMESTAMP')
->from('MI_BAND_ACTIVITY_SAMPLE')
->where($newQuery->expr()->eq('DEVICE_ID', $newQuery->createNamedParameter($device['_id'])));
$device['STARTTIMESTAMP'] = min($newQuery->execute()->fetchAll());
}
}
$result->closeCursor();
$devices = $database->getDevices();

return new DataResponse($devices);
}
Expand All @@ -168,230 +126,13 @@ public function getDevices($databaseId) {
* @return DataResponse
*/
public function getDeviceData($databaseId, $deviceId, $startTimestamp, $endTimestamp) {
try {
$connection = $this->getConnection($databaseId);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}

// TODO: Is any of this necessary?
$query = $connection->getQueryBuilder();
$query->automaticTablePrefix(false);
$query->select('*')
->from('DEVICE')
->where($query->expr()->eq('_id', $query->createNamedParameter($deviceId)));
$database = $this->getDatabase($databaseId);
$device = $database->getDeviceById($deviceId);

$result = $query->execute();
$device = $result->fetch();
$result->closeCursor();

//EndTodo
$start = \DateTime::createFromFormat('U', $startTimestamp);
$end = \DateTime::createFromFormat('U', $endTimestamp);


if ($device['TYPE'] === '14') {
return $this->getMiBandData($connection, $device, $start, $end);
}

return new DataResponse([], Http::STATUS_UNPROCESSABLE_ENTITY);
}
/**
* @param IDBConnection $connection
* @param array $device
* @param \DateTime $start
* @param \DateTime $end
* @return DataResponse
*/
protected function getMiBandData(IDBConnection $connection, array $device, \DateTime $start, \DateTime $end) {
$query = $connection->getQueryBuilder();
$query->automaticTablePrefix(false);
$query
->select('*')
->from('MI_BAND_ACTIVITY_SAMPLE')
->where($query->expr()->eq('DEVICE_ID', $query->createNamedParameter($device['_id'])))
->andWhere($query->expr()->gte('TIMESTAMP', $query->createNamedParameter($start->getTimestamp())))
->andWhere($query->expr()->lte('TIMESTAMP', $query->createNamedParameter($end->getTimestamp())))
->orderBy('TIMESTAMP', 'ASC')
;

$result = $query->execute();
$data = $result->fetchAll();
$result->closeCursor();

$this->lastValidKind = $this->getLastMiBandActivity($connection, $device, $start->getTimestamp());
$range = $end->diff($start);
// Lets keep the amount of samples provided to the frontend realistic.
// A quick and dirty way of doing this: divide all samples by number of days requested.
$data = array_values(array_filter($data, function($k) use ($range) {
if ($range->days > 1) {
return $k % $range->days === 0;
}
return true;
}, ARRAY_FILTER_USE_KEY));
$data = array_map([$this, 'postProcessing'], $data);
$steps = array_column($data, 'STEPS');
$timestamps = array_column($data, 'TIMESTAMP');
$kinds = array_column($data, 'RAW_KIND');
$activityColors = array_column($data, 'ACTIVITY_COLOR');
$heartRates = array_column($data, 'HEART_RATE');
$newData = [
'STEPS' => $steps,
'TIMESTAMPS' => $timestamps,
'KINDS' => $kinds,
'ACTIVITY_COLORS' => $activityColors,
'HEART_RATES' => $heartRates
];
/**
* (int) $row['DEVICE_ID']
* (int) $row['USER_ID']
* \DateTime::createFromFormat('U', (int) $row['TIMESTAMP'])
* (int) $row['RAW_INTENSITY']
* (int) $row['STEPS']
* (int) $row['RAW_KIND']
* (int) $row['HEART_RATE']
*/
return new DataResponse($newData);
}

protected $lastValidKind = self::TYPE_UNSET;

protected $lastValidHeartRate;
protected function postProcessing($data) {
if (empty($data)) {
return $data;
}

// We expect MS on the JS side, lets expand timestamp here.
$data['TIMESTAMP'] *= 1000;

$rawKind = $data['RAW_KIND'];
if ($rawKind !== self::TYPE_UNSET) {
$rawKind &= 0xf;
$data['RAW_KIND'] = $rawKind;
}

switch ($rawKind) {
case self::TYPE_IGNORE:
case self::TYPE_NO_CHANGE:
if ($this->lastValidKind !== self::TYPE_UNSET) {
$data['RAW_KIND'] = $this->lastValidKind;
}
break;
default:
$this->lastValidKind = $data['RAW_KIND'];
break;
}

$data['RAW_KIND'] = $this->normalizeType($data['RAW_KIND']);
$data['ACTIVITY_COLOR'] = $this->getActivityColor($data['RAW_KIND']);

// Heartrate Normalization
$hRate = $data['HEART_RATE'];
if ($hRate > 20 && $hRate < 255) { // Valid Heartrate
$this->lastValidHeartRate = $hRate;
} else if ($hRate > 0) {
$data['HEART_RATE'] = $this->lastValidHeartRate;
} else {
$data['HEART_RATE'] = null;
}
$data['RAW_KIND'] *= 10;
if($data['RAW_KIND'] < 1) { // Unknown or unmeasured
$data['STEPS'] = 2;
} else { // Bound steps between 10 and 250. Not sure why, old code.
$data['STEPS'] = min(250, max(10, $data['STEPS']));
}
return $data;
}

protected function getLastMiBandActivity(IDBConnection $connection, array $device, $beforeTimestamp) {
$query = $connection->getQueryBuilder();
$query->automaticTablePrefix(false);
$query
->select('RAW_KIND')
->from('MI_BAND_ACTIVITY_SAMPLE')
->where($query->expr()->eq('DEVICE_ID', $query->createNamedParameter($device['_id'])))
->andWhere($query->expr()->lte('TIMESTAMP', $query->createNamedParameter($beforeTimestamp)))
->andWhere($query->expr()->notIn('RAW_KIND', $query->createNamedParameter([
self::TYPE_NO_CHANGE,
self::TYPE_IGNORE,
self::TYPE_UNSET,
16,
80,
96,
112,
], IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy('TIMESTAMP', 'DESC')
->setMaxResults(1)
;

$result = $query->execute();
$step = $result->fetch();
$result->closeCursor();

if (!$step) {
// No data before
return self::TYPE_UNSET;
}

return $step['RAW_KIND'] & 0xf;
}

protected function normalizeType($rawType) {
switch ($rawType) {
case self::TYPE_DEEP_SLEEP:
return ActivityKind::TYPE_DEEP_SLEEP;
case self::TYPE_LIGHT_SLEEP:
return ActivityKind::TYPE_LIGHT_SLEEP;
case self::TYPE_ACTIVITY:
case self::TYPE_RUNNING:
case self::TYPE_WAKE_UP:
return ActivityKind::TYPE_ACTIVITY;
case self::TYPE_NONWEAR:
return ActivityKind::TYPE_NOT_WORN;
case self::TYPE_CHARGING:
return ActivityKind::TYPE_NOT_WORN; //I believe it's a safe assumption
default:
case self::TYPE_UNSET: // fall through
return ActivityKind::TYPE_UNKNOWN;
}
}

private function getActivityColor($kind) {
switch($kind) {
case ActivityKind::TYPE_ACTIVITY:
return '#3ADF00';
case ActivityKind::TYPE_LIGHT_SLEEP:
return '#2ECCFA';
case ActivityKind::TYPE_DEEP_SLEEP:
return '#0040FF';
case ActivityKind::TYPE_NOT_WORN:
default:
return '#AAAAAA';
}
}

/**
* @param string $path
* @return IDBConnection
* @throws \InvalidArgumentException
*/
private function getDatabaseConnection($path) {
$factory = new \OC\DB\ConnectionFactory(\OC::$server->getSystemConfig());

try {
return $factory->getConnection('sqlite3', [
'user' => '',
'password' => '',
'path' => $path,
'sqlite.journal_mode' => 'WAL',
'tablePrefix' => '',
]);
} catch (DBALException $e) {
throw new \InvalidArgumentException('Unprocessable Entity', Http::STATUS_UNPROCESSABLE_ENTITY);
}
return new DataResponse($device->getSamples($start, $end));
}

/**
Expand All @@ -400,20 +141,30 @@ private function getDatabaseConnection($path) {
* @throws NotFoundException
* @throws \InvalidArgumentException
*/
protected function getConnection($database) {
$user = $this->userSession->getUser();
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$databaseFile = $userFolder->getById($database);
protected function getDatabase($database) {
try {
$user = $this->userSession->getUser();
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$databaseFile = $userFolder->getById($database);

if (count($databaseFile) !== 1 && !$databaseFile[0] instanceof File) {
throw new \InvalidArgumentException('Unprocessable entity', Http::STATUS_UNPROCESSABLE_ENTITY);
}
$databaseFile = $databaseFile[0];
if (count($databaseFile) !== 1 && !$databaseFile[0] instanceof File) {
throw new \InvalidArgumentException('Unprocessable entity', Http::STATUS_UNPROCESSABLE_ENTITY);
}
$databaseFile = $databaseFile[0];

/** @var File $databaseFile */
$storage = $databaseFile->getStorage();
$tmpPath = $storage->getLocalFile($databaseFile->getInternalPath());
/** @var File $databaseFile */
$storage = $databaseFile->getStorage();
$tmpPath = $storage->getLocalFile($databaseFile->getInternalPath());
$database = new Database($tmpPath);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
} catch (InvalidDatabaseException $e) {
return new DataResponse(['error' => $e], Http::STATUS_UNPROCESSABLE_ENTITY);
}

return $this->getDatabaseConnection($tmpPath);

return $database;
}
}
Loading

0 comments on commit 05fd676

Please sign in to comment.