diff --git a/.eslintrc.js b/.eslintrc.js
index 4bb1e696f5a..974a562c7c6 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -9,6 +9,15 @@ module.exports = {
"commonjs": true,
"es6": true,
"node": true,
+ "mocha": true,
"jquery": true
+ },
+ "rules": {
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "should|expect"
+ }
+ ]
}
};
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 00000000000..3c9fc778c1b
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,34 @@
+name: CI test
+
+on: [push, pull_request]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-16.04
+
+ strategy:
+ matrix:
+ node-version: [10.x, 12.x]
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Install dependencies
+ run: npm install
+ - name: Install MongoDB
+ run: |
+ wget -qO - https://www.mongodb.org/static/pgp/server-3.6.asc | sudo apt-key add -
+ echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu xenial/mongodb-org/3.6 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.6.list
+ sudo apt-get update
+ sudo apt-get install -y mongodb-org
+ sudo apt-get install -y --allow-downgrades mongodb-org=3.6.14 mongodb-org-server=3.6.14 mongodb-org-shell=3.6.14 mongodb-org-mongos=3.6.14 mongodb-org-tools=3.6.14
+ - name: Start MongoDB
+ run: sudo systemctl start mongod
+ - name: Run Tests
+ run: npm run-script test-ci
+ - name: Send Coverage
+ run: npm run-script coverage
diff --git a/.gitignore b/.gitignore
index 2cb28800650..1cf7ab06f2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,8 +8,8 @@ bundle/bundle.out.js
.idea/
*.iml
my.env
+my.*.env
-*.env
static/bower_components/
.*.sw?
.DS_Store
@@ -24,3 +24,9 @@ npm-debug.log
*.heapsnapshot
/tmp
+/.vs
+/cgm-remote-monitor.njsproj
+/cgm-remote-monitor.sln
+/obj/Debug
+/bin
+/*.bat
diff --git a/.travis.yml b/.travis.yml
index 90331521284..78b056b8dbc 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -26,5 +26,5 @@ matrix:
include:
- node_js: "10"
<<: *node_js-steps
- - node_js: "node" # Latest Node is not supported, and recommend, but we'll test it to know incompatibility issues
+ - node_js: "12" # Latest Node is not supported, and recommend, but we'll test it to know incompatibility issues
<<: *node_js-steps
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e2e71049f4c..7beea2d51a3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,9 +25,7 @@
[![Build Status][build-img]][build-url]
[![Dependency Status][dependency-img]][dependency-url]
[![Coverage Status][coverage-img]][coverage-url]
-[![Gitter chat][gitter-img]][gitter-url]
-[![Stories in Ready][ready-img]][waffle]
-[![Stories in Progress][progress-img]][waffle]
+[![Discord chat][discord-img]][discord-url]
[build-img]: https://img.shields.io/travis/nightscout/cgm-remote-monitor.svg
[build-url]: https://travis-ci.org/nightscout/cgm-remote-monitor
@@ -35,11 +33,8 @@
[dependency-url]: https://david-dm.org/nightscout/cgm-remote-monitor
[coverage-img]: https://img.shields.io/coveralls/nightscout/cgm-remote-monitor/master.svg
[coverage-url]: https://coveralls.io/r/nightscout/cgm-remote-monitor?branch=master
-[gitter-img]: https://img.shields.io/badge/Gitter-Join%20Chat%20%E2%86%92-1dce73.svg
-[gitter-url]: https://gitter.im/nightscout/public
-[ready-img]: https://badge.waffle.io/nightscout/cgm-remote-monitor.svg?label=ready&title=Ready
-[waffle]: https://waffle.io/nightscout/cgm-remote-monitor
-[progress-img]: https://badge.waffle.io/nightscout/cgm-remote-monitor.svg?label=in+progress&title=In+Progress
+[discord-img]: https://img.shields.io/discord/629952586895851530?label=discord%20chat
+[discord-url]: https://discord.gg/rTKhrqz
## Installation for development
@@ -47,11 +42,11 @@ Nightscout is a Node.js application. The basic installation of the software for
1. Clone the software to your local machine using git
2. Install Node from https://nodejs.org/en/download/
-2. Use `npm` to install Nightscout dependencies by invokin `npm install` in the project directory. Note the
- dependency installation has to be done usign a non-root user - _do not use root_ for development and hosting
+2. Use `npm` to install Nightscout dependencies by invoking `npm install` in the project directory. Note the
+ dependency installation has to be done using a non-root user - _do not use root_ for development and hosting
the software!
-3. Get a Mongo database by either installing Mongo locally, or get a free cloud account from mLab or Mongodb Atlas.
-4. Configure nightscout by copying `my.env.template` to `my.env` and run it - see the next chapter in the instructions
+3. Get a Mongo database by either installing Mongo locally, or get a free cloud account from mLab or MongoDB Atlas.
+4. Configure Nightscout by copying `my.env.template` to `my.env` and run it - see the next chapter in the instructions
## Develop on `dev`
@@ -59,7 +54,7 @@ We develop on the `dev` branch. All new pull requests should be targeted to `dev
You can get the `dev` branch checked out using `git checkout dev`.
-Once checked out, install the dependencies using `npm install`, then copy the included `my.env.template`file to `my.env` and edit the file to include your settings (like the Mongo URL). Leave the `NODE_ENV=development` line intact. Once set, run the site using `npm run dev`. This will start Nigthscout in the development mode, with different code packaging rules and automatic restarting of the server using nodemon, when you save changed files on disk. The client also hot-reloads new code in, but it's recommended to reload the the website after changes due to the way the plugin sandbox works.
+Once checked out, install the dependencies using `npm install`, then copy the included `my.env.template`file to `my.env` and edit the file to include your settings (like the Mongo URL). Leave the `NODE_ENV=development` line intact. Once set, run the site using `npm run dev`. This will start Nightscout in the development mode, with different code packaging rules and automatic restarting of the server using nodemon, when you save changed files on disk. The client also hot-reloads new code in, but it's recommended to reload the website after changes due to the way the plugin sandbox works.
Note the template sets `INSECURE_USE_HTTP` to `true` to enable the site to work over HTTP in local development.
@@ -67,20 +62,15 @@ If you want to additionaly test the site in production mode, create a file calle
## REST API
-Nightscout implements a REST API for data syncronization. The API is documented using Swagger. To access the documentation
-for the API, run Nightscout locally and load the documentation from /api-docs (or read the associated swagger.json and swagger.yaml
-files locally).
+Nightscout implements a REST API for data syncronization. The API is documented using Swagger. To access the documentation for the API, run Nightscout locally and load the documentation from /api-docs (or read the associated swagger.json and swagger.yaml files locally).
-Note all dates used to access the API and dates stored in the objects are expected to comply with the ISO-8601 format and
-be deserializable by the Javascript Date class. Of note here is the dates can contain a plus sign which has a special meaning in
-URL encoding, so when issuing requests that place dates to the URL, take special care to ensure the data is properly URL
-encoded.
+Note all dates used to access the API and dates stored in the objects are expected to comply with the ISO-8601 format and be deserializable by the Javascript Date class. Of note here is the dates can contain a plus sign which has a special meaning in URL encoding, so when issuing requests that place dates to the URL, take special care to ensure the data is properly URL encoded.
## Design & new features
If you intend to add a new feature, please allow the community to participate in the design process by creating an issue to discuss your design. For new features, the issue should describe what use cases the new feature intends to solve, or which existing use cases are being improved.
-Note Nighscout has a plugin architecture for adding new features. We expect most code for new features live inside a Plugin, so the code retains a clear separation of concerns. If the Plugin API doesn't implement all features you need to implement your feature, please discuss with us on adding those features to the API. Note new features should under almost no circumstances require changes to the existing plugins.
+Note Nightscout has a plugin architecture for adding new features. We expect most code for new features live inside a Plugin, so the code retains a clear separation of concerns. If the Plugin API doesn't implement all features you need to implement your feature, please discuss with us on adding those features to the API. Note new features should under almost no circumstances require changes to the existing plugins.
## Style Guide
@@ -105,7 +95,7 @@ If in doubt, format your code with `js-beautify --indent-size 2 --comma-first -
## Create a prototype
-Fork cgm-remote-monitor and create a branch. You can create a branch using `git checkout -b wip/add-my-widget`. This creates a new branch called `wip/add-my-widget`. The `wip` stands for work in progress and is a common prefix so that when know what to expect when reviewing many branches.
+Fork cgm-remote-monitor and create a branch. You can create a branch using `git checkout -b wip/add-my-widget`. This creates a new branch called `wip/add-my-widget`. The "`wip`" stands for work-in-progress and is a common prefix so that we know what to expect when reviewing many branches.
## Submit a pull request
@@ -115,11 +105,9 @@ This can be done by checking your code `git commit -avm 'my improvements are her
Now that the commits are available on github, you can click on the compare buttons on your fork to create a pull request. Make sure to select [Nightscout's `dev` branch](https://github.com/nightscout/cgm-remote-monitor/tree/dev).
-We assume all new Pull Requests are at least smoke tested by the author and all code in the PR actually works.
-Please include a description of what the features do and rationalize why the changes are needed.
+We assume all new Pull Requests are at least smoke tested by the author and all code in the PR actually works. Please include a description of what the features do and rationalize why the changes are needed.
-If you add any new NPM module dependencies, you have to rationalize why there are needed - we prefer pull requests that reduce dependencies, not add them.
-Before releasing a a new version, we check with `npm audit` if our dependencies don't have known security issues.
+If you add any new NPM module dependencies, you have to rationalize why they are needed - we prefer pull requests that reduce dependencies, not add them. Before releasing a a new version, we check with `npm audit` if our dependencies don't have known security issues.
When adding new features that add configuration options, please ensure the `README` document is amended with information on the new configuration.
@@ -142,7 +130,7 @@ We encourage liberal use of the comments, including images where appropriate.
## Co-ordination
-Most cgm-remote-monitor hackers use github's ticketing system, along with Facebook cgm-in-the-cloud, and gitter.
+We primarily use GitHub's ticketing system for discussing PRs and bugs, and [Discord][discord-url] for general development chatter.
We use git-flow, with `master` as our production, stable branch, and `dev` is used to queue up for upcoming releases. Everything else is done on branches, hopefully with names that indicate what to expect.
@@ -152,7 +140,7 @@ Every commit is tested by travis. We encourage adding tests to validate your de
## Other Dev Tips
-* Join the [Gitter chat][gitter-url]
+* Join the [Discord chat][discord-url].
* Get a local dev environment setup if you haven't already.
* Try breaking up big features/improvements into small parts. It's much easier to accept small PR's.
* Create tests for your new code as well as the old code. We are aiming for a full test coverage.
@@ -193,6 +181,7 @@ Also if you can't code, it's possible to contribute by improving the documentati
[@unsoluble]: https://github.com/unsoluble
[@viderehh]: https://github.com/viderehh
[@OpossumGit]: https://github.com/OpossumGit
+[@Bartlomiejsz]: https://github.com/Bartlomiejsz
| Contribution area | List of contributors |
| ------------------------------------- | ---------------------------------- |
@@ -203,13 +192,13 @@ Also if you can't code, it's possible to contribute by improving the documentati
| Release coordination 0.11.x: | [@PieterGit] |
| Issue/Pull request coordination: | Please volunteer |
| Cleaning up git fork spam: | Please volunteer |
-| Documentation writers: | [@andrew-warrington][@unsoluble] [@tynbendad] [@danamlewis] [@rarneson] |
+| Documentation writers: | [@andrew-warrington] [@unsoluble] [@tynbendad] [@danamlewis] [@rarneson] |
### Plugin contributors
| Contribution area | List of developers | List of testers
| ------------------------------------- | -------------------- | -------------------- |
-| [`alexa` (Amazon Alexa)](README.md#alexa-amazon-alexa)| Please volunteer | Please volunteer |
+| [`alexa` (Amazon Alexa)](README.md#alexa-amazon-alexa)| [@inventor96] | Please volunteer |
| [`ar2` (AR2 Forecasting)](README.md#ar2-ar2-forecasting)| Please volunteer | Please volunteer |
| [`basal` (Basal Profile)](README.md#basal-basal-profile)| Please volunteer | Please volunteer |
| [`boluscalc` (Bolus Wizard)](README.md#boluscalc-bolus-wizard)| Please volunteer | Please volunteer |
@@ -224,7 +213,7 @@ Also if you can't code, it's possible to contribute by improving the documentati
| [`direction` (BG Direction)](README.md#direction-bg-direction)| Please volunteer | Please volunteer |
| [`errorcodes` (CGM Error Codes)](README.md#errorcodes-cgm-error-codes)| Please volunteer | Please volunteer |
| [`food` (Custom Foods)](README.md#food-custom-foods)| Please volunteer | Please volunteer |
-| [`googlehome` (Google Home)](README.md#google-home) |[@mdomox] [@rickfriele] | [@mcdafydd] [@oteroos] [@jamieowendexcom] |
+| [`googlehome` (Google Home/DialogFlow)](README.md#googlehome-google-homedialogflow)| [@mdomox] [@rickfriele] [@inventor96] | [@mcdafydd] [@oteroos] [@jamieowendexcom] |
| [`iage` (Insulin Age)](README.md#iage-insulin-age)| Please volunteer | Please volunteer |
| [`iob` (Insulin-on-Board)](README.md#iob-insulin-on-board)| Please volunteer | Please volunteer |
| [`loop` (Loop)](README.md#loop-loop)| Please volunteer | Please volunteer |
@@ -233,9 +222,9 @@ Also if you can't code, it's possible to contribute by improving the documentati
| [`profile` (Treatment Profile)](README.md#profile-treatment-profile)| Please volunteer | Please volunteer |
| [`pump` (Pump Monitoring)](README.md#pump-pump-monitoring)| Please volunteer | Please volunteer |
| [`rawbg` (Raw BG)](README.md#rawbg-raw-bg)| [@jpcunningh] | Please volunteer |
-| [`sage` (Sensor Age)](README.md#sage-sensor-age)| @jpcunningh | Please volunteer |
+| [`sage` (Sensor Age)](README.md#sage-sensor-age)| [@jpcunningh] | Please volunteer |
| [`simplealarms` (Simple BG Alarms)](README.md#simplealarms-simple-bg-alarms)| Please volunteer | Please volunteer |
-| [`speech` (Speech)](README.md#speech-speech) | [@sulkaharo] | Please volunteer |
+| [`speech` (Speech)](README.md#speech-speech)| [@sulkaharo] | Please volunteer |
| [`timeago` (Time Ago)](README.md#timeago-time-ago)| Please volunteer | Please volunteer |
| [`treatmentnotify` (Treatment Notifications)](README.md#treatmentnotify-treatment-notifications)| Please volunteer | Please volunteer |
| [`upbat` (Uploader Battery)](README.md#upbat-uploader-battery)| [@jpcunningh] | Please volunteer |
@@ -252,19 +241,19 @@ Languages with less than 90% coverage will be removed in a future Nightscout ver
| Čeština (`cs`) |Please volunteer|OK |
| Deutsch (`de`) |[@viderehh] [@herzogmedia] |OK |
| Dansk (`dk`) | [@janrpn] |OK |
-| Ελληνικά `(el`)|Please volunteer|Needs attention: 68.5%|
+| Ελληνικά (`el`)|Please volunteer|Needs attention: 68.5%|
| English (`en`)|Please volunteer|OK|
| Español (`es`) |Please volunteer|OK|
| Suomi (`fi`)|[@sulkaharo] |OK|
| Français (`fr`)|Please volunteer|OK|
-| עברית (`he`)|Please volunteer|OK|
-| Hrvatski (`hr`)|[@OpossumGit]|Needs attention: 47.8% - committed 100% to dev|
+| עברית (`he`)| [@jakebloom] |OK|
+| Hrvatski (`hr`)|[@OpossumGit]|OK|
| Italiano (`it`)|Please volunteer|OK|
| 日本語 (`ja`)|[@LuminaryXion]|Working on this|
| 한국어 (`ko`)|Please volunteer|Needs attention: 80.6%|
| Norsk (Bokmål) (`nb`)|Please volunteer|OK|
| Nederlands (`nl`)|[@PieterGit]|OK|
-| Polski (`pl`)|Please volunteer|OK|
+| Polski (`pl`)|[@Bartlomiejsz]|OK|
| Português (Brasil) (`pt`)|Please volunteer|OK|
| Română (`ro`)|Please volunteer|OK|
| Русский (`ru`)|[@apanasef]|OK|
@@ -279,7 +268,7 @@ Languages with less than 90% coverage will be removed in a future Nightscout ver
### List of all contributors
| Contribution area | List of contributors |
| ------------------------------------- | -------------------- |
-| All active developers: | [@jasoncalabrese] [@jpcunningh] [@jweismann] [@komarserjio] [@mdomox] [@MilosKozak] [@PieterGit] [@rickfriele] [@sulkaharo]
+| All active developers: | [@jasoncalabrese] [@jpcunningh] [@jweismann] [@komarserjio] [@mdomox] [@MilosKozak] [@PieterGit] [@rickfriele] [@sulkaharo] [@unsoluble]
| All active testers/documentors: | [@danamlewis] [@jamieowendexcom] [@mcdafydd] [@oteroos] [@rarneson] [@tynbendad] [@unsoluble]
| All active translators: | [@apanasef] [@jizhongwen] [@viderehh] [@herzogmedia] [@LuminaryXion] [@OpossumGit]
diff --git a/Makefile b/Makefile
index bf87aaed1c1..1ca626ab88c 100644
--- a/Makefile
+++ b/Makefile
@@ -43,7 +43,7 @@ report:
test_onebyone:
python -c 'import os,sys,fcntl; flags = fcntl.fcntl(sys.stdout, fcntl.F_GETFL); fcntl.fcntl(sys.stdout, fcntl.F_SETFL, flags&~os.O_NONBLOCK);'
- $(foreach var,$(wildcard tests/*.js),${MONGO_SETTINGS} ${MOCHA} --timeout 30000 --exit --bail -R tap $(var);)
+ for var in tests/*.js; do ${MONGO_SETTINGS} ${MOCHA} --timeout 30000 --exit --bail -R tap $$var; done | tap-set-exit
test:
${MONGO_SETTINGS} ${MOCHA} --timeout 30000 --exit --bail -R tap ${TESTS}
@@ -52,7 +52,7 @@ travis:
python -c 'import os,sys,fcntl; flags = fcntl.fcntl(sys.stdout, fcntl.F_GETFL); fcntl.fcntl(sys.stdout, fcntl.F_SETFL, flags&~os.O_NONBLOCK);'
# NODE_ENV=test ${MONGO_SETTINGS} \
# ${ISTANBUL} cover ${MOCHA} --report lcovonly -- --timeout 5000 -R tap ${TESTS}
- $(foreach var,$(wildcard tests/*.js),${MONGO_SETTINGS} ${MOCHA} --timeout 30000 --exit --bail -R tap $(var);)
+ for var in tests/*.js; do ${MONGO_SETTINGS} ${MOCHA} --timeout 30000 --exit --bail -R tap $$var; done
docker_release:
# Get the version from the package.json file
diff --git a/README.md b/README.md
index 917be293303..bd55ebf8094 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Nightscout Web Monitor (a.k.a. cgm-remote-monitor)
[![Dependency Status][dependency-img]][dependency-url]
[![Coverage Status][coverage-img]][coverage-url]
[![Codacy Badge][codacy-img]][codacy-url]
-[![Gitter chat][gitter-img]][gitter-url]
+[![Discord chat][discord-img]][discord-url]
[![Deploy to Azure](http://azuredeploy.net/deploybutton.png)](https://azuredeploy.net/) [![Deploy to Heroku][heroku-img]][heroku-url] [![Update your site][update-img]][update-fork]
@@ -35,8 +35,8 @@ Community maintained fork of the
[coverage-url]: https://coveralls.io/github/nightscout/cgm-remote-monitor?branch=master
[codacy-img]: https://www.codacy.com/project/badge/f79327216860472dad9afda07de39d3b
[codacy-url]: https://www.codacy.com/app/Nightscout/cgm-remote-monitor
-[gitter-img]: https://img.shields.io/badge/Gitter-Join%20Chat%20%E2%86%92-1dce73.svg
-[gitter-url]: https://gitter.im/nightscout/public
+[discord-img]: https://img.shields.io/discord/629952586895851530?label=discord%20chat
+[discord-url]: https://discord.gg/rTKhrqz
[heroku-img]: https://www.herokucdn.com/deploy/button.png
[heroku-url]: https://heroku.com/deploy
[update-img]: update.png
@@ -49,23 +49,24 @@ Community maintained fork of the
- [Install](#install)
- [Supported configurations:](#supported-configurations)
- - [Minimum browser requirements for viewing the site:](#minimum-browser-requirements-for-viewing-the-site)
+ - [Recommended minimum browser versions for using Nightscout:](#recommended-minimum-browser-versions-for-using-nightscout)
- [Windows installation software requirements:](#windows-installation-software-requirements)
- [Installation notes for users with nginx or Apache reverse proxy for SSL/TLS offloading:](#installation-notes-for-users-with-nginx-or-apache-reverse-proxy-for-ssltls-offloading)
- [Installation notes for Microsoft Azure, Windows:](#installation-notes-for-microsoft-azure-windows)
+- [Development](#development)
- [Usage](#usage)
- [Updating my version?](#updating-my-version)
- - [What is my mongo string?](#what-is-my-mongo-string)
- [Configure my uploader to match](#configure-my-uploader-to-match)
- [Nightscout API](#nightscout-api)
- [Example Queries](#example-queries)
- [Environment](#environment)
- [Required](#required)
- - [Features/Labs](#featureslabs)
+ - [Features](#features)
- [Alarms](#alarms)
- [Core](#core)
- [Predefined values for your browser settings (optional)](#predefined-values-for-your-browser-settings-optional)
- [Predefined values for your server settings (optional)](#predefined-values-for-your-server-settings-optional)
+ - [Views](#views)
- [Plugins](#plugins)
- [Default Plugins](#default-plugins)
- [`delta` (BG Delta)](#delta-bg-delta)
@@ -97,8 +98,9 @@ Community maintained fork of the
- [`openaps` (OpenAPS)](#openaps-openaps)
- [`loop` (Loop)](#loop-loop)
- [`override` (Override Mode)](#override-override-mode)
- - [`xdrip-js` (xDrip-js)](#xdrip-js-xdrip-js)
+ - [`xdripjs` (xDrip-js)](#xdripjs-xdrip-js)
- [`alexa` (Amazon Alexa)](#alexa-amazon-alexa)
+ - [`googlehome` (Google Home/DialogFLow)](#googlehome-google-homedialogflow)
- [`speech` (Speech)](#speech-speech)
- [`cors` (CORS)](#cors-cors)
- [Extended Settings](#extended-settings)
@@ -108,6 +110,7 @@ Community maintained fork of the
- [Setting environment variables](#setting-environment-variables)
- [Vagrant install](#vagrant-install)
- [More questions?](#more-questions)
+ - [Browser testing suite provided by](#browser-testing-suite-provided-by)
- [License](#license)
@@ -119,7 +122,7 @@ Community maintained fork of the
If you plan to use Nightscout, we recommend using [Heroku](http://www.nightscout.info/wiki/welcome/set-up-nightscout-using-heroku), as Nightscout can reach the usage limits of the free Azure plan and cause it to shut down for hours or days. If you end up needing a paid tier, the $7/mo Heroku plan is also much cheaper than the first paid tier of Azure. Currently, the only added benefit to choosing the $7/mo Heroku plan vs the free Heroku plan is a section showing site use metrics for performance (such as response time). This has limited benefit to the average Nightscout user. In short, Heroku is the free and best option for Nightscout hosting.
- [Nightscout Setup with Heroku](http://www.nightscout.info/wiki/welcome/set-up-nightscout-using-heroku) (recommended)
-- [Nightscout Setup with Microsoft Azure](http://www.nightscout.info/wiki/faqs-2/azure-2) (not recommended, please
+- [Nightscout Setup with Microsoft Azure](http://www.nightscout.info/wiki/faqs-2/azure-2) (not recommended, please
[switch from Azure to Heroku](http://openaps.readthedocs.io/en/latest/docs/While%20You%20Wait%20For%20Gear/nightscout-setup.html#switching-from-azure-to-heroku) )
- Linux based install (Debian, Ubuntu, Raspbian) install with own Node.JS and MongoDB install (see software requirements below)
- Windows based install with own Node.JS and MongoDB install (see software requirements below)
@@ -129,13 +132,15 @@ If you plan to use Nightscout, we recommend using [Heroku](http://www.nightscout
Older versions of the browsers might work, but are untested.
- Android 4
-- Chrome 68
+- iOS 6
+- Chrome 35
- Edge 17
- Firefox 61
+- Opera 12.1
+- Safari 6 (macOS 10.7)
- Internet Explorer: not supported
-- iOS 11
-- Opera 54
-- Safari 10 (macOS 10.12)
+
+Some features may not work with devices/browsers on the older end of these requirements.
## Windows installation software requirements:
@@ -155,7 +160,7 @@ $ npm install
- HTTP Strict Transport Security (HSTS) headers are enabled by default, use settings `SECURE_HSTS_HEADER` and `SECURE_HSTS_HEADER_*`
- See [Predefined values for your server settings](#predefined-values-for-your-server-settings-optional) for more details
-## Installation notes for Microsoft Azure, Windows:
+## Installation notes for Microsoft Azure, Windows:
- If deploying the software to Microsoft Azure, you must set ** in the app settings for *WEBSITE_NODE_DEFAULT_VERSION* and *SCM_COMMAND_IDLE_TIMEOUT* **before** you deploy the latest Nightscout or the site deployment will likely fail. Other hosting environments do not require this setting. Additionally, if using the Azure free hosting tier, the installation might fail due to resource constraints imposed by Azure on the free hosting. Please set the following settings to the environment in Azure:
```
@@ -163,11 +168,11 @@ WEBSITE_NODE_DEFAULT_VERSION=10.15.2
SCM_COMMAND_IDLE_TIMEOUT=300
```
- See [install MongoDB, Node.js, and Nightscouton a single Windows system](https://github.com/jaylagorio/Nightscout-on-Windows-Server). if you want to host your Nightscout outside of the cloud. Although the instructions are intended for Windows Server the procedure is compatible with client versions of Windows such as Windows 7 and Windows 10.
-- If you deploy to Windows and want to develop or test you need to install [Cygwin](https://www.cygwin.com/) (use [setup-x86_64.exe](https://www.cygwin.com/setup-x86_64.exe) and make sure to install `build-essential` package. Test your configuration by executing `make` and check if all tests are ok.
+- If you deploy to Windows and want to develop or test you need to install [Cygwin](https://www.cygwin.com/) (use [setup-x86_64.exe](https://www.cygwin.com/setup-x86_64.exe) and make sure to install `build-essential` package. Test your configuration by executing `make` and check if all tests are ok.
# Development
-Wanna help with development, or just see how Nigthscout works? Great! See [CONTRIBUTING.md](CONTRIBUTING.md) for development related documentation.
+Want to help with development, or just see how Nightscout works? Great! See [CONTRIBUTING.md](CONTRIBUTING.md) for development-related documentation.
# Usage
@@ -179,15 +184,9 @@ MongoDB server such as [mLab][mLab].
[mongostring]: https://nightscout.github.io/pages/mongostring/
## Updating my version?
-The easiest way to update your version of cgm-remote-monitor to our latest
-recommended version is to use the [update my fork tool][update-fork]. It even
-gives out stars if you are up to date.
-
-## What is my mongo string?
-Try the [what is my mongo string tool][mongostring] to get a good idea of your
-mongo string. You can copy and paste the text in the gray box into your
-`MONGO_CONNECTION` environment variable.
+The easiest way to update your version of cgm-remote-monitor to the latest version is to use the [update tool][update-fork]. A step-by-step guide is available [here][http://www.nightscout.info/wiki/welcome/how-to-update-to-latest-cgm-remote-monitor-aka-cookie].
+To downgrade to an older version, follow [this guide][http://www.nightscout.info/wiki/welcome/how-to-deploy-an-older-version-of-nightscout].
## Configure my uploader to match
@@ -195,7 +194,7 @@ Use the [autoconfigure tool][autoconfigure] to sync an uploader to your config.
## Nightscout API
-The Nightscout API enables direct access to your DData without the need for direct Mongo access.
+The Nightscout API enables direct access to your data without the need for Mongo access.
You can find CGM data in `/api/v1/entries`, Care Portal Treatments in `/api/v1/treatments`, and Treatment Profiles in `/api/v1/profile`.
The server status and settings are available from `/api/v1/status.json`.
@@ -206,7 +205,7 @@ Once you've installed Nightscout, you can access API documentation by loading `/
#### Example Queries
-(replace `http://localhost:1337` with your base url, YOUR-SITE)
+(replace `http://localhost:1337` with your own URL)
* 100's: `http://localhost:1337/api/v1/entries.json?find[sgv]=100`
* Count of 100's in a month: `http://localhost:1337/api/v1/count/entries/where?find[dateString][$gte]=2016-09&find[dateString][$lte]=2016-10&find[sgv]=100`
@@ -223,15 +222,16 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
### Required
- * `MONGO_CONNECTION` - Your mongo uri, for example: `mongodb://sally:sallypass@ds099999.mongolab.com:99999/nightscout`
- * `DISPLAY_UNITS` (`mg/dl`) - Choices: `mg/dl` and `mmol`. Setting to `mmol` puts the entire server into `mmol` mode by default, no further settings needed.
- * `BASE_URL` - Used for building links to your sites api, ie pushover callbacks, usually the URL of your Nightscout site you may want https instead of http
+ * `MONGODB_URI` - The connection string for your Mongo database. Something like `mongodb://sally:sallypass@ds099999.mongolab.com:99999/nightscout`.
+ * `API_SECRET` - A secret passphrase that must be at least 12 characters long.
+ * `MONGODB_COLLECTION` (`entries`) - The Mongo collection where CGM entries are stored.
+ * `DISPLAY_UNITS` (`mg/dl`) - Options are `mg/dl` or `mmol/L` (or just `mmol`). Setting to `mmol/L` puts the entire server into `mmol/L` mode by default, no further settings needed.
-### Features/Labs
+### Features
* `ENABLE` - Used to enable optional features, expects a space delimited list, such as: `careportal rawbg iob`, see [plugins](#plugins) below
* `DISABLE` - Used to disable default features, expects a space delimited list, such as: `direction upbat`, see [plugins](#plugins) below
- * `API_SECRET` - A secret passphrase that must be at least 12 characters long, required to enable `POST` and `PUT`; also required for the Care Portal
+ * `BASE_URL` - Used for building links to your site's API, i.e. Pushover callbacks, usually the URL of your Nightscout site.
* `AUTH_DEFAULT_ROLES` (`readable`) - possible values `readable`, `denied`, or any valid role
name. When `readable`, anyone can view Nightscout without a token.
Setting it to `denied` will require a token from every visit, using `status-only` will enable api-secret based login.
@@ -240,13 +240,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
### Alarms
- These alarm setting effect all delivery methods (browser, pushover, maker, etc), some settings can be overridden per client (web browser)
+ These alarm setting affect all delivery methods (browser, Pushover, IFTTT, etc.). Values and settings entered here will be the defaults for new browser views, but will be overridden if different choices are made in the settings UI.
* `ALARM_TYPES` (`simple` if any `BG_`* ENV's are set, otherwise `predict`) - currently 2 alarm types are supported, and can be used independently or combined. The `simple` alarm type only compares the current BG to `BG_` thresholds above, the `predict` alarm type uses highly tuned formula that forecasts where the BG is going based on it's trend. `predict` **DOES NOT** currently use any of the `BG_`* ENV's
- * `BG_HIGH` (`260`) - must be set using mg/dl units; the high BG outside the target range that is considered urgent
- * `BG_TARGET_TOP` (`180`) - must be set using mg/dl units; the top of the target range, also used to draw the line on the chart
- * `BG_TARGET_BOTTOM` (`80`) - must be set using mg/dl units; the bottom of the target range, also used to draw the line on the chart
- * `BG_LOW` (`55`) - must be set using mg/dl units; the low BG outside the target range that is considered urgent
+ * `BG_HIGH` (`260`) - the high BG outside the target range that is considered urgent (interprets units based on DISPLAY_UNITS setting)
+ * `BG_TARGET_TOP` (`180`) - the top of the target range, also used to draw the line on the chart (interprets units based on DISPLAY_UNITS setting)
+ * `BG_TARGET_BOTTOM` (`80`) - the bottom of the target range, also used to draw the line on the chart (interprets units based on DISPLAY_UNITS setting)
+ * `BG_LOW` (`55`) - the low BG outside the target range that is considered urgent (interprets units based on DISPLAY_UNITS setting)
* `ALARM_URGENT_HIGH` (`on`) - possible values `on` or `off`
* `ALARM_URGENT_HIGH_MINS` (`30 60 90 120`) - Number of minutes to snooze urgent high alarms, space separated for options in browser, first used for pushover
* `ALARM_HIGH` (`on`) - possible values `on` or `off`
@@ -258,10 +258,8 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `ALARM_URGENT_MINS` (`30 60 90 120`) - Number of minutes to snooze urgent alarms (that aren't tagged as high or low), space separated for options in browser, first used for pushover
* `ALARM_WARN_MINS` (`30 60 90 120`) - Number of minutes to snooze warning alarms (that aren't tagged as high or low), space separated for options in browser, first used for pushover
-
### Core
- * `MONGO_COLLECTION` (`entries`) - The collection used to store SGV, MBG, and CAL records from your CGM device
* `MONGO_TREATMENTS_COLLECTION` (`treatments`) -The collection used to store treatments entered in the Care Portal, see the `ENABLE` env var above
* `MONGO_DEVICESTATUS_COLLECTION`(`devicestatus`) - The collection used to store device status information such as uploader battery
* `MONGO_PROFILE_COLLECTION`(`profile`) - The collection used to store your profiles
@@ -276,13 +274,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `DEBUG_MINIFY` (`true`) - Debug option, setting to `false` will disable bundle minification to help tracking down error and speed up development
* `DE_NORMALIZE_DATES`(`true`) - The Nightscout REST API normalizes all entered dates to UTC zone. Some Nightscout clients have broken date deserialization logic and expect to received back dates in zoned formats. Setting this variable to `true` causes the REST API to serialize dates sent to Nightscout in zoned format back to zoned format when served to clients over REST.
-
### Predefined values for your browser settings (optional)
+
* `TIME_FORMAT` (`12`)- possible values `12` or `24`
* `NIGHT_MODE` (`off`) - possible values `on` or `off`
* `SHOW_RAWBG` (`never`) - possible values `always`, `never` or `noise`
- * `CUSTOM_TITLE` (`Nightscout`) - Usually name of T1
- * `THEME` (`default`) - possible values `default`, `colors`, or `colorblindfriendly`
+ * `CUSTOM_TITLE` (`Nightscout`) - Title for the main view
+ * `THEME` (`colors`) - possible values `default`, `colors`, or `colorblindfriendly`
* `ALARM_TIMEAGO_WARN` (`on`) - possible values `on` or `off`
* `ALARM_TIMEAGO_WARN_MINS` (`15`) - minutes since the last reading to trigger a warning
* `ALARM_TIMEAGO_URGENT` (`on`) - possible values `on` or `off`
@@ -290,12 +288,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `SHOW_PLUGINS` - enabled plugins that should have their visualizations shown, defaults to all enabled
* `SHOW_FORECAST` (`ar2`) - plugin forecasts that should be shown by default, supports space delimited values such as `"ar2 openaps"`
* `LANGUAGE` (`en`) - language of Nightscout. If not available english is used
- * Currently supported language codes are: bg (Български), cs (Čeština), de (Deutsch), dk (Dansk), el (Ελληνικά), en (English), es (Español), fi (Suomi), fr (Français), he (עברית), hr (Hrvatski), it (Italiano), ko (한국어), nb (Norsk (Bokmål)), nl (Nederlands), pl (Polski), pt (Português (Brasil)), ro (Română), ru (Русский), sk (Slovenčina), sv (Svenska), zh_cn (中文(简体)), zh_tw (中文(繁體))
+ * Currently supported language codes are: bg (Български), cs (Čeština), de (Deutsch), dk (Dansk), el (Ελληνικά), en (English), es (Español), fi (Suomi), fr (Français), he (עברית), hr (Hrvatski), it (Italiano), ko (한국어), nb (Norsk (Bokmål)), nl (Nederlands), pl (Polski), pt (Português (Brasil)), ro (Română), ru (Русский), sk (Slovenčina), sv (Svenska), tr (Turkish), zh_cn (中文(简体)), zh_tw (中文(繁體))
* `SCALE_Y` (`log`) - The type of scaling used for the Y axis of the charts system wide.
* The default `log` (logarithmic) option will let you see more detail towards the lower range, while still showing the full CGM range.
- * The `linear` option has equidistant tick marks, the range used is dynamic so that space at the top of chart isn't wasted.
+ * The `linear` option has equidistant tick marks; the range used is dynamic so that space at the top of chart isn't wasted.
* The `log-dynamic` is similar to the default `log` options, but uses the same dynamic range and the `linear` scale.
- * `EDIT_MODE` (`on`) - possible values `on` or `off`. Enable or disable icon allowing enter treatments edit mode
+ * `EDIT_MODE` (`on`) - possible values `on` or `off`. Enables the icon allowing for editing of treatments in the main view.
+ * `BOLUS_RENDER_OVER` (1) - U value over which the bolus values are rendered on the chart if the 'x U and Over' option is selected. This value can be an integer or a float, e.g. 0.3, 1.5, 2, etc...
### Predefined values for your server settings (optional)
* `INSECURE_USE_HTTP` (`false`) - Redirect unsafe http traffic to https. Possible values `false`, or `true`. Your site redirects to `https` by default. If you don't want that from Nightscout, but want to implement that with a Nginx or Apache proxy, set `INSECURE_USE_HTTP` to `true`. Note: This will allow (unsafe) http traffic to your Nightscout instance and is not recommended.
@@ -304,14 +303,19 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `SECURE_HSTS_HEADER_PRELOAD` (`false`) - ask for preload in browsers for HSTS. Possible values `false`, or `true`.
* `SECURE_CSP` (`false`) - Add Content Security Policy headers. Possible values `false`, or `true`.
* `SECURE_CSP_REPORT_ONLY` (`false`) - If set to `true` allows to experiment with policies by monitoring (but not enforcing) their effects. Possible values `false`, or `true`.
-
+
### Views
There are a few alternate web views available from the main menu that display a simplified BG stream. (If you launch one of these in a fullscreen view in iOS, you can use a left-to-right swipe gesture to exit the view.)
* `Clock` - Shows current BG, trend arrow, and time of day. Grey text on a black background.
- * `Color` - Shows current BG and trend arrow. White text on a background that changes color to indicate current BG threshold (green = in range; blue = below range; yellow = above range; red = urgent below/above).
+ * `Color` - Shows current BG and trend arrow. White text on a background that changes color to indicate current BG threshold (green = in range; blue = below range; yellow = above range; red = urgent below/above). Set `SHOW_CLOCK_DELTA` to `true` to show BG change in the last 5 minutes, set `SHOW_CLOCK_LAST_TIME` to `true` to always show BG age.
* `Simple` - Shows current BG. Grey text on a black background.
- * Optional configuration: set `SHOW_CLOCK_CLOSEBUTTON` to `false` to never show the small X button in clock views. For bookmarking a clock view without the close box but have it appear when navigating to a clock from the Nightscout menu, don't change the settng, but remove the `showClockClosebutton=true` parameter from the clock view URL.
+
+### Split View
+
+ Some users will need easy access to multiple Nightscout views at the same time. We have a special view for this case, accessed on /split path on your Nightscout URL. The view supports any number of sites between 1 to 8 way split, where the content for the screen can be loaded from multiple Nightscout instances. Note you still need to host separate instances for each Nightscout being monitored including the one that hosts the split view page - these variables only add the ability to load multiple views into one browser page. To set the URLs from which the content is loaded, set:
+ * `FRAME_URL_1` - URL where content is loaded, for the first view (increment the number up to 8 to get more views)
+ * `FRAME_NAME_1` - Name for the first split view portion of the screen (increment the number to name more views)
### Plugins
@@ -321,7 +325,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
#### Default Plugins
- These can be disabled by setting the `DISABLE` env var, for example `DISABLE="direction upbat"`
+ These can be disabled by adding them to the `DISABLE` variable, for example `DISABLE="direction upbat"`
##### `delta` (BG Delta)
Calculates and displays the change between the last 2 BG values.
@@ -343,7 +347,6 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `ALARM_TIMEAGO_URGENT` (`on`) - possible values `on` or `off`
* `ALARM_TIMEAGO_URGENT_MINS` (`30`) - minutes since the last reading to trigger a urgent alarm
-
##### `devicestatus` (Device Status)
Used by `upbat` and other plugins to display device status info. Supports the `DEVICESTATUS_ADVANCED="true"` [extended setting](#extended-settings) to send all device statuses to the client for retrospective use and to support other plugins.
@@ -437,14 +440,15 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `BASAL_RENDER` (`none`) - Possible values are `none`, `default`, or `icicle` (inverted)
##### `bridge` (Share2Nightscout bridge)
- Glucose reading directly from the Share service, uses these extended settings:
- * `BRIDGE_USER_NAME` - Your user name for the Share service.
+ Glucose reading directly from the Dexcom Share service, uses these extended settings:
+ * `BRIDGE_USER_NAME` - Your username for the Share service.
* `BRIDGE_PASSWORD` - Your password for the Share service.
- * `BRIDGE_INTERVAL` (`150000` *2.5 minutes*) - The time to wait between each update.
- * `BRIDGE_MAX_COUNT` (`1`) - The maximum number of records to fetch per update.
+ * `BRIDGE_INTERVAL` (`150000` *2.5 minutes*) - The time (in milliseconds) to wait between each update.
+ * `BRIDGE_MAX_COUNT` (`1`) - The number of records to attempt to fetch per update.
* `BRIDGE_FIRST_FETCH_COUNT` (`3`) - Changes max count during the very first update only.
* `BRIDGE_MAX_FAILURES` (`3`) - How many failures before giving up.
- * `BRIDGE_MINUTES` (`1400`) - The time window to search for new data per update (default is one day in minutes).
+ * `BRIDGE_MINUTES` (`1400`) - The time window to search for new data per update (the default value is one day in minutes).
+ * `BRIDGE_SERVER` (``) - The default blank value is used to fetch data from Dexcom servers in the US. Set to (`EU`) to fetch from European servers instead.
##### `mmconnect` (MiniMed Connect bridge)
Transfer real-time MiniMed Connect data from the Medtronic CareLink server into Nightscout ([read more](https://github.com/mddub/minimed-connect-to-nightscout))
@@ -481,14 +485,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `OPENAPS_URGENT` (`60`) - The number of minutes since the last loop that needs to be exceed before an urgent alarm is triggered
* `OPENAPS_FIELDS` (`status-symbol status-label iob meal-assist rssi`) - The fields to display by default. Any of the following fields: `status-symbol`, `status-label`, `iob`, `meal-assist`, `freq`, and `rssi`
* `OPENAPS_RETRO_FIELDS` (`status-symbol status-label iob meal-assist rssi`) - The fields to display in retro mode. Any of the above fields.
- * `OPENAPS_PRED_IOB_COLOR` (`#1e88e5`) - The color to use for IOB prediction lines. Colors can be in either `#RRGGBB` or `#RRGGBBAA` format.
- * `OPENAPS_PRED_COB_COLOR` (`#FB8C00FF`) - The color to use for COB prediction lines. Same format as above.
- * `OPENAPS_PRED_ACOB_COLOR` (`#FB8C0080`) - The color to use for ACOB prediction lines. Same format as above.
+ * `OPENAPS_PRED_IOB_COLOR` (`#1e88e5`) - The color to use for IOB prediction lines. Colors can be in `#RRGGBB` format, but [other CSS color units](https://www.w3.org/TR/css-color-3/#colorunits) may be used as well.
+ * `OPENAPS_PRED_COB_COLOR` (`#FB8C00`) - The color to use for COB prediction lines. Same format as above.
+ * `OPENAPS_PRED_ACOB_COLOR` (`#FB8C00`) - The color to use for ACOB prediction lines. Same format as above.
* `OPENAPS_PRED_ZT_COLOR` (`#00d2d2`) - The color to use for ZT prediction lines. Same format as above.
* `OPENAPS_PRED_UAM_COLOR` (`#c9bd60`) - The color to use for UAM prediction lines. Same format as above.
* `OPENAPS_COLOR_PREDICTION_LINES` (`true`) - Enables / disables the colored lines vs the classic purple color.
-
Also see [Pushover](#pushover) and [IFTTT Maker](#ifttt-maker).
##### `loop` (Loop)
@@ -499,20 +502,29 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `LOOP_URGENT` (`60`) - The number of minutes since the last loop that needs to be exceeded before an urgent alarm is triggered
* Add `loop` to `SHOW_FORECAST` to show forecasted BG.
+For remote overrides, the following extended settings must be configured:
+ * `LOOP_APNS_KEY` - Apple Push Notifications service (APNs) Key, created in the Apple Developer website.
+ * `LOOP_APNS_KEY_ID` - The Key ID for the above key.
+ * `LOOP_DEVELOPER_TEAM_ID` - Your Apple developer team ID.
+ * `LOOP_PUSH_SERVER_ENVIRONMENT` - (optional) Set this to `production` if you are using a provisioning profile that specifies production aps-environment, such as when distributing builds via TestFlight.
+
##### `override` (Override Mode)
Additional monitoring for DIY automated insulin delivery systems to display real-time overrides such as Eating Soon or Exercise Mode:
* Requires `DEVICESTATUS_ADVANCED="true"` to be set
-##### `xdrip-js` (xDrip-js)
+##### `xdripjs` (xDrip-js)
Integrated xDrip-js monitoring, uses these extended settings:
* Requires `DEVICESTATUS_ADVANCED="true"` to be set
- * `XDRIP-JS_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications when CGM state is not OK or battery voltages fall below threshold.
- * `XDRIP-JS_STATE_NOTIFY_INTRVL` (`0.5`) - Set to number of hours between CGM state notifications
- * `XDRIP-JS_WARN_BAT_V` (`300`) - The voltage of either transmitter battery, a warning will be triggered when dropping below this threshold.
+ * `XDRIPJS_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications when CGM state is not OK or battery voltages fall below threshold.
+ * `XDRIPJS_STATE_NOTIFY_INTRVL` (`0.5`) - Set to number of hours between CGM state notifications
+ * `XDRIPJS_WARN_BAT_V` (`300`) - The voltage of either transmitter battery, a warning will be triggered when dropping below this threshold.
##### `alexa` (Amazon Alexa)
Integration with Amazon Alexa, [detailed setup instructions](docs/plugins/alexa-plugin.md)
+##### `googlehome` (Google Home/DialogFLow)
+ Integration with Google Home (via DialogFlow), [detailed setup instructions](docs/plugins/googlehome-plugin.md)
+
##### `speech` (Speech)
Speech synthesis plugin. When enabled, speaks out the blood glucose values, IOB and alarms. Note you have to set the LANGUAGE setting on the server to get all translated alarms.
@@ -520,6 +532,25 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
Enabled [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) so other websites can make request to your Nightscout site, uses these extended settings:
* `CORS_ALLOW_ORIGIN` (`*`) - The list of sites that are allow to make requests
+##### `dbsize` (Database Size)
+ Show size of Nightscout Database, as a percentage of declared available space or in MiB.
+
+ Many deployments of Nightscout use free tier of MongoDB on Heroku, which is limited in size. After some time, as volume of stored data grows, it may happen that this limit is reached and system is unable to store new data. This plugin provides pill that indicates size of Database and shows (when configured) alarms regarding reaching space limit.
+
+ **IMPORTANT:** This plugin can only check how much space database already takes, _but cannot infer_ max size available on server for it. To have correct alarms and realistic percentage, `DBSIZE_MAX` need to be properly set - according to your mongoDB hosting configuration.
+
+ **NOTE:** It may happen that new data cannot be saved to database (due to size limits) even when this plugin reports that storage is not 100% full. MongoDB pre-allocate data in advance, and database file is always made bigger than total real data size. To avoid premature alarms, this plugin refers to data size instead of file size, but sets warning thresholds low. It may happen, that file size of database will take 100% of allowed space but there will be plenty of place inside to store the data. But it may also happen, with file size is at its max, that data size will be ~70% of file size, and there will be no place left. That may happen because data inside file is fragmented and free space _holes_ are too small for new entries. In such case calling `db.repairDatabase()` on mongoDB is recommended to compact and repack data (currently only doable with third-party mongoDB tools, but action is planned to be added in _Admin Tools_ section in future releases).
+
+ All sizes are expressed as integers, in _Mebibytes_ `1 MiB == 1024 KiB == 1024*1024 B`
+
+ * `DBSIZE_MAX` (`496`) - Maximal allowed size of database on your mongoDB server, in MiB. You need to adjust that value to match your database hosting limits - default value is for standard Heroku mongoDB free tier.
+ * `DBSIZE_WARN_PERCENTAGE` (`60`) - Threshold to show first warning about database size. When database reach this percentage of `DBSIZE_MAX` size - pill will show size in yellow.
+ * `DBSIZE_URGENT_PERCENTAGE` (`75`) - Threshold to show urgent warning about database size. When database reach this percentage of `DBSIZE_MAX` size, it is urgent to do backup and clean up old data. At this percentage info pill turns red.
+ * `DBSIZE_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications about database size.
+ * `DBSIZE_IN_MIB` (`false`) - Set to `true` to display size of database in MiB-s instead of default percentage.
+
+ This plugin should be enabled by default, if needed can be diasabled by adding `dbsize` to the list of disabled plugins, for example: `DISABLE="dbsize"`.
+
#### Extended Settings
Some plugins support additional configuration using extra environment variables. These are prefixed with the name of the plugin and a `_`. For example setting `MYPLUGIN_EXAMPLE_VALUE=1234` would make `extendedSettings.exampleValue` available to the `MYPLUGIN` plugin.
@@ -545,13 +576,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `PUSHOVER_ANNOUNCEMENT_KEY` - An optional Pushover user/group key, will be used for system wide user generated announcements. If not defined this will fallback to `PUSHOVER_USER_KEY` or `PUSHOVER_ALARM_KEY`. This also support a space delimited list of keys. To disable Announcement pushes set this to `off`.
* `BASE_URL` - Used for pushover callbacks, usually the URL of your Nightscout site, use https when possible.
* `API_SECRET` - Used for signing the pushover callback request for acknowledgments.
-
+
If you never want to get info level notifications (treatments) use `PUSHOVER_USER_KEY="off"`
If you never want to get an alarm via pushover use `PUSHOVER_ALARM_KEY="off"`
If you never want to get an announcement via pushover use `PUSHOVER_ANNOUNCEMENT_KEY="off"`
-
+
If only `PUSHOVER_USER_KEY` is set it will be used for all info notifications, alarms, and announcements
-
+
For testing/development try [localtunnel](http://localtunnel.me/).
#### IFTTT Maker
@@ -581,7 +612,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
Treatment Profile Fields:
* `timezone` (Time Zone) - time zone local to the patient. *Should be set.*
- * `units` (Profile Units) - blood glucose units used in the profile, either "mgdl" or "mmol"
+ * `units` (Profile Units) - blood glucose units used in the profile, either "mg/dl" or "mmol"
* `dia` (Insulin duration) - value should be the duration of insulin action to use in calculating how much insulin is left active. Defaults to 3 hours.
* `carbs_hr` (Carbs per Hour) - The number of carbs that are processed per hour, for more information see [#DIYPS](http://diyps.org/2014/05/29/determining-your-carbohydrate-absorption-rate-diyps-lessons-learned/).
* `carbratio` (Carb Ratio) - grams per unit of insulin.
@@ -632,6 +663,12 @@ Feel free to [post an issue][issues], but read the [wiki][wiki] first.
[issues]: https://github.com/nightscout/cgm-remote-monitor/issues
[wiki]: https://github.com/nightscout/cgm-remote-monitor/wiki
+### Browser testing suite provided by
+[![BrowserStack][browserstack-img]][browserstack-url]
+
+[browserstack-img]: /static/images/browserstack-logo.png
+[browserstack-url]: https://www.browserstack.com/
+
License
---------------
@@ -641,16 +678,16 @@ License
Copyright (C) 2017 Nightscout contributors. See the COPYRIGHT file
at the root directory of this distribution and at
https://github.com/nightscout/cgm-remote-monitor/blob/master/COPYRIGHT
-
+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
-
+
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
-
+
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
diff --git a/app.js b/app.js
index 5b6f49b9708..b3fa861f362 100644
--- a/app.js
+++ b/app.js
@@ -7,6 +7,7 @@ const bodyParser = require('body-parser');
const path = require('path');
const fs = require('fs');
+const ejs = require('ejs');
function create (env, ctx) {
var app = express();
@@ -92,6 +93,15 @@ function create (env, ctx) {
}
app.locals.cachebuster = cacheBuster;
+ app.get("/sw.js", (req, res) => {
+ res.setHeader('Content-Type', 'application/javascript');
+ res.send(ejs.render(fs.readFileSync(
+ require.resolve(`${__dirname}/views/service-worker.js`),
+ { encoding: 'utf-8' }),
+ { locals: app.locals}
+ ));
+ });
+
if (ctx.bootErrors && ctx.bootErrors.length > 0) {
app.get('*', require('./lib/server/booterror')(ctx));
return app;
@@ -117,8 +127,11 @@ function create (env, ctx) {
///////////////////////////////////////////////////
// api and json object variables
///////////////////////////////////////////////////
+ const apiRoot = require('./lib/api/root')(env, ctx);
var api = require('./lib/api/')(env, ctx);
+ var api3 = require('./lib/api3/')(env, ctx);
var ddata = require('./lib/data/endpoints')(env, ctx);
+ var notificationsV2 = require('./lib/api/notifications-v2')(app, ctx);
app.use(compression({
filter: function shouldCompress (req, res) {
@@ -128,49 +141,77 @@ function create (env, ctx) {
}
}));
- const clockviews = require('./lib/server/clocks.js')(env, ctx);
- clockviews.setLocals(app.locals);
-
- app.use("/clock", clockviews);
-
- app.get("/", (req, res) => {
- res.render("index.html", {
- locals: app.locals
- });
- });
-
var appPages = {
- "/clock-color.html": "clock-color.html"
- , "/admin": "adminindex.html"
- , "/profile": "profileindex.html"
- , "/food": "foodindex.html"
- , "/bgclock.html": "bgclock.html"
- , "/report": "reportindex.html"
- , "/translations": "translationsindex.html"
- , "/clock.html": "clock.html"
+ "/": {
+ file: "index.html"
+ , type: "index"
+ }
+ , "/admin": {
+ file: "adminindex.html"
+ , title: 'Admin Tools'
+ , type: 'admin'
+ }
+ , "/food": {
+ file: "foodindex.html"
+ , title: 'Food Editor'
+ , type: 'food'
+ }
+ , "/profile": {
+ file: "profileindex.html"
+ , title: 'Profile Editor'
+ , type: 'profile'
+ }
+ , "/report": {
+ file: "reportindex.html"
+ , title: 'Nightscout reporting'
+ , type: 'report'
+ }
+ , "/translations": {
+ file: "translationsindex.html"
+ , title: 'Nightscout translations'
+ , type: 'translations'
+ }
+ , "/split": {
+ file: "frame.html"
+ , title: '8-user view'
+ , type: 'index'
+ }
};
Object.keys(appPages).forEach(function(page) {
app.get(page, (req, res) => {
- res.render(appPages[page], {
- locals: app.locals
+ res.render(appPages[page].file, {
+ locals: app.locals,
+ title: appPages[page].title ? appPages[page].title : '',
+ type: appPages[page].type ? appPages[page].type : '',
+ settings: env.settings
});
});
});
- app.get("/appcache/*", (req, res) => {
- res.render("nightscout.appcache", {
- locals: app.locals
- });
- });
+ const clockviews = require('./lib/server/clocks.js')(env, ctx);
+ clockviews.setLocals(app.locals);
+
+ app.use("/clock", clockviews);
+
+ app.use('/api', bodyParser({
+ limit: 1048576 * 50
+ }), apiRoot);
app.use('/api/v1', bodyParser({
limit: 1048576 * 50
}), api);
+ app.use('/api/v2', bodyParser({
+ limit: 1048576 * 50
+ }), api);
+
app.use('/api/v2/properties', ctx.properties);
app.use('/api/v2/authorization', ctx.authorization.endpoints);
app.use('/api/v2/ddata', ddata);
+ app.use('/api/v2/notifications', notificationsV2);
+
+ app.use('/api/v3', api3);
// pebble data
app.get('/pebble', ctx.pebble);
@@ -224,7 +265,7 @@ function create (env, ctx) {
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
- app.use('/swagger-ui-dist', (req, res, next) => {
+ app.use('/swagger-ui-dist', (req, res) => {
res.redirect(307, '/api-docs');
});
@@ -233,10 +274,13 @@ function create (env, ctx) {
app.locals.bundle = '/bundle';
+ app.locals.mode = 'production';
+
if (process.env.NODE_ENV === 'development') {
console.log('Development mode');
+ app.locals.mode = 'development';
app.locals.bundle = '/devbundle';
const webpack = require('webpack');
diff --git a/app.json b/app.json
index 8fea5103f88..46c2fe0ae46 100644
--- a/app.json
+++ b/app.json
@@ -53,25 +53,30 @@
"required": true
},
"BG_HIGH": {
- "description": "Urgent High BG threshold, triggers the ALARM_URGENT_HIGH alarm. Must be set in mg/dL, even if you use mmol/L (multiply a mmol/L value by 18 to change it to mg/dl).",
+ "description": "Urgent High BG threshold, triggers the ALARM_URGENT_HIGH alarm. Set in mg/dL or mmol/L, as set in DISPLAY_UNITS variable.",
"value": "260",
"required": false
},
"BG_LOW": {
- "description": "Urgent Low BG threshold, triggers the ALARM_URGENT_LOW alarm. Must be set in mg/dL, even if you use mmol/L (multiply a mmol/L value by 18 to change it to mg/dl).",
+ "description": "Urgent Low BG threshold, triggers the ALARM_URGENT_LOW alarm. Set in mg/dL or mmol/L, as set in DISPLAY_UNITS variable.",
"value": "55",
"required": false
},
"BG_TARGET_BOTTOM": {
- "description": "Low BG threshold, triggers the ALARM_LOW alarm. Must be set in mg/dL, even if you use mmol/L (multiply a mmol/L value by 18 to change it to mg/dl).",
+ "description": "Low BG threshold, triggers the ALARM_LOW alarm. Set in mg/dL or mmol/L, as set in DISPLAY_UNITS variable.",
"value": "80",
"required": false
},
"BG_TARGET_TOP": {
- "description": "High BG threshold, triggers the ALARM_HIGH alarm. Must be set in mg/dL, even if you use mmol/L (multiply a mmol/L value by 18 to change it to mg/dl).",
+ "description": "High BG threshold, triggers the ALARM_HIGH alarm. Set in mg/dL or mmol/L, as set in DISPLAY_UNITS variable.",
"value": "180",
"required": false
},
+ "BOLUS_RENDER_OVER": {
+ "description": "U value over which the bolus values are rendered on the chart if the 'x U and Over' option is selected.",
+ "value": "1",
+ "required": false
+ },
"BRIDGE_PASSWORD": {
"description": "Your Dexcom account password, to receive CGM data from the Dexcom Share service. Also make sure to include 'bridge' in your ENABLE line.",
"value": "",
@@ -93,13 +98,13 @@
"required": false
},
"DISPLAY_UNITS": {
- "description": "Preferred BG units for the site:'mg/dl' or 'mmol'. (Note that it is *not* 'mmol/L')",
+ "description": "Preferred BG units for the site: 'mg/dl' or 'mmol/L' (or just 'mmol').",
"value": "mg/dl",
"required": true
},
"ENABLE": {
"description": "Plugins to enable for your site. Must be a space-delimited, lower-case list. Include the word 'bridge' here if you are receiving data from the Dexcom Share service. Include 'mmconnect' if you are bridging from the MiniMed CareLink service.",
- "value": "careportal basal",
+ "value": "careportal basal dbsize",
"required": false
},
"MMCONNECT_USER_NAME": {
@@ -129,7 +134,7 @@
},
"SHOW_PLUGINS": {
"description": "Default setting for whether or not these plugins are checked (active) by default, not merely enabled. Include plugins here as in the ENABLE line; space-separated and lower-case.",
- "value": "careportal",
+ "value": "careportal dbsize",
"required": false
},
"SHOW_RAWBG": {
diff --git a/assets/fonts/Nightscout Plugin Icons.json b/assets/fonts/Nightscout Plugin Icons.json
new file mode 100644
index 00000000000..65874c15679
--- /dev/null
+++ b/assets/fonts/Nightscout Plugin Icons.json
@@ -0,0 +1,87 @@
+{
+ "metadata": {
+ "name": "Nightscout Plugin Icons",
+ "lastOpened": 0,
+ "created": 1580075608590
+ },
+ "iconSets": [
+ {
+ "selection": [
+ {
+ "order": 2,
+ "id": 0,
+ "name": "database",
+ "prevSize": 32,
+ "code": 59649,
+ "tempChar": ""
+ }
+ ],
+ "id": 2,
+ "metadata": {
+ "name": "Plugin Icons",
+ "importSize": {
+ "width": 16,
+ "height": 18
+ }
+ },
+ "height": 1024,
+ "prevSize": 32,
+ "icons": [
+ {
+ "id": 0,
+ "paths": [
+ "M455.111 0c-251.449 0-455.111 101.831-455.111 227.556s203.662 227.556 455.111 227.556 455.111-101.831 455.111-227.556-203.662-227.556-455.111-227.556zM0 341.333v170.667c0 125.724 203.662 227.556 455.111 227.556s455.111-101.831 455.111-227.556v-170.667c0 125.724-203.662 227.556-455.111 227.556s-455.111-101.831-455.111-227.556zM0 625.778v170.667c0 125.724 203.662 227.556 455.111 227.556s455.111-101.831 455.111-227.556v-170.667c0 125.724-203.662 227.556-455.111 227.556s-455.111-101.831-455.111-227.556z"
+ ],
+ "attrs": [
+ {}
+ ],
+ "width": 910,
+ "isMulticolor": false,
+ "isMulticolor2": false,
+ "grid": 0,
+ "tags": [
+ "plugins"
+ ]
+ }
+ ],
+ "invisible": false,
+ "colorThemes": []
+ }
+ ],
+ "preferences": {
+ "showGlyphs": true,
+ "showQuickUse": true,
+ "showQuickUse2": true,
+ "showSVGs": true,
+ "fontPref": {
+ "prefix": "plugicon-",
+ "metadata": {
+ "fontFamily": "pluginicons",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ "metrics": {
+ "emSize": 1024,
+ "baseline": 6.25,
+ "whitespace": 50
+ },
+ "embed": false,
+ "showSelector": false,
+ "showMetrics": false,
+ "showMetadata": false,
+ "showVersion": false
+ },
+ "imagePref": {
+ "prefix": "icon-",
+ "png": true,
+ "useClassSelector": true,
+ "color": 0,
+ "bgColor": 16777215,
+ "classSelector": ".icon"
+ },
+ "historySize": 50,
+ "showCodes": true,
+ "gridSize": 16
+ },
+ "uid": -1
+}
\ No newline at end of file
diff --git a/assets/fonts/README.md b/assets/fonts/README.md
new file mode 100644
index 00000000000..b6b98ea4322
--- /dev/null
+++ b/assets/fonts/README.md
@@ -0,0 +1,29 @@
+How to upgrade icons in icon-fonts on Nightscout
+================================================
+
+This guide is fol developers regarding how to add new icon to Nightscout.
+
+Nightscout use icon fonts to render icons. Each icon is glyph (like - letter, or more like emoji character) inside custom made font file.
+That way we have nice, vector icons, that are small, scalable, looks good on each platform, and are easy to embed inside CSS.
+
+To extend existing icon set.:
+
+1. Prepare minimalist, black & white icon in SVG tool of choice, and optimize it (you can use Inkscape) to be small in size and render good at small sizes.
+2. Use https://icomoon.io/app and import accompanied JSON project file (`Nightscout Plugin Icons.json`)
+3. Add SVG as new glyph. Remember to take care to set proper character code and CSS name
+4. Save new version of JSON project file and store in this folder
+5. Generate font, download zip file and unpack it to get `fonts/pluginicons.svg` and `fonts/pluginicons.woff`
+6. Update `statc/css/main.css` file
+ * In section of `@font-face` with `font-family: 'pluginicons'`
+ * update part after `data:application/font-woff;charset=utf-8;base64,` with Base64-encoded content of just generated `pluginicons.woff` font
+ * update part after `data:application/font-svg;charset=utf-8;base64,` with Base64-encoded content of just generated `pluginicons.svg` font
+ * copy/update all entries `.plugicon-****:before { content: "****"; }` from generated font `style.css` into `statc/css/main.css`
+7. Do not forget to update `Nightscout Plugin Icons.json` in this repo (´download updated project from icomoon.io)
+
+Hints
+-----
+
+* You can find many useful online tools to encode file into Base64, like: https://base64.guru/converter/encode/file
+* Do not split Base64 output - it should be one LONG line
+* Since update process is **manual** and generated fonts & updated CSS sections are **binary** - try to avoid **git merge conflicts** by speaking with other developers if you plan to add new icon
+* When in doubt - check `git log` and reach last contributor for guidelines :)
diff --git a/azuredeploy.json b/azuredeploy.json
index dc89c0ad956..bff5e3c41f7 100644
--- a/azuredeploy.json
+++ b/azuredeploy.json
@@ -173,7 +173,7 @@
},
"enable": {
"type": "string",
- "defaultValue": "basal bwp cage careportal iob cob rawbg sage iage treatmentnotify boluscalc profile food"
+ "defaultValue": "basal bwp cage careportal iob cob rawbg sage iage treatmentnotify boluscalc profile food dbsize"
},
"night_mode": {
"type": "string",
@@ -185,7 +185,7 @@
},
"show_plugins": {
"type": "string",
- "defaultValue": "careportal"
+ "defaultValue": "careportal dbsize"
},
"show_rawbg": {
"type": "string",
diff --git a/bundle/bundle.reports.source.js b/bundle/bundle.reports.source.js
index c07368543b4..27d67e9fb82 100644
--- a/bundle/bundle.reports.source.js
+++ b/bundle/bundle.reports.source.js
@@ -1,10 +1,11 @@
import './bundle.source';
window.Nightscout.report_plugins = require('../lib/report_plugins/')();
+window.Nightscout.predictions = require('../lib/report/predictions');
console.info('Nightscout report bundle ready');
// Needed for Hot Module Replacement
if(typeof(module.hot) !== 'undefined') {
- module.hot.accept() // eslint-disable-line no-undef
+ module.hot.accept()
}
diff --git a/bundle/bundle.source.js b/bundle/bundle.source.js
index d554744e6e4..db61af947bf 100644
--- a/bundle/bundle.source.js
+++ b/bundle/bundle.source.js
@@ -32,5 +32,5 @@ console.info('Nightscout bundle ready');
// Needed for Hot Module Replacement
if(typeof(module.hot) !== 'undefined') {
- module.hot.accept() // eslint-disable-line no-undef
+ module.hot.accept()
}
diff --git a/ci.test.env b/ci.test.env
new file mode 100644
index 00000000000..f5e240f8381
--- /dev/null
+++ b/ci.test.env
@@ -0,0 +1,8 @@
+CUSTOMCONNSTR_mongo=mongodb://127.0.0.1:27017/testdb
+API_SECRET=abcdefghij123
+HOSTNAME=localhost
+INSECURE_USE_HTTP=true
+PORT=1337
+NODE_ENV=production
+CI=true
+CODACY_PROJECT_TOKEN=cff7ab3377d6434a9355fd051dbb4595
\ No newline at end of file
diff --git a/docs/plugins/add-virtual-assistant-support-to-plugin.md b/docs/plugins/add-virtual-assistant-support-to-plugin.md
new file mode 100644
index 00000000000..60ac1d1957b
--- /dev/null
+++ b/docs/plugins/add-virtual-assistant-support-to-plugin.md
@@ -0,0 +1,62 @@
+
+
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Adding Virtual Assistant Support to a Plugin](#adding-virtual-assistant-support-to-a-plugin)
+ - [Intent Handlers](#intent-handlers)
+ - [Rollup handlers](#rollup-handlers)
+
+
+
+Adding Virtual Assistant Support to a Plugin
+=========================================
+
+To add virtual assistant support to a plugin, the `init` method of the plugin should return an object that contains a `virtAsst` key. Here is an example:
+
+```javascript
+iob.virtAsst = {
+ intentHandlers: [{
+ intent: "MetricNow"
+ , metrics: ["iob"]
+ , intentHandler: virtAsstIOBIntentHandler
+ }]
+ , rollupHandlers: [{
+ rollupGroup: "Status"
+ , rollupName: "current iob"
+ , rollupHandler: virtAsstIOBRollupHandler
+ }]
+};
+```
+
+There are 2 types of handlers that you can supply:
+* Intent handler - Enables you to "teach" the virtual assistant how to respond to a user's question.
+* A rollup handler - Enables you to create a command that aggregates information from multiple plugins. This would be akin to the a "flash briefing". An example would be a status report that contains your current bg, iob, and your current basal.
+
+### Intent Handlers
+
+A plugin can expose multiple intent handlers (e.g. useful when it can supply multiple kinds of metrics). Each intent handler should be structured as follows:
++ `intent` - This is the intent this handler is built for. Right now, the templates used by both Alexa and Google Home use only the `"MetricNow"` intent (used for getting the present value of the requested metric)
++ `metrics` - An array of metric name(s) the handler will supply. e.g. "What is my `metric`" - iob, bg, cob, etc. Make sure to add the metric name and its synonyms to the list of metrics used by the virtual assistant(s).
+ - **IMPORTANT NOTE:** There is no protection against overlapping metric names, so PLEASE make sure your metric name is unique!
+ - Note: Although this value *is* an array, you really should only supply one (unique) value, and then add aliases or synonyms to that value in the list of metrics for the virtual assistant. We keep this value as an array for backwards compatibility.
++ `intenthandler` - This is a callback function that receives 3 arguments:
+ - `callback` Call this at the end of your function. It requires 2 arguments:
+ - `title` - Title of the handler. This is the value that will be displayed on the Alexa card (for devices with a screen). The Google Home response doesn't currently display a card, so it doesn't use this value.
+ - `text` - This is text that the virtual assistant should speak (and show, for devices with a screen).
+ - `slots` - These are the slots (Alexa) or parameters (Google Home) that the virtual assistant detected (e.g. `pwd` as seen in the templates is a slot/parameter. `metric` is technically a slot, too).
+ - `sandbox` - This is the Nightscout sandbox that allows access to various functions.
+
+### Rollup handlers
+
+A plugin can also expose multiple rollup handlers
++ `rollupGroup` - This is the key that is used to aggregate the responses when the intent is invoked
++ `rollupName` - This is the name of the handler. Primarily used for debugging
++ `rollupHandler` - This is a callback function that receives 3 arguments
+ - `slots` - These are the values of the slots. Make sure to add these values to the appropriate custom slot
+ - `sandbox` - This is the nightscout sandbox that allows access to various functions.
+ - `callback` -
+ - `error` - This would be an error message
+ - `response` - A simple object that expects a `results` string and a `priority` integer. Results should be the text (speech) that is added to the rollup and priority affects where in the rollup the text should be added. The lowest priority is spoken first. An example callback:
+ ```javascript
+ callback(null, {results: "Hello world", priority: 1});
+ ```
diff --git a/docs/plugins/alexa-plugin.md b/docs/plugins/alexa-plugin.md
index a5dcb886e9c..87117affd46 100644
--- a/docs/plugins/alexa-plugin.md
+++ b/docs/plugins/alexa-plugin.md
@@ -10,12 +10,13 @@
- [Create a new Alexa skill](#create-a-new-alexa-skill)
- [Define the interaction model](#define-the-interaction-model)
- [Point your skill at your site](#point-your-skill-at-your-site)
+ - [Do you use Authentication Roles?](#do-you-use-authentication-roles)
- [Test your skill out with the test tool](#test-your-skill-out-with-the-test-tool)
- [What questions can you ask it?](#what-questions-can-you-ask-it)
- [Activate the skill on your Echo or other device](#activate-the-skill-on-your-echo-or-other-device)
+ - [Updating your skill with new features](#updating-your-skill-with-new-features)
+ - [Adding support for additional languages](#adding-support-for-additional-languages)
- [Adding Alexa support to a plugin](#adding-alexa-support-to-a-plugin)
- - [Intent Handlers](#intent-handlers)
- - [Rollup handlers](#rollup-handlers)
@@ -41,9 +42,9 @@ To add Alexa support for a plugin, [check this out](#adding-alexa-support-to-a-p
### Get an Amazon Developer account
-- Sign up for a free [Amazon Developer account](https://developer.amazon.com/) if you don't already have one.
-- [Register](https://developer.amazon.com/docs/devconsole/test-your-skill.html#h2_register) your Alexa-enabled device with your Developer account.
-- Sign in and go to the [Alexa developer portal](https://developer.amazon.com/alexa).
+1. Sign up for a free [Amazon Developer account](https://developer.amazon.com/) if you don't already have one.
+1. [Register](https://developer.amazon.com/docs/devconsole/test-your-skill.html#h2_register) your Alexa-enabled device with your Developer account.
+1. Sign in and go to the [Alexa developer portal](https://developer.amazon.com/alexa/console/ask).
### Create a new Alexa skill
@@ -58,164 +59,11 @@ To add Alexa support for a plugin, [check this out](#adding-alexa-support-to-a-p
Your Alexa skill's "interaction model" defines how your spoken questions get translated into requests to your Nightscout site, and how your Nightscout site's responses get translated into the audio responses that Alexa says back to you.
-To get up and running with a basic interaction model, which will allow you to ask Alexa a few basic questions about your Nightscout site, you can copy and paste the configuration code below.
-
-```json
-{
- "interactionModel": {
- "languageModel": {
- "invocationName": "nightscout",
- "intents": [
- {
- "name": "NSStatus",
- "slots": [],
- "samples": [
- "How am I doing"
- ]
- },
- {
- "name": "UploaderBattery",
- "slots": [],
- "samples": [
- "How is my uploader battery"
- ]
- },
- {
- "name": "PumpBattery",
- "slots": [],
- "samples": [
- "How is my pump battery"
- ]
- },
- {
- "name": "LastLoop",
- "slots": [],
- "samples": [
- "When was my last loop"
- ]
- },
- {
- "name": "MetricNow",
- "slots": [
- {
- "name": "metric",
- "type": "LIST_OF_METRICS"
- },
- {
- "name": "pwd",
- "type": "AMAZON.US_FIRST_NAME"
- }
- ],
- "samples": [
- "What is my {metric}",
- "What my {metric} is",
- "What is {pwd} {metric}"
- ]
- },
- {
- "name": "InsulinRemaining",
- "slots": [
- {
- "name": "pwd",
- "type": "AMAZON.US_FIRST_NAME"
- }
- ],
- "samples": [
- "How much insulin do I have left",
- "How much insulin do I have remaining",
- "How much insulin does {pwd} have left",
- "How much insulin does {pwd} have remaining"
- ]
- }
- ],
- "types": [
- {
- "name": "LIST_OF_METRICS",
- "values": [
- {
- "name": {
- "value": "bg"
- }
- },
- {
- "name": {
- "value": "blood glucose"
- }
- },
- {
- "name": {
- "value": "number"
- }
- },
- {
- "name": {
- "value": "iob"
- }
- },
- {
- "name": {
- "value": "insulin on board"
- }
- },
- {
- "name": {
- "value": "current basal"
- }
- },
- {
- "name": {
- "value": "basal"
- }
- },
- {
- "name": {
- "value": "cob"
- }
- },
- {
- "name": {
- "value": "carbs on board"
- }
- },
- {
- "name": {
- "value": "carbohydrates on board"
- }
- },
- {
- "name": {
- "value": "loop forecast"
- }
- },
- {
- "name": {
- "value": "ar2 forecast"
- }
- },
- {
- "name": {
- "value": "forecast"
- }
- },
- {
- "name": {
- "value": "raw bg"
- }
- },
- {
- "name": {
- "value": "raw blood glucose"
- }
- }
- ]
- }
- ]
- }
- }
-}
-```
-
-Select "JSON Editor" in the left-hand menu on your skill's edit page (which you should be on if you followed the above instructions). Replace everything in the textbox with the above code. Then click "Save Model" at the top. A success message should appear indicating that the model was saved.
+To get up and running with an interaction model, which will allow you to ask Alexa a few basic questions about your Nightscout site, you can copy and paste the configuration code for your language from [the list of templates](alexa-templates/).
+
+- If you're language doesn't have a template, please consider starting with [the en-us template](alexa-templates/en-us.json), then [modifying it to work with your language](#adding-support-for-additional-languages), and [making a pull request](/CONTRIBUTING.md) or [submitting an issue](https://github.com/nightscout/cgm-remote-monitor/issues) with your translated template to share it with others.
+
+Select "JSON Editor" in the left-hand menu on your skill's edit page (which you should be on if you followed the above instructions). Replace everything in the textbox with the code from your chosen template. Then click "Save Model" at the top. A success message should appear indicating that the model was saved.
Next you need to build your custom model. Click "Build Model" at the top of the same page. It'll take a minute to build, and then you should see another success message, "Build Successful".
@@ -228,122 +76,91 @@ Now you need to point your skill at *your* Nightscout site.
1. In the left-hand menu for your skill, there's an option called "Endpoint". Click it.
1. Under "Service Endpoint Type", select "HTTPS".
1. You only need to set up the Default Region. In the box that says "Enter URI...", put in `https://{yourdomain}/api/v1/alexa`. (So if your Nightscout site is at `mynightscoutsite.herokuapp.com`, you'll enter `https://mynightscoutsite.herokuapp.com/api/v1/alexa` in the box.)
+ - If you use Authentication Roles, you'll need to add a bit to the end of your URL. See [the section](#do-you-use-authentication-roles) below.
1. In the dropdown under the previous box, select "My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority".
1. Click the "Save Endpoints" button at the top.
1. You should see a success message pop up when the save succeeds.
+### Do you use Authentication Roles? ###
+
+If you use Authentication Roles, you will need to add a token to the end of your Nightscout URL when configuring your Endpoint.
+
+1. In your Nightscout Admin Tools, add a new subject and give it the "readable" role.
+ - If you **really** would like to be super specific, you could create a new role and set the permissions to `api:*:read`.
+1. After the new subject is created, copy the "Access Token" value for the new row in your subject table (**don't** copy the link, just copy the text).
+1. At the end of your Nighscout URL, add `?token={yourtoken}`, where `{yourtoken}` is the Access Token you just copied. Your new URL should look like `https://{yourdomain}/api/v1/googlehome?token={yourtoken}`.
+
### Test your skill out with the test tool
Click on the "Test" tab on the top menu. This will take you to the page where you can test your new skill.
Enable testing for your skill (click the toggle). As indicated on this page, when testing is enabled, you can interact with the development version of your skill in the Alexa simulator and on all devices linked to your Alexa developer account. (Your skill will always be a development version. There's no need to publish it to the public.)
-After you enable testing, you can also use the Alexa Simulator in the left column, to try out the skill. You can type in questions and see the text your skill would reply with. You can also hold the microphone icon to ask questions using your microphone, and you'll get the audio and text responses back.
+After you enable testing, you can also use the Alexa Simulator in the left column, to try out the skill. You can type in questions and see the text your skill would reply with. When typing your test question, only type what you would verbally say to an Alexa device after the wake word. (e.g. you would verbally say "Alexa, ask Nightscout how am I doing", so you would type only "ask Nightscout how am I doing") You can also hold the microphone icon to ask questions using your microphone, and you'll get the audio and text responses back.
##### What questions can you ask it?
-*Forecast:*
-
-- "Alexa, ask Nightscout how am I doing"
-- "Alexa, ask Nightscout how I'm doing"
-
-*Uploader Battery:*
-
-- "Alexa, ask Nightscout how is my uploader battery"
-
-*Pump Battery:*
-
-- "Alexa, ask Nightscout how is my pump battery"
-
-*Metrics:*
-
-- "Alexa, ask Nightscout what my bg is"
-- "Alexa, ask Nightscout what my blood glucose is"
-- "Alexa, ask Nightscout what my number is"
-- "Alexa, ask Nightscout what is my insulin on board"
-- "Alexa, ask Nightscout what is my basal"
-- "Alexa, ask Nightscout what is my current basal"
-- "Alexa, ask Nightscout what is my cob"
-- "Alexa, ask Nightscout what is Charlie's carbs on board"
-- "Alexa, ask Nightscout what is Sophie's carbohydrates on board"
-- "Alexa, ask Nightscout what is Harper's loop forecast"
-- "Alexa, ask Nightscout what is Alicia's ar2 forecast"
-- "Alexa, ask Nightscout what is Peter's forecast"
-- "Alexa, ask Nightscout what is Arden's raw bg"
-- "Alexa, ask Nightscout what is Dana's raw blood glucose"
-
-*Insulin Remaining:*
-
-- "Alexa, ask Nightscout how much insulin do I have left"
-- "Alexa, ask Nightscout how much insulin do I have remaining"
-- "Alexa, ask Nightscout how much insulin does Dana have left?
-- "Alexa, ask Nightscout how much insulin does Arden have remaining?
-
-*Last Loop:*
-
-- "Alexa, ask Nightscout when was my last loop"
-
-(Note: all the formats with specific names will respond to questions for any first name. You don't need to configure anything with your PWD's name.)
+See [Interacting with Virtual Assistants](interacting-with-virtual-assistants.md) for details on what you can do with Alexa.
### Activate the skill on your Echo or other device
If your device is [registered](https://developer.amazon.com/docs/devconsole/test-your-skill.html#h2_register) with your developer account, you should be able to use your skill right away. Try it by asking Alexa one of the above questions using your device.
+## Updating your skill with new features
+
+As more work is done on Nightscout, new ways to interact with Nighscout via Alexa may be made available. To be able to use these new features, you first will need to [update your Nightscout site](https://github.com/nightscout/cgm-remote-monitor#updating-my-version), and then you can follow the steps below to update your Alexa skill.
+
+1. Make sure you've [updated your Nightscout site](https://github.com/nightscout/cgm-remote-monitor#updating-my-version) first.
+1. Open [the latest skill template](alexa-templates/) in your language. You'll be copying the contents of the file later.
+ - If your language doesn't include the latest features you're looking for, you're help [translating those new features](#adding-support-for-additional-languages) would be greatly appreciated!
+1. Sign in to the [Alexa developer portal](https://developer.amazon.com/alexa/console/ask).
+1. Open your Nightscout skill.
+1. Open the "JSON Editor" in the left navigation pane.
+1. Select everything in the text box (Ctrl + A on Windows, Cmd + A on Mac) and delete it.
+1. Copy the contents of the updated template and paste it in the text box in the JSON Editor page.
+1. Click the "Save Model" button near the top of the page, and then click the "Build Model" button.
+1. Make sure to follow any directions specific to the Nightscout update. If there are any, they will be noted in the [release notes](https://github.com/nightscout/cgm-remote-monitor/releases).
+1. If you gave your skill name something other than "night scout," you will need to go to the "Invocation" page in the left navigation pane and change the Skill Invocation Name back to your preferred name. Make sure to click the "Save Model" button followed by the "Build Model" button after you change the name.
+1. Enjoy the new features!
+
+## Adding support for additional languages
+
+If the translations in Nightscout are configured correctly for the desired language code, Nightscout *should* automatically respond in that language after following the steps below.
+
+If you add support for another language, please consider [making a pull request](/CONTRIBUTING.md) or [submitting an issue](https://github.com/nightscout/cgm-remote-monitor/issues) with your translated template to share it with others. You can export your translated template by going to the "JSON Editor" in the left navigation pane.
+
+1. Open the Build tab of your Alexa Skill.
+ - Get to your list of Alexa Skills at https://developer.amazon.com/alexa/console/ask and click on the name of the skill.
+1. Click on the language drop-down box in the upper right corner of the window.
+1. Click "Language settings".
+1. Add your desired language.
+1. Click the "Save" button.
+1. Navigate to "CUSTOM" in the left navigation pane.
+1. Select your new language in the language drop-down box.
+1. Go to "JSON Editor" (just above "Interfaces" in the left navigation pane).
+1. Remove the existing contents in the text box, and copy and paste the configuration code from a familiar language in [the list of templates](alexa-templates/).
+1. Click "Save Model".
+1. Click the "Add" button next to the "Slot Types" section in the left pane.
+1. Click the radio button for "Use an existing slot type from Alexa's built-in library"
+1. In the search box just below that option, search for "first name"
+1. If your language has an option, click the "Add Slot Type" button for that option.
+ - If your language doesn't have an option, you won't be able to ask Nightscout a question that includes a name.
+1. For each Intent listed in the left navigation pane (e.g. "NSStatus" and "MetricNow"):
+ 1. Click on the Intent name.
+ 1. Scroll down to the "Slots" section
+ 1. If there's a slot with the name "pwd", change the Slot Type to the one found above.
+ - If you didn't find one above, you'll have to see if another language gets close enough for you, or delete the slot.
+ 1. If there's a slot with the name "metric", click the "Edit Dialog" link on the right. This is where you set Alexa's questions and your answers if you happen to ask a question about metrics but don't include which metric you want to know.
+ 1. Set the "Alexa speech prompts" in your language, and remove the old ones.
+ 1. Under "User utterances", set the phrases you would say in response to the questions Alexa would pose from the previous step. MAKE SURE that your example phrases include where you would say the name of the metric. You do this by typing the left brace (`{`) and then selecting `metric` in the popup.
+ 1. Click on the Intent name (just to the left of "metric") to return to the previous screen.
+ 1. For each Sample Utterance, add an equivalent phrase in your language. If the phrase you're replacing has a `metric` slot, make sure to include that in your replacement phrase. Same goes for the `pwd` slot, unless you had to delete that slot a couple steps ago, in which case you need to modify the phrase to not use a first name, or not make a replacement phrase. After you've entered your replacement phrase, delete the phrase you're replacing.
+1. Navigate to the "LIST_OF_METRICS" under the Slot Types section.
+1. For each metric listed, add synonyms in your language, and delete the old synonyms.
+ - What ever you do, **DO NOT** change the text in the "VALUE" column! Nightscout will be looking for these exact values. Only change the synonyms.
+1. Click "Save Model" at the top, and then click on "Build Model".
+1. You should be good to go! Feel free to try it out using the "Test" tab near the top of the window, or start asking your Alexa-enabled device some questions. See [Interacting with Virtual Assistants](interacting-with-virtual-assistants.md) for details on what you can do with Alexa.
+
## Adding Alexa support to a plugin
-This document assumes some familiarity with the Alexa interface. You can find more information [here](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/getting-started-guide).
-
-To add alexa support to a plugin the ``init`` should return an object that contains an "alexa" key. Here is an example:
-
-```javascript
-var iob = {
- name: 'iob'
- , label: 'Insulin-on-Board'
- , pluginType: 'pill-major'
- , alexa : {
- rollupHandlers: [{
- rollupGroup: "Status"
- , rollupName: "current iob"
- , rollupHandler: alexaIOBRollupHandler
- }]
- , intentHandlers: [{
- intent: "MetricNow"
- , routableSlot: "metric"
- , slots: ["iob", "insulin on board"]
- , intentHandler: alexaIOBIntentHandler
- }]
- }
-};
-```
-
-There are 2 types of handlers that you will need to supply:
-* Intent handler - enables you to "teach" Alexa how to respond to a user's question.
-* A rollup handler - enables you to create a command that aggregates information from multiple plugins. This would be akin to the Alexa "flash briefing". An example would be a status report that contains your current bg, iob, and your current basal.
-
-### Intent Handlers
-
-A plugin can expose multiple intent handlers.
-+ ``intent`` - this is the intent in the "intent schema" above
-+ ``routeableSlot`` - This enables routing by a slot name to the appropriate intent handler for overloaded intents e.g. "What is my " - iob, bg, cob, etc. This value should match the slot named in the "intent schema"
-+ ``slots`` - These are the values of the slots. Make sure to add these values to the appropriate custom slot
-+ ``intenthandler`` - this is a callback function that receives 3 arguments
- - ``callback`` Call this at the end of your function. It requires 2 arguments
- - ``title`` - Title of the handler. This is the value that will be displayed on the Alexa card
- - ``text`` - This is text that Alexa should speak.
- - ``slots`` - these are the slots that Alexa detected
- - ``sandbox`` - This is the nightscout sandbox that allows access to various functions.
-
-### Rollup handlers
-
-A plugin can also expose multiple rollup handlers
-+ ``rollupGroup`` - This is the key that is used to aggregate the responses when the intent is invoked
-+ ``rollupName`` - This is the name of the handler. Primarily used for debugging
-+ ``rollupHandler`` - this is a callback function that receives 3 arguments
- - ``slots`` - These are the values of the slots. Make sure to add these values to the appropriate custom slot
- - ``sandbox`` - This is the nightscout sandbox that allows access to various functions.
- - ``callback`` -
- - ``error`` - This would be an error message
- - ``response`` - A simple object that expects a ``results`` string and a ``priority`` integer. Results should be the text (speech) that is added to the rollup and priority affects where in the rollup the text should be added. The lowest priority is spoken first. An example callback:
- ```javascript
- callback(null, {results: "Hello world", priority: 1});
- ```
+See [Adding Virtual Assistant Support to a Plugin](add-virtual-assistant-support-to-plugin.md)
\ No newline at end of file
diff --git a/docs/plugins/alexa-templates/en-us.json b/docs/plugins/alexa-templates/en-us.json
new file mode 100644
index 00000000000..79cc1baa977
--- /dev/null
+++ b/docs/plugins/alexa-templates/en-us.json
@@ -0,0 +1,293 @@
+{
+ "interactionModel": {
+ "languageModel": {
+ "invocationName": "night scout",
+ "intents": [
+ {
+ "name": "NSStatus",
+ "slots": [],
+ "samples": [
+ "How am I doing"
+ ]
+ },
+ {
+ "name": "LastLoop",
+ "slots": [],
+ "samples": [
+ "When was my last loop"
+ ]
+ },
+ {
+ "name": "MetricNow",
+ "slots": [
+ {
+ "name": "metric",
+ "type": "LIST_OF_METRICS",
+ "samples": [
+ "what {pwd} {metric} is",
+ "what my {metric} is",
+ "how {pwd} {metric} is",
+ "how my {metric} is",
+ "how much {metric} does {pwd} have",
+ "how much {metric} I have",
+ "how much {metric}",
+ "{pwd} {metric}",
+ "{metric}",
+ "my {metric}"
+ ]
+ },
+ {
+ "name": "pwd",
+ "type": "AMAZON.US_FIRST_NAME"
+ }
+ ],
+ "samples": [
+ "how much {metric} does {pwd} have left",
+ "what's {metric}",
+ "what's my {metric}",
+ "how much {metric} is left",
+ "what's {pwd} {metric}",
+ "how much {metric}",
+ "how is {metric}",
+ "how is my {metric}",
+ "how is {pwd} {metric}",
+ "how my {metric} is",
+ "what is {metric}",
+ "how much {metric} do I have",
+ "how much {metric} does {pwd} have",
+ "how much {metric} I have",
+ "what is my {metric}",
+ "what my {metric} is",
+ "what is {pwd} {metric}"
+ ]
+ },
+ {
+ "name": "AMAZON.NavigateHomeIntent",
+ "samples": []
+ },
+ {
+ "name": "AMAZON.StopIntent",
+ "samples": []
+ }
+ ],
+ "types": [
+ {
+ "name": "LIST_OF_METRICS",
+ "values": [
+ {
+ "name": {
+ "value": "delta",
+ "synonyms": [
+ "blood glucose delta",
+ "blood sugar delta",
+ "bg delta"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "uploader battery",
+ "synonyms": [
+ "uploader battery remaining",
+ "uploader battery power"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "pump reservoir",
+ "synonyms": [
+ "remaining insulin",
+ "insulin remaining",
+ "insulin is left",
+ "insulin left",
+ "insulin in my pump",
+ "insulin"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "pump battery",
+ "synonyms": [
+ "pump battery remaining",
+ "pump battery power"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "bg",
+ "synonyms": [
+ "number",
+ "blood sugar",
+ "blood glucose"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "iob",
+ "synonyms": [
+ "insulin on board"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "basal",
+ "synonyms": [
+ "current basil",
+ "basil",
+ "current basal"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cob",
+ "synonyms": [
+ "carbs",
+ "carbs on board",
+ "carboydrates",
+ "carbohydrates on board"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "forecast",
+ "synonyms": [
+ "ar2 forecast",
+ "loop forecast"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "raw bg",
+ "synonyms": [
+ "raw number",
+ "raw blood sugar",
+ "raw blood glucose"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cgm noise"
+ }
+ },
+ {
+ "name": {
+ "value": "cgm tx age",
+ "synonyms": [
+ "tx age",
+ "transmitter age",
+ "cgm transmitter age"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cgm tx status",
+ "synonyms": [
+ "tx status",
+ "transmitter status",
+ "cgm transmitter status"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cgm battery",
+ "synonyms": [
+ "cgm battery level",
+ "cgm battery levels",
+ "cgm batteries",
+ "cgm transmitter battery",
+ "cgm transmitter battery level",
+ "cgm transmitter battery levels",
+ "cgm transmitter batteries",
+ "transmitter battery",
+ "transmitter battery level",
+ "transmitter battery levels",
+ "transmitter batteries"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cgm session age",
+ "synonyms": [
+ "session age"
+ ]
+ }
+ },
+ {
+ "name": {
+ "value": "cgm status"
+ }
+ },
+ {
+ "name": {
+ "value": "cgm mode"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "dialog": {
+ "intents": [
+ {
+ "name": "MetricNow",
+ "confirmationRequired": false,
+ "prompts": {},
+ "slots": [
+ {
+ "name": "metric",
+ "type": "LIST_OF_METRICS",
+ "confirmationRequired": false,
+ "elicitationRequired": true,
+ "prompts": {
+ "elicitation": "Elicit.Slot.1421281086569.34001419564"
+ }
+ },
+ {
+ "name": "pwd",
+ "type": "AMAZON.US_FIRST_NAME",
+ "confirmationRequired": false,
+ "elicitationRequired": false,
+ "prompts": {}
+ }
+ ]
+ }
+ ],
+ "delegationStrategy": "ALWAYS"
+ },
+ "prompts": [
+ {
+ "id": "Elicit.Slot.1421281086569.34001419564",
+ "variations": [
+ {
+ "type": "PlainText",
+ "value": "What metric are you looking for?"
+ },
+ {
+ "type": "PlainText",
+ "value": "What value are you looking for?"
+ },
+ {
+ "type": "PlainText",
+ "value": "What metric do you want to know?"
+ },
+ {
+ "type": "PlainText",
+ "value": "What value do you want to know?"
+ }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/docs/plugins/google-home-templates/en-us.zip b/docs/plugins/google-home-templates/en-us.zip
new file mode 100644
index 00000000000..d8ada2a834a
Binary files /dev/null and b/docs/plugins/google-home-templates/en-us.zip differ
diff --git a/docs/plugins/googlehome-plugin.md b/docs/plugins/googlehome-plugin.md
new file mode 100644
index 00000000000..8e43549a2cf
--- /dev/null
+++ b/docs/plugins/googlehome-plugin.md
@@ -0,0 +1,151 @@
+
+
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Nightscout Google Home/DialogFlow Plugin](#nightscout-google-homedialogflow-plugin)
+ - [Overview](#overview)
+ - [Activate the Nightscout Google Home Plugin](#activate-the-nightscout-google-home-plugin)
+ - [Create Your DialogFlow Agent](#create-your-dialogflow-agent)
+ - [Do you use Authentication Roles?](#do-you-use-authentication-roles)
+ - [What questions can you ask it?](#what-questions-can-you-ask-it)
+ - [Updating your agent with new features](#updating-your-agent-with-new-features)
+ - [Adding support for additional languages](#adding-support-for-additional-languages)
+ - [Adding Google Home support to a plugin](#adding-google-home-support-to-a-plugin)
+
+
+
+Nightscout Google Home/DialogFlow Plugin
+========================================
+
+## Overview
+
+To add Google Home support for your Nightscout site, here's what you need to do:
+
+1. [Activate the `googlehome` plugin](#activate-the-nightscout-google-home-plugin) on your Nightscout site, so your site will respond correctly to Google's requests.
+1. [Create a custom DialogFlow agent](#create-your-dialogflow-agent) that points at your site and defines certain questions you want to be able to ask.
+
+## Activate the Nightscout Google Home Plugin
+
+1. Your Nightscout site needs to be new enough that it supports the `googlehome` plugin. It needs to be [version 13.0.0 (Ketchup)](https://github.com/nightscout/cgm-remote-monitor/releases/tag/13.0.0) or later. See [updating my version](https://github.com/nightscout/cgm-remote-monitor#updating-my-version) if you need a newer version.
+1. Add `googlehome` to the list of plugins in your `ENABLE` setting. ([Environment variables](https://github.com/nightscout/cgm-remote-monitor#environment) are set in the configuration section for your monitor. Typically Azure, Heroku, etc.)
+
+## Create Your DialogFlow Agent
+
+1. Download the agent template in your language for Google Home [here](google-home-templates/).
+ - If you're language doesn't have a template, please consider starting with [the en-us template](google-home-templates/en-us.zip), then [modifying it to work with your language](#adding-support-for-additional-languages), and [making a pull request](/CONTRIBUTING.md) or [submitting an issue](https://github.com/nightscout/cgm-remote-monitor/issues) with your translated template to share it with others.
+1. [Sign in to Google's Action Console](https://console.actions.google.com)
+ - Make sure to use the same account that is connected to your Google Home device, Android smartphone, Android tablet, etc.
+1. Click on the "New Project" button.
+1. If prompted, agree to the Terms of Service.
+1. Give your project a name (e.g. "Nightscout") and then click "Create project".
+1. For the "development experience", select "Conversational" at the bottom of the list.
+1. Click on the "Develop" tab at the top of the sreen.
+1. Click on "Invocation" in the left navigation pane.
+1. Set the display name (e.g. "Night Scout") of your Action and set your Google Assistant voice.
+ - Unfortunately, the Action name needs to be two words, and is required to be unique across all of Google, even though you won't be publishing this for everyone on Google to use. So you'll have to be creative with the name since "Night Scout" is already taken.
+1. Click "Save" in the upper right corner.
+1. Navigate to "Actions" in the left nagivation pane, then click on the "Add your first action" button.
+1. Make sure you're on "Cutom intent" and then click "Build" to open DialogFlow in a new tab.
+1. Sign in with the same Google account you used to sign in to the Actions Console.
+ - You'll have to go through the account setup steps if this is your first time using DialogFlow.
+1. Verify the name for your agent (e.g. "Nightscout") and click "CREATE".
+1. In the navigation pane on the left, click the gear icon next to your agent name.
+1. Click on the "Export and Import" tab in the main area of the page.
+1. Click the "IMPORT FROM ZIP" button.
+1. Select the template file downloaded in step 1.
+1. Type "IMPORT" where requested and then click the "IMPORT" button.
+1. After the import finishes, click the "DONE" button followed by the "SAVE" button.
+1. In the navigation pane on the left, click on "Fulfillment".
+1. Enable the toggle for "Webhook" and then fill in the URL field with your Nightscout URL: `https://YOUR-NIGHTSCOUT-SITE/api/v1/googlehome`
+ - If you use Authentication Roles, you'll need to add a bit to the end of your URL. See [the section](#do-you-use-authentication-roles) below.
+1. Scroll down to the bottom of the page and click the "SAVE" button.
+1. Click on "Integrations" in the navigation pane.
+1. Click on "INTEGRATION SETTINGS" for "Google Assistant".
+1. Under "Implicit invocation", add every intent listed.
+1. Turn on the toggle for "Auto-preview changes".
+1. Click "CLOSE".
+
+That's it! Now try asking Google "Hey Google, ask *your Action's name* how am I doing?"
+
+### Do you use Authentication Roles? ###
+
+If you use Authentication Roles, you will need to add a token to the end of your Nightscout URL when configuring your Webhook.
+
+1. In your Nightscout Admin Tools, add a new subject and give it the "readable" role.
+ - If you **really** would like to be super specific, you could create a new role and set the permissions to `api:*:read`.
+1. After the new subject is created, copy the "Access Token" value for the new row in your subject table (**don't** copy the link, just copy the text).
+1. At the end of your Nighscout URL, add `?token=YOUR-TOKEN`, where `YOUR-TOKEN` is the Access Token you just copied. Your new URL should look like `https://YOUR-NIGHTSCOUT-SITE/api/v1/googlehome?token=YOUR-TOKEN`.
+
+### What questions can you ask it?
+
+See [Interacting with Virtual Assistants](interacting-with-virtual-assistants.md) for details on what you can do with Google Home.
+
+## Updating your agent with new features
+
+As more work is done on Nightscout, new ways to interact with Nighscout via Google Home may be made available. To be able to use these new features, you first will need to [update your Nightscout site](https://github.com/nightscout/cgm-remote-monitor#updating-my-version), and then you can follow the steps below to update your DialogFlow agent.
+
+1. Make sure you've [updated your Nightscout site](https://github.com/nightscout/cgm-remote-monitor#updating-my-version) first.
+1. Download [the latest skill template](google-home-templates/) in your language.
+ - If your language doesn't include the latest features you're looking for, you're help [translating those new features](#adding-support-for-additional-languages) would be greatly appreciated!
+1. Sign in to the [DialogFlow developer portal](https://dialogflow.cloud.google.com/).
+1. Make sure you're viewing your Nightscout agent (there's a drop-down box immediately below the DialogFlow logo where you can select your agent).
+1. Click on the gear icon next to your agent name, then click on the "Export and Import" tab.
+1. Click the "RESTORE FROM ZIP" button.
+1. Select the template file you downloaded earlier, then type "RESTORE" in the text box as requested, and click the "RESTORE" button.
+1. After the import is completed, click the "DONE" button.
+1. Make sure to follow any directions specific to the Nightscout update. If there are any, they will be noted in the [release notes](https://github.com/nightscout/cgm-remote-monitor/releases).
+1. Enjoy the new features!
+
+## Adding support for additional languages
+
+If the translations in Nightscout are configured correctly for the desired language code, Nightscout *should* automatically respond in that language after following the steps below.
+
+If you add support for another language, please consider [making a pull request](/CONTRIBUTING.md) or [submitting an issue](https://github.com/nightscout/cgm-remote-monitor/issues) with your translated template to share it with others. You can export your translated template by going to the settings of your DialogFlow agent (the gear icon next to the project's name in the left nagivation pane), going to the "Export and Import" tab, and clicking "EXPORT AS ZIP".
+
+1. Open your DialogFlow agent.
+ - Get to your list of agents at https://console.dialogflow.com/api-client/#/agents and click on the name of your Nightscout agent.
+1. Click on the "Languages" tab.
+1. Click the "Add Additional Language" drop-down box.
+1. Select your desired language.
+1. Click the "SAVE" button.
+ - Note the new language code below the agent's name. e.g. if you're using the English template and you added Spanish, you would see two buttons: "en" and "es".
+1. Click on "Intents" in the left navigation pane.
+1. For each intent in the list (NOT including those that start with "Default" in the name):
+ 1. Click on the intent name.
+ 1. Note the phrases used in the "Training phrases" section.
+ - If the phrase has a colored block (e.g. `metric` or `pwd`), click the phrase (but NOT the colored block) and note the "PARAMETER NAME" of the item with the same-colored "ENTITY".
+ 1. Click on the new language code (beneath the agent name near the top of the navigation pane).
+ 1. Add equivalent or similar training phrases as those you noted a couple steps ago.
+ - If the phrase in the orginal language has a colored block with a word in it, that needs to be included. When adding the phrase to the new language, follow these steps to add the colored block:
+ 1. When typing that part of the training phrase, don't translate the word in the block; just keep it as-is.
+ 1. After typing the phrase (DON'T push the Enter key yet!) highlight/select the word.
+ 1. A box will pop up with a list of parameter types, some of which end with a colon (`:`) and a parameter name. Click the option that has the same parameter name as the one you determined just a few steps ago.
+ 1. Press the Enter key to add the phrase.
+ 1. Click the "SAVE" button.
+ 1. Go back and forth between your starting language and your new language, adding equivalent phrase(s) to the new language. Continue once you've added all the equivalent phrases you can think of.
+ 1. Scroll down to the "Action and parameters" section.
+ 1. If any of the items in that list have the "REQUIRED" option checked:
+ 1. Click the "Define prompts..." link on the right side of that item.
+ 1. Add phrases that Google will ask if you happen to say something similar to a training phrase, but don't include this parameter (e.g. if you ask about a metric but don't say what metric you want to know about).
+ 1. Click "CLOSE".
+ 1. Scroll down to the "Responses" section.
+ 1. Set just one phrase here. This will be what Google says if it has technical difficulties getting a response from your Nightscout website.
+ 1. Click the "SAVE" button at the top of the window.
+1. Click on the "Entities" section in the navigation pane.
+1. For each entity listed:
+ 1. Click the entity name.
+ 1. Switch to the starting language (beneath the agent name near the top of the left navigation pane).
+ 1. Click the menu icon to the right of the "SAVE" button and click "Switch to raw mode".
+ 1. Select all the text in the text box and copy it.
+ 1. Switch back to your new language.
+ 1. Click the menu icon to the right of the "SAVE" button and click "Switch to raw mode".
+ 1. In the text box, paste the text you just copied.
+ 1. Click the menu icon to the right of the "SAVE" button and click "Switch to editor mode".
+ 1. For each item in the list, replace the items on the RIGHT side of the list with equivalent words and phrases in your language.
+ - What ever you do, **DO NOT** change the values on the left side of the list. Nightscout will be looking for these exact values. Only change the items on the right side of the list.
+ 1. Click the "SAVE" button.
+1. You should be good to go! Feel free to try it out by click the "See how it works in Google Assistant" link in the right navigation pane, or start asking your Google-Home-enabled device some questions. See [Interacting with Virtual Assistants](interacting-with-virtual-assistants.md) for details on what you can do with Google Home.
+
+## Adding Google Home support to a plugin
+
+See [Adding Virtual Assistant Support to a Plugin](add-virtual-assistant-support-to-plugin.md)
\ No newline at end of file
diff --git a/docs/plugins/interacting-with-virtual-assistants.md b/docs/plugins/interacting-with-virtual-assistants.md
new file mode 100644
index 00000000000..3fe67bee2fb
--- /dev/null
+++ b/docs/plugins/interacting-with-virtual-assistants.md
@@ -0,0 +1,77 @@
+
+
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Interacting with Virtual Assistants](#interacting-with-virtual-assistants)
+- [Alexa vs. Google Home](#alexa-vs-google-home)
+- [What questions can you ask it?](#what-questions-can-you-ask-it)
+ - [A note about names](#a-note-about-names)
+
+
+
+Interacting with Virtual Assistants
+===================================
+
+# Alexa vs. Google Home
+
+Although these example phrases reference Alexa, the exact same questions could be asked of Google.
+Just replace "Alexa, ask Nightscout ..." with "Hey Google, ask *your action's name* ..."
+
+# What questions can you ask it?
+
+This list is not meant to be comprehensive, nor does it include every way you can ask the questions. To get the full picture, in the respective console for your virtual assistant, check the example phrases for each `intent`, and the values (including synonyms) of the "metric" `slot` (Alexa) or `entity` (Google Home). You can also just experiement with asking different questions to see what works.
+
+*Forecast:*
+
+- "Alexa, ask Nightscout how am I doing"
+- "Alexa, ask Nightscout how I'm doing"
+
+*Uploader Battery:*
+
+- "Alexa, ask Nightscout how is my uploader battery"
+
+*Pump Battery:*
+
+- "Alexa, ask Nightscout how is my pump battery"
+
+*Metrics:*
+
+- "Alexa, ask Nightscout what my bg is"
+- "Alexa, ask Nightscout what my blood glucose is"
+- "Alexa, ask Nightscout what my number is"
+- "Alexa, ask Nightscout what is my insulin on board"
+- "Alexa, ask Nightscout what is my basal"
+- "Alexa, ask Nightscout what is my current basal"
+- "Alexa, ask Nightscout what is my cob"
+- "Alexa, ask Nightscout what is my delta"
+- "Alexa, ask Nightscout what is Charlie's carbs on board"
+- "Alexa, ask Nightscout what is Sophie's carbohydrates on board"
+- "Alexa, ask Nightscout what is Harper's loop forecast"
+- "Alexa, ask Nightscout what is Alicia's ar2 forecast"
+- "Alexa, ask Nightscout what is Peter's forecast"
+- "Alexa, ask Nightscout what is Arden's raw bg"
+- "Alexa, ask Nightscout what is Dana's raw blood glucose"
+
+*CGM Info:* (when using the [`xdripjs` plugin](/README.md#xdripjs-xdrip-js))
+
+- "Alexa, ask Nightscout what's my CGM status"
+- "Alexa, ask Nightscout what's my CGM session age"
+- "Alexa, ask Nightscout what's my CGM transmitter age"
+- "Alexa, ask Nightscout what's my CGM mode"
+- "Alexa, ask Nightscout what's my CGM noise"
+- "Alexa, ask Nightscout what's my CGM battery"
+
+*Insulin Remaining:*
+
+- "Alexa, ask Nightscout how much insulin do I have left"
+- "Alexa, ask Nightscout how much insulin do I have remaining"
+- "Alexa, ask Nightscout how much insulin does Dana have left?
+- "Alexa, ask Nightscout how much insulin does Arden have remaining?
+
+*Last Loop:*
+
+- "Alexa, ask Nightscout when was my last loop"
+
+## A note about names
+
+All the formats with specific names will respond to questions for any first name. You don't need to configure anything with your PWD's name.
\ No newline at end of file
diff --git a/env.js b/env.js
index 9114e7297fc..0d8d41409b0 100644
--- a/env.js
+++ b/env.js
@@ -22,14 +22,6 @@ function config ( ) {
*/
env.DISPLAY_UNITS = readENV('DISPLAY_UNITS', 'mg/dl');
- // be lenient at accepting the mmol input
- if (env.DISPLAY_UNITS.toLowerCase().includes('mmol')) {
- env.DISPLAY_UNITS = 'mmol';
- } else {
- // also ensure the mg/dl is set with expected case
- env.DISPLAY_UNITS = 'mg/dl';
- }
-
console.log('Units set to', env.DISPLAY_UNITS );
env.PORT = readENV('PORT', 1337);
@@ -112,6 +104,7 @@ function setStorage() {
env.authentication_collections_prefix = readENV('MONGO_AUTHENTICATION_COLLECTIONS_PREFIX', 'auth_');
env.treatments_collection = readENV('MONGO_TREATMENTS_COLLECTION', 'treatments');
env.profile_collection = readENV('MONGO_PROFILE_COLLECTION', 'profile');
+ env.settings_collection = readENV('MONGO_SETTINGS_COLLECTION', 'settings');
env.devicestatus_collection = readENV('MONGO_DEVICESTATUS_COLLECTION', 'devicestatus');
env.food_collection = readENV('MONGO_FOOD_COLLECTION', 'food');
env.activity_collection = readENV('MONGO_ACTIVITY_COLLECTION', 'activity');
@@ -144,8 +137,6 @@ function updateSettings() {
env.settings.authDefaultRoles = env.settings.authDefaultRoles || "";
env.settings.authDefaultRoles += ' careportal';
}
-
-
}
function readENV(varName, defaultValue) {
@@ -155,6 +146,13 @@ function readENV(varName, defaultValue) {
|| process.env[varName]
|| process.env[varName.toLowerCase()];
+ if (varName == 'DISPLAY_UNITS' && value) {
+ if (value.toLowerCase().includes('mmol')) {
+ value = 'mmol';
+ } else {
+ value = 'mg/dl';
+ }
+ }
return value != null ? value : defaultValue;
}
diff --git a/lib/api/activity/index.js b/lib/api/activity/index.js
index d88019ab936..c42e73570c6 100644
--- a/lib/api/activity/index.js
+++ b/lib/api/activity/index.js
@@ -43,17 +43,17 @@ function configure(app, wares, ctx) {
var d2 = null;
- if (t.hasOwnProperty('created_at')) {
+ if (Object.prototype.hasOwnProperty.call(t, 'created_at')) {
d2 = new Date(t.created_at);
} else {
- if (t.hasOwnProperty('timestamp')) {
+ if (Object.prototype.hasOwnProperty.call(t, 'timestamp')) {
d2 = new Date(t.timestamp);
}
}
if (d2 == null) { return; }
- if (d1 == null || d2.getTime() > d1.getTime()) {
+ if (d1 == null || d2.getTime() > d1.getTime()) {
d1 = d2;
}
});
@@ -80,7 +80,7 @@ function configure(app, wares, ctx) {
if (!_isArray(activity)) {
activity = [activity];
- };
+ }
ctx.activity.create(activity, function(err, created) {
if (err) {
diff --git a/lib/api/alexa/index.js b/lib/api/alexa/index.js
index 65f477ad85d..2a5fd4ef6cd 100644
--- a/lib/api/alexa/index.js
+++ b/lib/api/alexa/index.js
@@ -1,159 +1,121 @@
'use strict';
var moment = require('moment');
-var _each = require('lodash/each');
function configure (app, wares, ctx, env) {
- var entries = ctx.entries;
- var express = require('express')
- , api = express.Router( );
- var translate = ctx.language.translate;
-
- // invoke common middleware
- api.use(wares.sendJSONStatus);
- // text body types get handled as raw buffer stream
- api.use(wares.bodyParser.raw());
- // json body types get handled as parsed json
- api.use(wares.bodyParser.json());
-
- ctx.plugins.eachEnabledPlugin(function each(plugin){
- if (plugin.alexa) {
- if (plugin.alexa.intentHandlers) {
- console.log(plugin.name + ' is Alexa enabled');
- _each(plugin.alexa.intentHandlers, function (route) {
- if (route) {
- ctx.alexa.configureIntentHandler(route.intent, route.intentHandler, route.routableSlot, route.slots);
- }
- });
- }
- if (plugin.alexa.rollupHandlers) {
- console.log(plugin.name + ' is Alexa rollup enabled');
- _each(plugin.alexa.rollupHandlers, function (route) {
- console.log('Route');
- console.log(route);
- if (route) {
- ctx.alexa.addToRollup(route.rollupGroup, route.rollupHandler, route.rollupName);
- }
- });
- }
- } else {
- console.log('Plugin ' + plugin.name + ' is not Alexa enabled');
- }
- });
-
- api.post('/alexa', ctx.authorization.isPermitted('api:*:read'), function (req, res, next) {
- console.log('Incoming request from Alexa');
- var locale = req.body.request.locale;
- if(locale){
- if(locale.length > 2) {
- locale = locale.substr(0, 2);
- }
- ctx.language.set(locale);
- moment.locale(locale);
- }
-
- switch (req.body.request.type) {
- case 'IntentRequest':
- onIntent(req.body.request.intent, function (title, response) {
- res.json(ctx.alexa.buildSpeechletResponse(title, response, '', 'true'));
- next( );
- });
- break;
- case 'LaunchRequest':
- onLaunch(req.body.request.intent, function (title, response) {
- res.json(ctx.alexa.buildSpeechletResponse(title, response, '', 'true'));
- next( );
- });
- break;
- case 'SessionEndedRequest':
- onSessionEnded(req.body.request.intent, function (alexaResponse) {
- res.json(alexaResponse);
- next( );
- });
- break;
- }
- });
-
- ctx.alexa.addToRollup('Status', function bgRollupHandler(slots, sbx, callback) {
- entries.list({count: 1}, function (err, records) {
- var direction;
- if (translate(records[0].direction)) {
- direction = translate(records[0].direction);
- } else {
- direction = records[0].direction;
- }
- var status = translate('alexaStatus', {
- params: [
- sbx.scaleMgdl(records[0].sgv),
- direction,
- moment(records[0].date).from(moment(sbx.time))
- ]
- });
- //var status = sbx.scaleMgdl(records[0].sgv) + direction + ' as of ' + moment(records[0].date).from(moment(sbx.time)) + '.';
- callback(null, {results: status, priority: -1});
- });
- // console.log('BG results called');
- // callback(null, 'BG results');
- }, 'BG Status');
+ var express = require('express')
+ , api = express.Router( );
+ var translate = ctx.language.translate;
+
+ // invoke common middleware
+ api.use(wares.sendJSONStatus);
+ // text body types get handled as raw buffer stream
+ api.use(wares.bodyParser.raw());
+ // json body types get handled as parsed json
+ api.use(wares.bodyParser.json());
+
+ ctx.virtAsstBase.setupVirtAsstHandlers(ctx.alexa);
+
+ api.post('/alexa', ctx.authorization.isPermitted('api:*:read'), function (req, res, next) {
+ console.log('Incoming request from Alexa');
+ var locale = req.body.request.locale;
+ if(locale){
+ if(locale.length > 2) {
+ locale = locale.substr(0, 2);
+ }
+ ctx.language.set(locale);
+ moment.locale(locale);
+ }
- ctx.alexa.configureIntentHandler('MetricNow', function ( callback, slots, sbx, locale) {
- entries.list({count: 1}, function(err, records) {
- var direction;
- if(translate(records[0].direction)){
- direction = translate(records[0].direction);
- } else {
- direction = records[0].direction;
- }
- var status = translate('alexaStatus', {
- params: [
- sbx.scaleMgdl(records[0].sgv),
- direction,
- moment(records[0].date).from(moment(sbx.time))]
- });
- //var status = sbx.scaleMgdl(records[0].sgv) + direction + ' as of ' + moment(records[0].date).from(moment(sbx.time));
- callback('Current blood glucose', status);
+ switch (req.body.request.type) {
+ case 'SessionEndedRequest':
+ onSessionEnded(function () {
+ res.json('');
+ next( );
});
- }, 'metric', ['bg', 'blood glucose', 'number']);
-
- ctx.alexa.configureIntentHandler('NSStatus', function(callback, slots, sbx, locale) {
- ctx.alexa.getRollup('Status', sbx, slots, locale, function (status) {
- callback('Full status', status);
+ break;
+ case 'LaunchRequest':
+ if (!req.body.request.intent) {
+ onLaunch(function () {
+ res.json(ctx.alexa.buildSpeechletResponse(
+ translate('virtAsstTitleLaunch'),
+ translate('virtAsstLaunch'),
+ translate('virtAsstLaunch'),
+ false
+ ));
+ next( );
+ });
+ break;
+ }
+ // if intent is set then fallback to IntentRequest
+ case 'IntentRequest': // eslint-disable-line no-fallthrough
+ onIntent(req.body.request.intent, function (title, response) {
+ res.json(ctx.alexa.buildSpeechletResponse(title, response, '', true));
+ next( );
});
- });
-
-
- function onLaunch() {
- console.log('Session launched');
+ break;
}
-
- function onIntent(intent, next) {
- console.log('Received intent request');
- console.log(JSON.stringify(intent));
- handleIntent(intent.name, intent.slots, next);
+ });
+
+ ctx.virtAsstBase.setupMutualIntents(ctx.alexa);
+
+ function onLaunch(next) {
+ console.log('Session launched');
+ next( );
+ }
+
+ function onIntent(intent, next) {
+ console.log('Received intent request');
+ console.log(JSON.stringify(intent));
+ handleIntent(intent.name, intent.slots, next);
+ }
+
+ function onSessionEnded(next) {
+ console.log('Session ended');
+ next( );
+ }
+
+ function handleIntent(intentName, slots, next) {
+ var metric;
+ if (slots) {
+ if (slots.metric
+ && slots.metric.resolutions
+ && slots.metric.resolutions.resolutionsPerAuthority
+ && slots.metric.resolutions.resolutionsPerAuthority.length
+ && slots.metric.resolutions.resolutionsPerAuthority[0].status
+ && slots.metric.resolutions.resolutionsPerAuthority[0].status.code
+ && slots.metric.resolutions.resolutionsPerAuthority[0].status.code == "ER_SUCCESS_MATCH"
+ && slots.metric.resolutions.resolutionsPerAuthority[0].values
+ && slots.metric.resolutions.resolutionsPerAuthority[0].values.length
+ && slots.metric.resolutions.resolutionsPerAuthority[0].values[0].value
+ && slots.metric.resolutions.resolutionsPerAuthority[0].values[0].value.name
+ ){
+ metric = slots.metric.resolutions.resolutionsPerAuthority[0].values[0].value.name;
+ } else {
+ next(translate('virtAsstUnknownIntentTitle'), translate('virtAsstUnknownIntentText'));
+ return;
+ }
}
- function onSessionEnded() {
- console.log('Session ended');
+ var handler = ctx.alexa.getIntentHandler(intentName, metric);
+ if (handler){
+ var sbx = initializeSandbox();
+ handler(next, slots, sbx);
+ return;
+ } else {
+ next(translate('virtAsstUnknownIntentTitle'), translate('virtAsstUnknownIntentText'));
+ return;
}
+ }
- function handleIntent(intentName, slots, next) {
- var handler = ctx.alexa.getIntentHandler(intentName, slots);
- if (handler){
- var sbx = initializeSandbox();
- handler(next, slots, sbx);
- } else {
- next('Unknown Intent', 'I\'m sorry I don\'t know what you\'re asking for');
- }
- }
-
- function initializeSandbox() {
- var sbx = require('../../sandbox')();
- sbx.serverInit(env, ctx);
- ctx.plugins.setProperties(sbx);
- return sbx;
- }
+ function initializeSandbox() {
+ var sbx = require('../../sandbox')();
+ sbx.serverInit(env, ctx);
+ ctx.plugins.setProperties(sbx);
+ return sbx;
+ }
- return api;
+ return api;
}
module.exports = configure;
diff --git a/lib/api/const.json b/lib/api/const.json
new file mode 100644
index 00000000000..cb1421d8520
--- /dev/null
+++ b/lib/api/const.json
@@ -0,0 +1,4 @@
+{
+ "API1_VERSION": "1.0.0",
+ "API2_VERSION": "2.0.0"
+}
\ No newline at end of file
diff --git a/lib/api/devicestatus/index.js b/lib/api/devicestatus/index.js
index 91702902fa3..25e226efef6 100644
--- a/lib/api/devicestatus/index.js
+++ b/lib/api/devicestatus/index.js
@@ -30,8 +30,7 @@ function configure (app, wares, ctx, env) {
// Support date de-normalization for older clients
if (env.settings.deNormalizeDates) {
results.forEach(function(e) {
- // eslint-disable-next-line no-prototype-builtins
- if (e.created_at && e.hasOwnProperty('utcOffset')) {
+ if (e.created_at && Object.prototype.hasOwnProperty.call(e, 'utcOffset')) {
const d = moment(e.created_at).utcOffset(e.utcOffset);
e.created_at = d.toISOString(true);
delete e.utcOffset;
@@ -106,7 +105,7 @@ function configure (app, wares, ctx, env) {
api.delete('/devicestatus/', ctx.authorization.isPermitted('api:devicestatus:delete'), delete_records);
}
- if (app.enabled('api') || true /*TODO: auth disabled for quick UI testing...*/ ) {
+ if (app.enabled('api')) {
config_authed(app, api, wares, ctx);
}
diff --git a/lib/api/entries/index.js b/lib/api/entries/index.js
index 0c8b8fc1ef7..5dd05b419fb 100644
--- a/lib/api/entries/index.js
+++ b/lib/api/entries/index.js
@@ -14,8 +14,7 @@ const expand = braces.expand;
const ID_PATTERN = /^[a-f\d]{24}$/;
function isId (value) {
- //TODO: why did we need tht length check?
- return value && ID_PATTERN.test(value) && value.length === 24;
+ return ID_PATTERN.test(value);
}
/**
@@ -74,8 +73,7 @@ function configure (app, wares, ctx, env) {
// Support date de-normalization for older clients
if (env.settings.deNormalizeDates) {
- // eslint-disable-next-line no-prototype-builtins
- if (data.dateString && data.hasOwnProperty('utcOffset')) {
+ if (data.dateString && Object.prototype.hasOwnProperty.call(data, 'utcOffset')) {
const d = moment(data.dateString).utcOffset(data.utcOffset);
data.dateString = d.toISOString(true);
delete data.utcOffset;
@@ -408,7 +406,7 @@ function configure (app, wares, ctx, env) {
// If "?count=" is present, use that number to decided how many to return.
if (!query.count) {
- query.count = 10;
+ query.count = consts.ENTRIES_DEFAULT_COUNT;
}
// bias towards entries, but allow expressing preference of storage layer
var storage = req.params.echo || 'entries';
@@ -434,7 +432,7 @@ function configure (app, wares, ctx, env) {
// If "?count=" is present, use that number to decided how many to return.
if (!query.count) {
- query.count = 10;
+ query.count = consts.ENTRIES_DEFAULT_COUNT;
}
// bias to entries, but allow expressing a preference
@@ -476,7 +474,7 @@ function configure (app, wares, ctx, env) {
}
var query = req.query;
if (!query.count) {
- query.count = 10
+ query.count = consts.ENTRIES_DEFAULT_COUNT;
}
// remove using the query
req.model.remove(query, function(err, stat) {
diff --git a/lib/api/googlehome/index.js b/lib/api/googlehome/index.js
new file mode 100644
index 00000000000..b44715b25eb
--- /dev/null
+++ b/lib/api/googlehome/index.js
@@ -0,0 +1,57 @@
+'use strict';
+
+var moment = require('moment');
+
+function configure (app, wares, ctx, env) {
+ var express = require('express')
+ , api = express.Router( );
+ var translate = ctx.language.translate;
+
+ // invoke common middleware
+ api.use(wares.sendJSONStatus);
+ // text body types get handled as raw buffer stream
+ api.use(wares.bodyParser.raw());
+ // json body types get handled as parsed json
+ api.use(wares.bodyParser.json());
+
+ ctx.virtAsstBase.setupVirtAsstHandlers(ctx.googleHome);
+
+ api.post('/googlehome', ctx.authorization.isPermitted('api:*:read'), function (req, res, next) {
+ console.log('Incoming request from Google Home');
+ var locale = req.body.queryResult.languageCode;
+ if(locale){
+ if(locale.length > 2) {
+ locale = locale.substr(0, 2);
+ }
+ ctx.language.set(locale);
+ moment.locale(locale);
+ }
+
+ var handler = ctx.googleHome.getIntentHandler(req.body.queryResult.intent.displayName, req.body.queryResult.parameters.metric);
+ if (handler){
+ var sbx = initializeSandbox();
+ handler(function (title, response) {
+ res.json(ctx.googleHome.buildSpeechletResponse(response, false));
+ next( );
+ return;
+ }, req.body.queryResult.parameters, sbx);
+ } else {
+ res.json(ctx.googleHome.buildSpeechletResponse(translate('virtAsstUnknownIntentText'), true));
+ next( );
+ return;
+ }
+ });
+
+ ctx.virtAsstBase.setupMutualIntents(ctx.googleHome);
+
+ function initializeSandbox() {
+ var sbx = require('../../sandbox')();
+ sbx.serverInit(env, ctx);
+ ctx.plugins.setProperties(sbx);
+ return sbx;
+ }
+
+ return api;
+}
+
+module.exports = configure;
diff --git a/lib/api/index.js b/lib/api/index.js
index 47a8a7bac3d..4b3d6a4fcb6 100644
--- a/lib/api/index.js
+++ b/lib/api/index.js
@@ -54,7 +54,7 @@ function create (env, ctx) {
app.all('/notifications*', require('./notifications-api')(app, wares, ctx));
app.all('/activity*', require('./activity/')(app, wares, ctx));
-
+
app.use('/', wares.sendJSONStatus, require('./verifyauth')(ctx));
app.all('/food*', require('./food/')(app, wares, ctx));
@@ -65,6 +65,10 @@ function create (env, ctx) {
app.all('/alexa*', require('./alexa/')(app, wares, ctx, env));
}
+ if (ctx.googleHome) {
+ app.all('/googlehome*', require('./googlehome/')(app, wares, ctx, env));
+ }
+
return app;
}
diff --git a/lib/api/notifications-api.js b/lib/api/notifications-api.js
index f9810256ad8..d08a03a7ead 100644
--- a/lib/api/notifications-api.js
+++ b/lib/api/notifications-api.js
@@ -1,5 +1,7 @@
'use strict';
+var consts = require('../constants');
+
function configure (app, wares, ctx) {
var express = require('express')
, api = express.Router( )
@@ -7,9 +9,9 @@ function configure (app, wares, ctx) {
api.post('/notifications/pushovercallback', function (req, res) {
if (ctx.pushnotify.pushoverAck(req.body)) {
- res.sendStatus(200);
+ res.sendStatus(consts.HTTP_OK);
} else {
- res.sendStatus(500);
+ res.sendStatus(consts.HTTP_INTERNAL_ERROR);
}
});
@@ -21,7 +23,7 @@ function configure (app, wares, ctx) {
var time = req.query.time && Number(req.query.time);
console.info('got api ack, level: ', level, ', time: ', time, ', query: ', req.query);
ctx.notifications.ack(level, group, time, true);
- res.sendStatus(200);
+ res.sendStatus(consts.HTTP_OK);
});
}
diff --git a/lib/api/notifications-v2.js b/lib/api/notifications-v2.js
new file mode 100644
index 00000000000..16eac1de975
--- /dev/null
+++ b/lib/api/notifications-v2.js
@@ -0,0 +1,23 @@
+'use strict';
+
+var consts = require('../constants');
+
+function configure (app, ctx) {
+ var express = require('express')
+ , api = express.Router( )
+ ;
+
+ api.post('/loop', ctx.authorization.isPermitted('notifications:loop:push'), function (req, res) {
+ ctx.loop.sendNotification(req.body, req.connection.remoteAddress, function (error) {
+ if (error) {
+ res.status(consts.HTTP_INTERNAL_ERROR).send(error)
+ console.log("error sending notification to Loop: ", error);
+ } else {
+ res.sendStatus(consts.HTTP_OK);
+ }
+ });
+ });
+
+ return api;
+}
+module.exports = configure;
diff --git a/lib/api/properties.js b/lib/api/properties.js
index 981f3c31328..7e9fd88ebab 100644
--- a/lib/api/properties.js
+++ b/lib/api/properties.js
@@ -42,6 +42,8 @@ function create (env, ctx) {
result = _pick(sbx.properties, selected);
}
+ result = env.settings.filteredSettings(result);
+
if (req.query && req.query.pretty) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(result, null, 2));
diff --git a/lib/api/root.js b/lib/api/root.js
new file mode 100644
index 00000000000..275660eeae8
--- /dev/null
+++ b/lib/api/root.js
@@ -0,0 +1,23 @@
+'use strict';
+
+function configure () {
+ const express = require('express')
+ , api = express.Router( )
+ , apiConst = require('./const')
+ , api3Const = require('../api3/const')
+ ;
+
+ api.get('/versions', function getVersion (req, res) {
+
+ const versions = [
+ { version: apiConst.API1_VERSION, url: '/api/v1' },
+ { version: apiConst.API2_VERSION, url: '/api/v2' },
+ { version: api3Const.API3_VERSION, url: '/api/v3' }
+ ];
+
+ res.json(versions);
+ });
+
+ return api;
+}
+module.exports = configure;
diff --git a/lib/api/status.js b/lib/api/status.js
index 9d18b524ec3..b630d629593 100644
--- a/lib/api/status.js
+++ b/lib/api/status.js
@@ -14,6 +14,12 @@ function configure (app, wares, env, ctx) {
// Status badge/text/json
api.get('/status', function (req, res) {
+
+ let extended = env.settings.filteredSettings(app.extendedClientSettings);
+ let settings = env.settings.filteredSettings(env.settings);
+
+ var authToken = req.query.token || req.query.secret || '';
+
var date = new Date();
var info = { status: 'ok'
, name: app.get('name')
@@ -23,9 +29,9 @@ function configure (app, wares, env, ctx) {
, apiEnabled: app.enabled('api')
, careportalEnabled: app.enabled('api') && env.settings.enable.indexOf('careportal') > -1
, boluscalcEnabled: app.enabled('api') && env.settings.enable.indexOf('boluscalc') > -1
- , settings: env.settings
- , extendedSettings: app.extendedClientSettings
- , authorized: ctx.authorization.authorize(req.query.token || '')
+ , settings: settings
+ , extendedSettings: extended
+ , authorized: ctx.authorization.authorize(authToken)
};
var badge = 'http://img.shields.io/badge/Nightscout-OK-green';
diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js
index 5d527fce6ac..b30c297143b 100644
--- a/lib/api/treatments/index.js
+++ b/lib/api/treatments/index.js
@@ -45,8 +45,7 @@ function configure (app, wares, ctx, env) {
t.carbs = Number(t.carbs);
t.insulin = Number(t.insulin);
- // eslint-disable-next-line no-prototype-builtins
- if (deNormalizeDates && t.hasOwnProperty('utcOffset')) {
+ if (deNormalizeDates && Object.prototype.hasOwnProperty.call(t, 'utcOffset')) {
const d = moment(t.created_at).utcOffset(t.utcOffset);
t.created_at = d.toISOString(true);
delete t.utcOffset;
@@ -54,10 +53,10 @@ function configure (app, wares, ctx, env) {
var d2 = null;
- if (t.hasOwnProperty('created_at')) {
+ if (Object.prototype.hasOwnProperty.call(t, 'created_at')) {
d2 = new Date(t.created_at);
} else {
- if (t.hasOwnProperty('timestamp')) {
+ if (Object.prototype.hasOwnProperty.call(t, 'timestamp')) {
d2 = new Date(t.timestamp);
}
}
@@ -91,7 +90,7 @@ function configure (app, wares, ctx, env) {
if (!_isArray(treatments)) {
treatments = [treatments];
- };
+ }
ctx.treatments.create(treatments, function(err, created) {
if (err) {
diff --git a/lib/api/verifyauth.js b/lib/api/verifyauth.js
index b3c67433d5b..a4eb2edf4ee 100644
--- a/lib/api/verifyauth.js
+++ b/lib/api/verifyauth.js
@@ -10,11 +10,15 @@ function configure (ctx) {
ctx.authorization.resolveWithRequest(req, function resolved (err, result) {
// this is used to see if req has api-secret equivalent authorization
- var authorized = !err &&
+ var authorized = !err &&
ctx.authorization.checkMultiple('*:*:create,update,delete', result.shiros) && //can write to everything
ctx.authorization.checkMultiple('admin:*:*:*', result.shiros); //full admin permissions too
+ var response = {
+ message: authorized ? 'OK' : 'UNAUTHORIZED',
+ rolefound: result.subject ? 'FOUND' : 'NOTFOUND'
+ }
- res.sendJSONStatus(res, consts.HTTP_OK, authorized ? 'OK' : 'UNAUTHORIZED');
+ res.sendJSONStatus(res, consts.HTTP_OK, response);
});
});
@@ -22,4 +26,3 @@ function configure (ctx) {
}
module.exports = configure;
-
diff --git a/lib/api3/const.json b/lib/api3/const.json
new file mode 100644
index 00000000000..fd874a1175a
--- /dev/null
+++ b/lib/api3/const.json
@@ -0,0 +1,55 @@
+{
+ "API3_VERSION": "3.0.0-alpha",
+ "API3_SECURITY_ENABLE": true,
+ "API3_TIME_SKEW_TOLERANCE": 5,
+ "API3_DEDUP_FALLBACK_ENABLED": true,
+ "API3_CREATED_AT_FALLBACK_ENABLED": true,
+ "API3_MAX_LIMIT": 1000,
+
+ "HTTP": {
+ "OK": 200,
+ "CREATED": 201,
+ "NO_CONTENT": 204,
+ "NOT_MODIFIED": 304,
+ "BAD_REQUEST": 400,
+ "UNAUTHORIZED": 401,
+ "FORBIDDEN": 403,
+ "NOT_FOUND": 404,
+ "NOT_ACCEPTABLE": 406,
+ "GONE": 410,
+ "PRECONDITION_FAILED": 412,
+ "UNPROCESSABLE_ENTITY": 422,
+ "INTERNAL_ERROR": 500
+ },
+
+ "MSG": {
+ "HTTP_400_BAD_LAST_MODIFIED": "Bad or missing Last-Modified header/parameter",
+ "HTTP_400_BAD_LIMIT": "Parameter limit out of tolerance",
+ "HTTP_400_BAD_REQUEST_BODY": "Bad or missing request body",
+ "HTTP_400_BAD_FIELD_IDENTIFIER": "Bad or missing identifier field",
+ "HTTP_400_BAD_FIELD_DATE": "Bad or missing date field",
+ "HTTP_400_BAD_FIELD_UTC": "Bad or missing utcOffset field",
+ "HTTP_400_BAD_FIELD_APP": "Bad or missing app field",
+ "HTTP_400_BAD_SKIP": "Parameter skip out of tolerance",
+ "HTTP_400_SORT_SORT_DESC": "Parameters sort and sort_desc cannot be combined",
+ "HTTP_400_UNSUPPORTED_FILTER_OPERATOR": "Unsupported filter operator {0}",
+ "HTTP_400_IMMUTABLE_FIELD": "Field {0} cannot be modified by the client",
+ "HTTP_401_BAD_DATE": "Bad Date header",
+ "HTTP_401_BAD_TOKEN": "Bad access token or JWT",
+ "HTTP_401_DATE_OUT_OF_TOLERANCE": "Date header out of tolerance",
+ "HTTP_401_MISSING_DATE": "Missing Date header",
+ "HTTP_401_MISSING_OR_BAD_TOKEN": "Missing or bad access token or JWT",
+ "HTTP_403_MISSING_PERMISSION": "Missing permission {0}",
+ "HTTP_403_NOT_USING_HTTPS": "Not using SSL/TLS",
+ "HTTP_406_UNSUPPORTED_FORMAT": "Unsupported output format requested",
+ "HTTP_422_READONLY_MODIFICATION": "Trying to modify read-only document",
+ "HTTP_500_INTERNAL_ERROR": "Internal Server Error",
+ "STORAGE_ERROR": "Database error",
+ "SOCKET_MISSING_OR_BAD_ACCESS_TOKEN": "Missing or bad accessToken",
+ "SOCKET_UNAUTHORIZED_TO_ANY": "Unauthorized to receive any collection"
+ },
+
+ "MIN_TIMESTAMP": 946684800000,
+ "MIN_UTC_OFFSET": -1440,
+ "MAX_UTC_OFFSET": 1440
+}
\ No newline at end of file
diff --git a/lib/api3/doc/formats.md b/lib/api3/doc/formats.md
new file mode 100644
index 00000000000..5a01a802f3e
--- /dev/null
+++ b/lib/api3/doc/formats.md
@@ -0,0 +1,88 @@
+# APIv3: Output formats
+
+### Choosing output format
+In APIv3, the standard content type is JSON for both HTTP request and HTTP response.
+However, in HTTP response, the response content type can be changed to XML or CSV
+for READ, SEARCH, and HISTORY operations.
+
+The response content type can be requested in one of the following ways:
+- add a file type extension to the URL, eg.
+ `/api/v3/entries.csv?...`
+ or `/api/v3/treatments/95e1a6e3-1146-5d6a-a3f1-41567cae0895.xml?...`
+- set `Accept` HTTP request header to `text/csv` or `application/xml`
+
+The server replies with `406 Not Acceptable` HTTP status in case of not supported content type.
+
+
+### JSON
+
+Default content type is JSON, output can look like this:
+
+```
+[
+ {
+ "type":"sgv",
+ "sgv":"171",
+ "dateString":"2014-07-19T02:44:15.000-07:00",
+ "date":1405763055000,
+ "device":"dexcom",
+ "direction":"Flat",
+ "identifier":"5c5a2404e0196f4d3d9a718a",
+ "srvModified":1405763055000,
+ "srvCreated":1405763055000
+ },
+ {
+ "type":"sgv",
+ "sgv":"176",
+ "dateString":"2014-07-19T03:09:15.000-07:00",
+ "date":1405764555000,
+ "device":"dexcom",
+ "direction":"Flat",
+ "identifier":"5c5a2404e0196f4d3d9a7187",
+ "srvModified":1405764555000,
+ "srvCreated":1405764555000
+ }
+]
+```
+
+### XML
+
+Sample output:
+
+```
+
+
+
+ sgv
+ 171
+ 2014-07-19T02:44:15.000-07:00
+ 1405763055000
+ dexcom
+ Flat
+ 5c5a2404e0196f4d3d9a718a
+ 1405763055000
+ 1405763055000
+
+
+ sgv
+ 176
+ 2014-07-19T03:09:15.000-07:00
+ 1405764555000
+ dexcom
+ Flat
+ 5c5a2404e0196f4d3d9a7187
+ 1405764555000
+ 1405764555000
+
+
+```
+
+### CSV
+
+Sample output:
+
+```
+type,sgv,dateString,date,device,direction,identifier,srvModified,srvCreated
+sgv,171,2014-07-19T02:44:15.000-07:00,1405763055000,dexcom,Flat,5c5a2404e0196f4d3d9a718a,1405763055000,1405763055000
+sgv,176,2014-07-19T03:09:15.000-07:00,1405764555000,dexcom,Flat,5c5a2404e0196f4d3d9a7187,1405764555000,1405764555000
+```
diff --git a/lib/api3/doc/security.md b/lib/api3/doc/security.md
new file mode 100644
index 00000000000..0fdf4c7d2aa
--- /dev/null
+++ b/lib/api3/doc/security.md
@@ -0,0 +1,48 @@
+# APIv3: Security
+
+### Enforcing HTTPS
+APIv3 is ment to run only under SSL version of HTTP protocol, which provides:
+- **message secrecy** - once the secure channel between client and server is closed the communication cannot be eavesdropped by any third party
+- **message consistency** - each request/response is protected against modification by any third party (any forgery would be detected)
+- **authenticity of identities** - once the client and server establish the secured channel, it is guaranteed that the identity of the client or server does not change during the whole session
+
+HTTPS (in use with APIv3) does not address the true identity of the client, but ensures the correct identity of the server. Furthermore, HTTPS does not prevent the resending of previously intercepted encrypted messages by an attacker.
+
+
+---
+### Authentication and authorization
+In APIv3, *API_SECRET* can no longer be used for authentication or authorization. Instead, a roles/permissions security model is used, which is managed in the *Admin tools* section of the web application.
+
+
+The identity of the client is represented by the *subject* to whom the access level is set by assigning security *roles*. One or more *permissions* can be assigned to each role. Permissions are used in an [Apache Shiro-like style](http://shiro.apache.org/permissions.html "Apache Shiro-like style").
+
+
+For each security *subject*, the system automatically generates an *access token* that is difficult to guess since it is derived from the secret *API_SECRET*. The *access token* must be included in every secured API operation to decode the client's identity and determine its authorization level. In this way, it is then possible to resolve whether the client has the permission required by a particular API operation.
+
+
+There are two ways to authorize API calls:
+- use `token` query parameter to pass the *access token*, eg. `token=testreadab-76eaff2418bfb7e0`
+- use so-called [JSON Web Tokens](https://jwt.io "JSON Web Tokens")
+ - at first let the `/api/v2/authorization/request` generates you a particular JWT, eg. `GET https://nsapiv3.herokuapp.com/api/v2/authorization/request/testreadab-76eaff2418bfb7e0`
+ - then, to each secure API operation attach a JWT token in the HTTP header, eg. `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NUb2tlbiI6InRlc3RyZWFkYWItNzZlYWZmMjQxOGJmYjdlMCIsImlhdCI6MTU2NTAzOTczMSwiZXhwIjoxNTY1MDQzMzMxfQ.Y-OFtFJ-gZNJcnZfm9r4S7085Z7YKVPiaQxuMMnraVk` (until the JWT expires)
+
+
+
+---
+### Client timestamps
+As previously mentioned, a potential attacker cannot decrypt the captured messages, but he can send them back to the client/server at any later time. APIv3 is partially preventing this by the temporal validity of each secured API call.
+
+
+The client must include his current timestamp to each call so that the server can compare it against its clock. If the timestamp difference is not within the limit, the request is considered invalid. The tolerance limit is set in minutes in the `API3_TIME_SKEW_TOLERANCE` environment variable.
+
+There are two ways to include the client timestamp to the call:
+- use `now` query parameter with UNIX epoch millisecond timestamp, eg. `now=1565041446908`
+- add HTTP `Date` header to the request, eg. `Date: Sun, 12 May 2019 07:49:58 GMT`
+
+
+The client can check each server response in the same way, because each response contains a server timestamp in the HTTP *Date* header as well.
+
+
+---
+APIv3 security is enabled by default, but it can be completely disabled for development and debugging purposes by setting the web environment variable `API3_SECURITY_ENABLE=false`.
+This setting is hazardous and it is strongly discouraged to be used for production purposes!
diff --git a/lib/api3/doc/socket.md b/lib/api3/doc/socket.md
new file mode 100644
index 00000000000..802a85e0235
--- /dev/null
+++ b/lib/api3/doc/socket.md
@@ -0,0 +1,142 @@
+# APIv3: Socket.IO storage modifications channel
+
+APIv3 has the ability to broadcast events about all created, edited and deleted documents, using Socket.IO library.
+
+This provides a real-time data exchange experience in combination with API REST operations.
+
+### Complete sample client code
+```html
+
+
+
+
+
+
+
+ APIv3 Socket.IO sample
+
+
+
+
+
+
+
+
+
+
+```
+
+**Important notice: Only changes made via APIv3 are being broadcasted. All direct database or APIv1 modifications are not included by this channel.**
+
+### Subscription (authorization)
+The client must first subscribe to the channel that is exposed at `storage` namespace, ie the `/storage` subadress of the base Nightscout's web address (without `/api/v3` subaddress).
+```javascript
+const socket = io('https://nsapiv3.herokuapp.com/storage');
+```
+
+
+Subscription is requested by emitting `subscribe` event to the server, while including document with parameters:
+* `accessToken`: required valid accessToken of the security subject, which has been prepared in *Admin Tools* of Nightscout.
+* `collections`: optional array of collections which the client wants to subscribe to, by default all collections are requested)
+
+```javascript
+socket.on('connect', function () {
+ socket.emit('subscribe', {
+ accessToken: 'testadmin-ad3b1f9d7b3f59d5',
+ collections: [ 'entries', 'treatments' ]
+ },
+```
+
+
+On the server, the subject is first identified and authenticated (by the accessToken) and then a verification takes place, if the subject has read access to each required collection.
+
+An exception is the `settings` collection for which `api:settings:admin` permission is required, for all other collections `api::read` permission is required.
+
+
+If the authentication was successful and the client has read access to at least one collection, `success` = `true` is set in the response object and the field `collections` contains an array of collections which were actually subscribed (granted).
+In other case `success` = `false` is set in the response object and the field `message` contains an error message.
+
+```javascript
+function (data) {
+ if (data.success) {
+ console.log('subscribed for collections', data.collections);
+ }
+ else {
+ console.error(data.message);
+ }
+ });
+});
+```
+
+### Receiving events
+After the successful subscription the client can start listening to `create`, `update` and/or `delete` events of the socket.
+
+
+##### create
+`create` event fires each time a new document is inserted into the storage, regardless of whether it was CREATE or UPDATE operation of APIv3 (both of these operations are upserting/deduplicating, so they are "insert capable"). If the document already existed in the storage, the `update` event would be fired instead.
+
+The received object contains:
+* `colName` field with the name of the affected collection
+* the inserted document in `doc` field
+
+```javascript
+socket.on('create', function (data) {
+ console.log(`${data.colName}:created document`, data.doc);
+});
+```
+
+
+##### update
+`update` event fires each time an existing document is modified in the storage, regardless of whether it was CREATE, UPDATE or PATCH operation of APIv3 (all of these operations are "update capable"). If the document did not yet exist in the storage, the `create` event would be fired instead.
+
+The received object contains:
+* `colName` field with the name of the affected collection
+* the new version of the modified document in `doc` field
+
+```javascript
+socket.on('update', function (data) {
+ console.log(`${data.colName}:updated document`, data.doc);
+});
+```
+
+
+##### delete
+`delete` event fires each time an existing document is deleted in the storage, regardless of whether it was "soft" (marking as invalid) or permanent deleting.
+
+The received object contains:
+* `colName` field with the name of the affected collection
+* the identifier of the deleted document in the `identifier` field
+
+```javascript
+socket.on('delete', function (data) {
+ console.log(`${data.colName}:deleted document with identifier`, data.identifier);
+});
+```
\ No newline at end of file
diff --git a/lib/api3/doc/tutorial.md b/lib/api3/doc/tutorial.md
new file mode 100644
index 00000000000..3d8c656dfbd
--- /dev/null
+++ b/lib/api3/doc/tutorial.md
@@ -0,0 +1,329 @@
+# APIv3: Basics tutorial
+
+Nightscout API v3 is a component of [cgm-remote-monitor](https://github.com/nightscout/cgm-remote-monitor) project.
+It aims to provide lightweight, secured and HTTP REST compliant interface for your T1D treatment data exchange.
+
+There is a list of REST operations that the API v3 offers (inside `/api/v3` relative URL namespace), we will briefly introduce them in this file.
+
+Each NS instance with API v3 contains self-included OpenAPI specification at [/api/v3/swagger-ui-dist/](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/) relative URL.
+
+
+---
+### VERSION
+
+[VERSION](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/other/get_version) operation gets you basic information about software packages versions.
+It is public (there is no need to add authorization parameters/headers).
+
+Sample GET `/version` client code (to get actual versions):
+```javascript
+const request = require('request');
+
+request('https://nsapiv3.herokuapp.com/api/v3/version',
+ (error, response, body) => console.log(body));
+```
+Sample result:
+```javascript
+{
+ "version":"0.12.2",
+ "apiVersion":"3.0.0-alpha",
+ "srvDate":1564386001772,
+ "storage":{
+ "storage":"mongodb",
+ "version":"3.6.12"
+ }
+}
+```
+
+
+---
+### STATUS
+
+[STATUS](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/other/get_status) operation gets you basic information about software packages versions.
+It is public (there is no need to add authorization parameters/headers).
+
+Sample GET `/status` client code (to get my actual permissions):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+
+request(`https://nsapiv3.herokuapp.com/api/v3/status?${auth}`,
+ (error, response, body) => console.log(body));
+```
+Sample result:
+```javascript
+{
+ "version":"0.12.2",
+ "apiVersion":"3.0.0-alpha",
+ "srvDate":1564391740738,
+ "storage":{
+ "storage":"mongodb",
+ "version":"3.6.12"
+ },
+ "apiPermissions":{
+ "devicestatus":"crud",
+ "entries":"crud",
+ "food":"crud",
+ "profile":"crud",
+ "settings":"crud",
+ "treatments":"crud"
+ }
+}
+```
+`"crud"` represents create + read + update + delete permissions for the collection.
+
+
+---
+### SEARCH
+
+[SEARCH](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/SEARCH) operation filters, sorts, paginates and projects documents from the collection.
+
+Sample GET `/entries` client code (to retrieve last 3 BG values):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+
+request(`https://nsapiv3.herokuapp.com/api/v3/entries?${auth}&sort$desc=date&limit=3&fields=dateString,sgv,direction`,
+ (error, response, body) => console.log(body));
+```
+Sample result:
+```
+[
+ {
+ "dateString":"2019-07-30T02:24:50.434+0200",
+ "sgv":115,
+ "direction":"FortyFiveDown"
+ },
+ {
+ "dateString":"2019-07-30T02:19:50.374+0200",
+ "sgv":121,
+ "direction":"FortyFiveDown"
+ },
+ {
+ "dateString":"2019-07-30T02:14:50.450+0200",
+ "sgv":129,
+ "direction":"FortyFiveDown"
+ }
+]
+```
+
+
+---
+### CREATE
+
+[CREATE](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/generic/post__collection_) operation inserts a new document into the collection.
+
+Sample POST `/treatments` client code:
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const doc = {
+ date: 1564591511232, // (new Date()).getTime(),
+ app: 'AndroidAPS',
+ device: 'Samsung XCover 4-861536030196001',
+ eventType: 'Correction Bolus',
+ insulin: 0.3
+};
+request({
+ method: 'post',
+ body: doc,
+ json: true,
+ url: `https://nsapiv3.herokuapp.com/api/v3/treatments?${auth}`
+ },
+ (error, response, body) => console.log(response.headers.location));
+```
+Sample result:
+```
+/api/v3/treatments/95e1a6e3-1146-5d6a-a3f1-41567cae0895
+```
+
+
+---
+### READ
+
+[READ](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/generic/get__collection___identifier_) operation retrieves you a single document from the collection by its identifier.
+
+Sample GET `/treatments/{identifier}` client code:
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
+
+request(`https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`,
+ (error, response, body) => console.log(body));
+```
+Sample result:
+```
+{
+ "date":1564591511232,
+ "app":"AndroidAPS",
+ "device":"Samsung XCover 4-861536030196001",
+ "eventType":"Correction Bolus",
+ "insulin":0.3,
+ "identifier":"95e1a6e3-1146-5d6a-a3f1-41567cae0895",
+ "utcOffset":0,
+ "created_at":"2019-07-31T16:45:11.232Z",
+ "srvModified":1564591627732,
+ "srvCreated":1564591511711,
+ "subject":"test-admin"
+}
+```
+
+
+---
+### LAST MODIFIED
+
+[LAST MODIFIED](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/other/LAST-MODIFIED) operation finds the date of last modification for each collection.
+
+Sample GET `/lastModified` client code (to get latest modification dates):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+
+request(`https://nsapiv3.herokuapp.com/api/v3/lastModified?${auth}`,
+ (error, response, body) => console.log(body));
+```
+Sample result:
+```javascript
+{
+ "srvDate":1564591783202,
+ "collections":{
+ "devicestatus":1564591490074,
+ "entries":1564591486801,
+ "profile":1548524042744,
+ "treatments":1564591627732
+ }
+}
+```
+
+
+---
+### UPDATE
+
+[UPDATE](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/put__collection___identifier_) operation updates existing document in the collection.
+
+Sample PUT `/treatments/{identifier}` client code (to update `insulin` from 0.3 to 0.4):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
+const doc = {
+ date: 1564591511232,
+ app: 'AndroidAPS',
+ device: 'Samsung XCover 4-861536030196001',
+ eventType: 'Correction Bolus',
+ insulin: 0.4
+};
+
+request({
+ method: 'put',
+ body: doc,
+ json: true,
+ url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
+ },
+ (error, response, body) => console.log(response.statusCode));
+```
+Sample result:
+```
+204
+```
+
+
+---
+### PATCH
+
+[PATCH](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/patch__collection___identifier_) operation partially updates existing document in the collection.
+
+Sample PATCH `/treatments/{identifier}` client code (to update `insulin` from 0.4 to 0.5):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
+const doc = {
+ insulin: 0.5
+};
+
+request({
+ method: 'patch',
+ body: doc,
+ json: true,
+ url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
+ },
+ (error, response, body) => console.log(response.statusCode));
+```
+Sample result:
+```
+204
+```
+
+
+---
+### DELETE
+
+[DELETE](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/delete__collection___identifier_) operation deletes existing document from the collection.
+
+Sample DELETE `/treatments/{identifier}` client code (to update `insulin` from 0.4 to 0.5):
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
+
+request({
+ method: 'delete',
+ url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
+ },
+ (error, response, body) => console.log(response.statusCode));
+```
+Sample result:
+```
+204
+```
+
+
+---
+### HISTORY
+
+[HISTORY](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/HISTORY2) operation queries all changes since the timestamp.
+
+Sample HISTORY `/treatments/history/{lastModified}` client code:
+```javascript
+const request = require('request');
+const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
+const lastModified = 1564521267421;
+
+request(`https://nsapiv3.herokuapp.com/api/v3/treatments/history/${lastModified}?${auth}`,
+ (error, response, body) => console.log(response.body));
+```
+Sample result:
+```
+[
+ {
+ "date":1564521267421,
+ "app":"AndroidAPS",
+ "device":"Samsung XCover 4-861536030196001",
+ "eventType":"Correction Bolus",
+ "insulin":0.5,
+ "utcOffset":0,
+ "created_at":"2019-07-30T21:14:27.421Z",
+ "identifier":"95e1a6e3-1146-5d6a-a3f1-41567cae0895",
+ "srvModified":1564592440416,
+ "srvCreated":1564592334853,
+ "subject":"test-admin",
+ "modifiedBy":"test-admin",
+ "isValid":false
+ },
+ {
+ "date":1564592545299,
+ "app":"AndroidAPS",
+ "device":"Samsung XCover 4-861536030196001",
+ "eventType":"Snack Bolus",
+ "carbs":10,
+ "identifier":"267c43c2-f629-5191-a542-4f410c69e486",
+ "utcOffset":0,
+ "created_at":"2019-07-31T17:02:25.299Z",
+ "srvModified":1564592545781,
+ "srvCreated":1564592545781,
+ "subject":"test-admin"
+ }
+]
+```
+Notice the `"isValid":false` field marking the deletion of the document.
\ No newline at end of file
diff --git a/lib/api3/generic/collection.js b/lib/api3/generic/collection.js
new file mode 100644
index 00000000000..0a1a29b3915
--- /dev/null
+++ b/lib/api3/generic/collection.js
@@ -0,0 +1,193 @@
+'use strict';
+
+const apiConst = require('../const.json')
+ , _ = require('lodash')
+ , dateTools = require('../shared/dateTools')
+ , opTools = require('../shared/operationTools')
+ , stringTools = require('../shared/stringTools')
+ , CollectionStorage = require('../storage/mongoCollection')
+ , searchOperation = require('./search/operation')
+ , createOperation = require('./create/operation')
+ , readOperation = require('./read/operation')
+ , updateOperation = require('./update/operation')
+ , patchOperation = require('./patch/operation')
+ , deleteOperation = require('./delete/operation')
+ , historyOperation = require('./history/operation')
+ ;
+
+/**
+ * Generic collection (abstraction over each collection specifics)
+ * @param {string} colName - name of the collection inside the storage system
+ * @param {function} fallbackGetDate - function that tries to create srvModified virtually from other fields of document
+ * @param {Array} dedupFallbackFields - fields that all need to be matched to identify document via fallback deduplication
+ * @param {function} fallbackHistoryFilter - function that creates storage filter for all newer records (than the timestamp from first function parameter)
+ */
+function Collection ({ ctx, env, app, colName, storageColName, fallbackGetDate, dedupFallbackFields,
+ fallbackDateField }) {
+
+ const self = this;
+
+ self.colName = colName;
+ self.fallbackGetDate = fallbackGetDate;
+ self.dedupFallbackFields = app.get('API3_DEDUP_FALLBACK_ENABLED') ? dedupFallbackFields : [];
+ self.autoPruneDays = app.setENVTruthy('API3_AUTOPRUNE_' + colName.toUpperCase());
+ self.nextAutoPrune = new Date();
+ self.storage = new CollectionStorage(ctx, env, storageColName);
+ self.fallbackDateField = fallbackDateField;
+
+ self.mapRoutes = function mapRoutes () {
+ const prefix = '/' + colName
+ , prefixId = prefix + '/:identifier'
+ , prefixHistory = prefix + '/history'
+ ;
+
+
+ // GET /{collection}
+ app.get(prefix, searchOperation(ctx, env, app, self));
+
+ // POST /{collection}
+ app.post(prefix, createOperation(ctx, env, app, self));
+
+ // GET /{collection}/history
+ app.get(prefixHistory, historyOperation(ctx, env, app, self));
+
+ // GET /{collection}/history
+ app.get(prefixHistory + '/:lastModified', historyOperation(ctx, env, app, self));
+
+ // GET /{collection}/{identifier}
+ app.get(prefixId, readOperation(ctx, env, app, self));
+
+ // PUT /{collection}/{identifier}
+ app.put(prefixId, updateOperation(ctx, env, app, self));
+
+ // PATCH /{collection}/{identifier}
+ app.patch(prefixId, patchOperation(ctx, env, app, self));
+
+ // DELETE /{collection}/{identifier}
+ app.delete(prefixId, deleteOperation(ctx, env, app, self));
+ };
+
+
+ /**
+ * Parse limit (max document count) from query string
+ */
+ self.parseLimit = function parseLimit (req, res) {
+ const maxLimit = app.get('API3_MAX_LIMIT');
+ let limit = maxLimit;
+
+ if (req.query.limit) {
+ if (!isNaN(req.query.limit) && req.query.limit > 0 && req.query.limit <= maxLimit) {
+ limit = parseInt(req.query.limit);
+ }
+ else {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LIMIT);
+ return null;
+ }
+ }
+
+ return limit;
+ };
+
+
+
+ /**
+ * Fetch modified date from document (with possible fallback and back-fill to srvModified/srvCreated)
+ * @param {Object} doc - document loaded from database
+ */
+ self.resolveDates = function resolveDates (doc) {
+ let modifiedDate;
+ try {
+ if (doc.srvModified) {
+ modifiedDate = new Date(doc.srvModified);
+ }
+ else {
+ if (typeof (self.fallbackGetDate) === 'function') {
+ modifiedDate = self.fallbackGetDate(doc);
+ if (modifiedDate) {
+ doc.srvModified = modifiedDate.getTime();
+ }
+ }
+ }
+
+ if (doc.srvModified && !doc.srvCreated) {
+ doc.srvCreated = modifiedDate.getTime();
+ }
+ }
+ catch (error) {
+ console.warn(error);
+ }
+ return modifiedDate;
+ };
+
+
+ /**
+ * Deletes old documents from the collection if enabled (for this collection)
+ * in the background (asynchronously)
+ * */
+ self.autoPrune = function autoPrune () {
+
+ if (!stringTools.isNumberInString(self.autoPruneDays))
+ return;
+
+ const autoPruneDays = parseFloat(self.autoPruneDays);
+ if (autoPruneDays <= 0)
+ return;
+
+ if (new Date() > self.nextAutoPrune) {
+
+ const deleteBefore = new Date(new Date().getTime() - (autoPruneDays * 24 * 3600 * 1000));
+
+ const filter = [
+ { field: 'srvCreated', operator: 'lt', value: deleteBefore.getTime() },
+ { field: 'created_at', operator: 'lt', value: deleteBefore.toISOString() },
+ { field: 'date', operator: 'lt', value: deleteBefore.getTime() }
+ ];
+
+ // let's autoprune asynchronously (we won't wait for the result)
+ self.storage.deleteManyOr(filter, function deleteDone (err, result) {
+ if (err || !result) {
+ console.error(err);
+ }
+
+ if (result.deleted) {
+ console.info('Auto-pruned ' + result.deleted + ' documents from ' + self.colName + ' collection ');
+ }
+ });
+
+ self.nextAutoPrune = new Date(new Date().getTime() + (3600 * 1000));
+ }
+ };
+
+
+ /**
+ * Parse date and utcOffset + optional created_at fallback
+ * @param {Object} doc
+ */
+ self.parseDate = function parseDate (doc) {
+ if (!_.isEmpty(doc)) {
+
+ let values = app.get('API3_CREATED_AT_FALLBACK_ENABLED')
+ ? [doc.date, doc.created_at]
+ : [doc.date];
+
+ let m = dateTools.parseToMoment(values);
+ if (m && m.isValid()) {
+ doc.date = m.valueOf();
+
+ if (typeof doc.utcOffset === 'undefined') {
+ doc.utcOffset = m.utcOffset();
+ }
+
+ if (app.get('API3_CREATED_AT_FALLBACK_ENABLED')) {
+ doc.created_at = m.toISOString();
+ }
+ else {
+ if (doc.created_at)
+ delete doc.created_at;
+ }
+ }
+ }
+ }
+}
+
+module.exports = Collection;
\ No newline at end of file
diff --git a/lib/api3/generic/create/insert.js b/lib/api3/generic/create/insert.js
new file mode 100644
index 00000000000..b643818569a
--- /dev/null
+++ b/lib/api3/generic/create/insert.js
@@ -0,0 +1,46 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , validate = require('./validate.js')
+ , path = require('path')
+ ;
+
+/**
+ * Insert new document into the collection
+ * @param {Object} opCtx
+ * @param {Object} doc
+ */
+async function insert (opCtx, doc) {
+
+ const { ctx, auth, col, req, res } = opCtx;
+
+ await security.demandPermission(opCtx, `api:${col.colName}:create`);
+
+ if (validate(opCtx, doc) !== true)
+ return;
+
+ const now = new Date;
+ doc.srvModified = now.getTime();
+ doc.srvCreated = doc.srvModified;
+
+ if (auth && auth.subject && auth.subject.name) {
+ doc.subject = auth.subject.name;
+ }
+
+ const identifier = await col.storage.insertOne(doc);
+
+ if (!identifier)
+ throw new Error('empty identifier');
+
+ res.setHeader('Last-Modified', now.toUTCString());
+ res.setHeader('Location', path.posix.join(req.baseUrl, req.path, identifier));
+ res.status(apiConst.HTTP.CREATED).send({ });
+
+ ctx.bus.emit('storage-socket-create', { colName: col.colName, doc });
+ col.autoPrune();
+ ctx.bus.emit('data-received');
+}
+
+
+module.exports = insert;
\ No newline at end of file
diff --git a/lib/api3/generic/create/operation.js b/lib/api3/generic/create/operation.js
new file mode 100644
index 00000000000..39986a87ebd
--- /dev/null
+++ b/lib/api3/generic/create/operation.js
@@ -0,0 +1,63 @@
+'use strict';
+
+const _ = require('lodash')
+ , apiConst = require('../../const.json')
+ , security = require('../../security')
+ , insert = require('./insert')
+ , replace = require('../update/replace')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+
+/**
+ * CREATE: Inserts a new document into the collection
+ */
+async function create (opCtx) {
+
+ const { col, req, res } = opCtx;
+ const doc = req.body;
+
+ if (_.isEmpty(doc)) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
+ }
+
+ col.parseDate(doc);
+ opTools.resolveIdentifier(doc);
+ const identifyingFilter = col.storage.identifyingFilter(doc.identifier, doc, col.dedupFallbackFields);
+
+ const result = await col.storage.findOneFilter(identifyingFilter, { });
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (result.length > 0) {
+ const storageDoc = result[0];
+ await replace(opCtx, doc, storageDoc, { isDeduplication: true });
+ }
+ else {
+ await insert(opCtx, doc);
+ }
+}
+
+
+function createOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await create(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = createOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/create/validate.js b/lib/api3/generic/create/validate.js
new file mode 100644
index 00000000000..e978a3955e5
--- /dev/null
+++ b/lib/api3/generic/create/validate.js
@@ -0,0 +1,26 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , stringTools = require('../../shared/stringTools')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+
+/**
+ * Validation of document to create
+ * @param {Object} opCtx
+ * @param {Object} doc
+ * @returns string with error message if validation fails, true in case of success
+ */
+function validate (opCtx, doc) {
+
+ const { res } = opCtx;
+
+ if (typeof(doc.identifier) !== 'string' || stringTools.isNullOrWhitespace(doc.identifier)) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_IDENTIFIER);
+ }
+
+ return opTools.validateCommon(doc, res);
+}
+
+module.exports = validate;
\ No newline at end of file
diff --git a/lib/api3/generic/delete/operation.js b/lib/api3/generic/delete/operation.js
new file mode 100644
index 00000000000..8f9463f3ef9
--- /dev/null
+++ b/lib/api3/generic/delete/operation.js
@@ -0,0 +1,122 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+/**
+ * DELETE: Deletes a document from the collection
+ */
+async function doDelete (opCtx) {
+
+ const { col, req } = opCtx;
+
+ await security.demandPermission(opCtx, `api:${col.colName}:delete`);
+
+ if (await validateDelete(opCtx) !== true)
+ return;
+
+ if (req.query.permanent && req.query.permanent === "true") {
+ await deletePermanently(opCtx);
+ } else {
+ await markAsDeleted(opCtx);
+ }
+}
+
+
+async function validateDelete (opCtx) {
+
+ const { col, req, res } = opCtx;
+
+ const identifier = req.params.identifier;
+ const result = await col.storage.findOne(identifier);
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (result.length === 0) {
+ return res.status(apiConst.HTTP.NOT_FOUND).end();
+ }
+ else {
+ const storageDoc = result[0];
+
+ if (storageDoc.isReadOnly === true || storageDoc.readOnly === true || storageDoc.readonly === true) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.UNPROCESSABLE_ENTITY,
+ apiConst.MSG.HTTP_422_READONLY_MODIFICATION);
+ }
+ }
+
+ return true;
+}
+
+
+async function deletePermanently (opCtx) {
+
+ const { ctx, col, req, res } = opCtx;
+
+ const identifier = req.params.identifier;
+ const result = await col.storage.deleteOne(identifier);
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (!result.deleted) {
+ return res.status(apiConst.HTTP.NOT_FOUND).end();
+ }
+
+ col.autoPrune();
+ ctx.bus.emit('storage-socket-delete', { colName: col.colName, identifier });
+ ctx.bus.emit('data-received');
+ return res.status(apiConst.HTTP.NO_CONTENT).end();
+}
+
+
+async function markAsDeleted (opCtx) {
+
+ const { ctx, col, req, res, auth } = opCtx;
+
+ const identifier = req.params.identifier;
+ const setFields = { 'isValid': false, 'srvModified': (new Date).getTime() };
+
+ if (auth && auth.subject && auth.subject.name) {
+ setFields.modifiedBy = auth.subject.name;
+ }
+
+ const result = await col.storage.updateOne(identifier, setFields);
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (!result.updated) {
+ return res.status(apiConst.HTTP.NOT_FOUND).end();
+ }
+
+ ctx.bus.emit('storage-socket-delete', { colName: col.colName, identifier });
+ col.autoPrune();
+ ctx.bus.emit('data-received');
+ return res.status(apiConst.HTTP.NO_CONTENT).end();
+}
+
+
+function deleteOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await doDelete(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = deleteOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/history/operation.js b/lib/api3/generic/history/operation.js
new file mode 100644
index 00000000000..5151c8b2749
--- /dev/null
+++ b/lib/api3/generic/history/operation.js
@@ -0,0 +1,153 @@
+'use strict';
+
+const dateTools = require('../../shared/dateTools')
+ , renderer = require('../../shared/renderer')
+ , apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ , FieldsProjector = require('../../shared/fieldsProjector')
+ , _ = require('lodash')
+ ;
+
+/**
+ * HISTORY: Retrieves incremental changes since timestamp
+ */
+async function history (opCtx, fieldsProjector) {
+
+ const { req, res, col } = opCtx;
+
+ let filter = parseFilter(opCtx)
+ , limit = col.parseLimit(req, res)
+ , projection = fieldsProjector.storageProjection()
+ , sort = prepareSort()
+ , skip = 0
+ , onlyValid = false
+ , logicalOperator = 'or'
+ ;
+
+ if (filter !== null && limit !== null && projection !== null) {
+
+ const result = await col.storage.findMany(filter
+ , sort
+ , limit
+ , skip
+ , projection
+ , onlyValid
+ , logicalOperator);
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (result.length === 0) {
+ return res.status(apiConst.HTTP.NO_CONTENT).end();
+ }
+
+ _.each(result, col.resolveDates);
+
+ const srvModifiedValues = _.map(result, function mapSrvModified (item) {
+ return item.srvModified;
+ })
+ , maxSrvModified = _.max(srvModifiedValues);
+
+ res.setHeader('Last-Modified', (new Date(maxSrvModified)).toUTCString());
+ res.setHeader('ETag', 'W/"' + maxSrvModified + '"');
+
+ _.each(result, fieldsProjector.applyProjection);
+
+ res.status(apiConst.HTTP.OK);
+ renderer.render(res, result);
+ }
+}
+
+
+/**
+ * Parse history filtering criteria from Last-Modified header
+ */
+function parseFilter (opCtx) {
+
+ const { req, res } = opCtx;
+
+ let lastModified = null
+ , lastModifiedParam = req.params.lastModified
+ , operator = null;
+
+ if (lastModifiedParam) {
+
+ // using param in URL as a source of timestamp
+ const m = dateTools.parseToMoment(lastModifiedParam);
+
+ if (m === null || !m.isValid()) {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
+ return null;
+ }
+
+ lastModified = m.toDate();
+ operator = 'gt';
+ }
+ else {
+ // using request HTTP header as a source of timestamp
+ const lastModifiedHeader = req.get('Last-Modified');
+ if (!lastModifiedHeader) {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
+ return null;
+ }
+
+ try {
+ lastModified = dateTools.floorSeconds(new Date(lastModifiedHeader));
+ } catch (err) {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
+ return null;
+ }
+ operator = 'gte';
+ }
+
+ return [
+ { field: 'srvModified', operator: operator, value: lastModified.getTime() },
+ { field: 'created_at', operator: operator, value: lastModified.toISOString() },
+ { field: 'date', operator: operator, value: lastModified.getTime() }
+ ];
+}
+
+
+
+/**
+ * Prepare sorting for storage query
+ */
+function prepareSort () {
+ return {
+ srvModified: 1,
+ created_at: 1,
+ date: 1
+ };
+}
+
+
+function historyOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ if (col.colName === 'settings') {
+ await security.demandPermission(opCtx, `api:${col.colName}:admin`);
+ } else {
+ await security.demandPermission(opCtx, `api:${col.colName}:read`);
+ }
+
+ const fieldsProjector = new FieldsProjector(req.query.fields);
+
+ await history(opCtx, fieldsProjector);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = historyOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/patch/operation.js b/lib/api3/generic/patch/operation.js
new file mode 100644
index 00000000000..d7bb5fc2b4d
--- /dev/null
+++ b/lib/api3/generic/patch/operation.js
@@ -0,0 +1,118 @@
+'use strict';
+
+const _ = require('lodash')
+ , apiConst = require('../../const.json')
+ , security = require('../../security')
+ , validate = require('./validate.js')
+ , opTools = require('../../shared/operationTools')
+ , dateTools = require('../../shared/dateTools')
+ , FieldsProjector = require('../../shared/fieldsProjector')
+ ;
+
+/**
+ * PATCH: Partially updates document in the collection
+ */
+async function patch (opCtx) {
+
+ const { req, res, col } = opCtx;
+ const doc = req.body;
+
+ if (_.isEmpty(doc)) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
+ }
+
+ await security.demandPermission(opCtx, `api:${col.colName}:update`);
+
+ col.parseDate(doc);
+ const identifier = req.params.identifier
+ , identifyingFilter = col.storage.identifyingFilter(identifier);
+
+ const result = await col.storage.findOneFilter(identifyingFilter, { });
+
+ if (!result)
+ throw new Error('result empty');
+
+ if (result.length > 0) {
+
+ const storageDoc = result[0];
+ if (storageDoc.isValid === false) {
+ return res.status(apiConst.HTTP.GONE).end();
+ }
+
+ const modifiedDate = col.resolveDates(storageDoc)
+ , ifUnmodifiedSince = req.get('If-Unmodified-Since');
+
+ if (ifUnmodifiedSince
+ && dateTools.floorSeconds(modifiedDate) > dateTools.floorSeconds(new Date(ifUnmodifiedSince))) {
+ return res.status(apiConst.HTTP.PRECONDITION_FAILED).end();
+ }
+
+ await applyPatch(opCtx, identifier, doc, storageDoc);
+ }
+ else {
+ return res.status(apiConst.HTTP.NOT_FOUND).end();
+ }
+}
+
+
+/**
+ * Patch existing document in the collection
+ * @param {Object} opCtx
+ * @param {string} identifier
+ * @param {Object} doc - fields and values to patch
+ * @param {Object} storageDoc - original (database) version of document
+ */
+async function applyPatch (opCtx, identifier, doc, storageDoc) {
+
+ const { ctx, res, col, auth } = opCtx;
+
+ if (validate(opCtx, doc, storageDoc) !== true)
+ return;
+
+ const now = new Date;
+ doc.srvModified = now.getTime();
+
+ if (auth && auth.subject && auth.subject.name) {
+ doc.modifiedBy = auth.subject.name;
+ }
+
+ const matchedCount = await col.storage.updateOne(identifier, doc);
+
+ if (!matchedCount)
+ throw new Error('matchedCount empty');
+
+ res.setHeader('Last-Modified', now.toUTCString());
+ res.status(apiConst.HTTP.NO_CONTENT).send({ });
+
+ const fieldsProjector = new FieldsProjector('_all');
+ const patchedDocs = await col.storage.findOne(identifier, fieldsProjector);
+ const patchedDoc = patchedDocs[0];
+ fieldsProjector.applyProjection(patchedDoc);
+ ctx.bus.emit('storage-socket-update', { colName: col.colName, doc: patchedDoc });
+
+ col.autoPrune();
+ ctx.bus.emit('data-received');
+}
+
+
+function patchOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await patch(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = patchOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/patch/validate.js b/lib/api3/generic/patch/validate.js
new file mode 100644
index 00000000000..057bb5c39e8
--- /dev/null
+++ b/lib/api3/generic/patch/validate.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const updateValidate = require('../update/validate')
+ ;
+
+
+/**
+ * Validate document to patch
+ * @param {Object} opCtx
+ * @param {Object} doc
+ * @param {Object} storageDoc
+ * @returns string - null if validation fails
+ */
+function validate (opCtx, doc, storageDoc) {
+
+ return updateValidate(opCtx, doc, storageDoc, { isPatching: true });
+}
+
+module.exports = validate;
\ No newline at end of file
diff --git a/lib/api3/generic/read/operation.js b/lib/api3/generic/read/operation.js
new file mode 100644
index 00000000000..c2e65a4afcc
--- /dev/null
+++ b/lib/api3/generic/read/operation.js
@@ -0,0 +1,77 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ , dateTools = require('../../shared/dateTools')
+ , renderer = require('../../shared/renderer')
+ , FieldsProjector = require('../../shared/fieldsProjector')
+ ;
+
+/**
+ * READ: Retrieves a single document from the collection
+ */
+async function read (opCtx) {
+
+ const { req, res, col } = opCtx;
+
+ await security.demandPermission(opCtx, `api:${col.colName}:read`);
+
+ const fieldsProjector = new FieldsProjector(req.query.fields);
+
+ const result = await col.storage.findOne(req.params.identifier
+ , fieldsProjector.storageProjection());
+
+ if (!result)
+ throw new Error('empty result');
+
+ if (result.length === 0) {
+ return res.status(apiConst.HTTP.NOT_FOUND).end();
+ }
+
+ const doc = result[0];
+ if (doc.isValid === false) {
+ return res.status(apiConst.HTTP.GONE).end();
+ }
+
+
+ const modifiedDate = col.resolveDates(doc);
+ if (modifiedDate) {
+ res.setHeader('Last-Modified', modifiedDate.toUTCString());
+
+ const ifModifiedSince = req.get('If-Modified-Since');
+
+ if (ifModifiedSince
+ && dateTools.floorSeconds(modifiedDate) <= dateTools.floorSeconds(new Date(ifModifiedSince))) {
+ return res.status(apiConst.HTTP.NOT_MODIFIED).end();
+ }
+ }
+
+ fieldsProjector.applyProjection(doc);
+
+ res.status(apiConst.HTTP.OK);
+ renderer.render(res, doc);
+}
+
+
+function readOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await read(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = readOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/search/input.js b/lib/api3/generic/search/input.js
new file mode 100644
index 00000000000..dbd37356760
--- /dev/null
+++ b/lib/api3/generic/search/input.js
@@ -0,0 +1,140 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , dateTools = require('../../shared/dateTools')
+ , stringTools = require('../../shared/stringTools')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+const filterRegex = /(.*)\$([a-zA-Z]+)/;
+
+
+/**
+ * Parse value of the parameter (to the correct data type)
+ */
+function parseValue(param, value) {
+
+ value = stringTools.isNumberInString(value) ? parseFloat(value) : value; // convert number from string
+
+ // convert boolean from string
+ if (value === 'true')
+ value = true;
+
+ if (value === 'false')
+ value = false;
+
+ // unwrap string in single quotes
+ if (typeof(value) === 'string' && value.startsWith('\'') && value.endsWith('\'')) {
+ value = value.substr(1, value.length - 2);
+ }
+
+ if (['date', 'srvModified', 'srvCreated'].includes(param)) {
+ let m = dateTools.parseToMoment(value);
+ if (m && m.isValid()) {
+ value = m.valueOf();
+ }
+ }
+
+ if (param === 'created_at') {
+ let m = dateTools.parseToMoment(value);
+ if (m && m.isValid()) {
+ value = m.toISOString();
+ }
+ }
+
+ return value;
+}
+
+
+/**
+ * Parse filtering criteria from query string
+ */
+function parseFilter (req, res) {
+ const filter = []
+ , reservedParams = ['token', 'sort', 'sort$desc', 'limit', 'skip', 'fields', 'now']
+ , operators = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 're']
+ ;
+
+ for (let param in req.query) {
+ if (!Object.prototype.hasOwnProperty.call(req.query, param)
+ || reservedParams.includes(param)) continue;
+
+ let field = param
+ , operator = 'eq'
+ ;
+
+ const match = filterRegex.exec(param);
+ if (match != null) {
+ operator = match[2];
+ field = match[1];
+
+ if (!operators.includes(operator)) {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
+ apiConst.MSG.HTTP_400_UNSUPPORTED_FILTER_OPERATOR.replace('{0}', operator));
+ return null;
+ }
+ }
+ const value = parseValue(field, req.query[param]);
+
+ filter.push({ field, operator, value });
+ }
+
+ return filter;
+}
+
+
+/**
+ * Parse sorting from query string
+ */
+function parseSort (req, res) {
+ let sort = {}
+ , sortDirection = 1;
+
+ if (req.query.sort && req.query.sort$desc) {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_SORT_SORT_DESC);
+ return null;
+ }
+
+ if (req.query.sort$desc) {
+ sortDirection = -1;
+ sort[req.query.sort$desc] = sortDirection;
+ }
+ else {
+ if (req.query.sort) {
+ sort[req.query.sort] = sortDirection;
+ }
+ }
+
+ sort.identifier = sortDirection;
+ sort.created_at = sortDirection;
+ sort.date = sortDirection;
+
+ return sort;
+}
+
+
+/**
+ * Parse skip (offset) from query string
+ */
+function parseSkip (req, res) {
+ let skip = 0;
+
+ if (req.query.skip) {
+ if (!isNaN(req.query.skip) && req.query.skip >= 0) {
+ skip = parseInt(req.query.skip);
+ }
+ else {
+ opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_SKIP);
+ return null;
+ }
+ }
+
+ return skip;
+}
+
+
+module.exports = {
+ parseFilter,
+ parseSort,
+ parseSkip
+};
\ No newline at end of file
diff --git a/lib/api3/generic/search/operation.js b/lib/api3/generic/search/operation.js
new file mode 100644
index 00000000000..c24f978dc27
--- /dev/null
+++ b/lib/api3/generic/search/operation.js
@@ -0,0 +1,79 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ , renderer = require('../../shared/renderer')
+ , input = require('./input')
+ , _each = require('lodash/each')
+ , FieldsProjector = require('../../shared/fieldsProjector')
+ ;
+
+
+/**
+ * SEARCH: Search documents from the collection
+ */
+async function search (opCtx) {
+
+ const { req, res, col } = opCtx;
+
+ if (col.colName === 'settings') {
+ await security.demandPermission(opCtx, `api:${col.colName}:admin`);
+ } else {
+ await security.demandPermission(opCtx, `api:${col.colName}:read`);
+ }
+
+ const fieldsProjector = new FieldsProjector(req.query.fields);
+
+ const filter = input.parseFilter(req, res)
+ , sort = input.parseSort(req, res)
+ , limit = col.parseLimit(req, res)
+ , skip = input.parseSkip(req, res)
+ , projection = fieldsProjector.storageProjection()
+ , onlyValid = true
+ ;
+
+
+ if (filter !== null && sort !== null && limit !== null && skip !== null && projection !== null) {
+
+ const result = await col.storage.findMany(filter
+ , sort
+ , limit
+ , skip
+ , projection
+ , onlyValid);
+
+ if (!result)
+ throw new Error('empty result');
+
+ _each(result, col.resolveDates);
+
+ _each(result, fieldsProjector.applyProjection);
+
+ res.status(apiConst.HTTP.OK);
+ renderer.render(res, result);
+ }
+}
+
+
+function searchOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await search(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = searchOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/setup.js b/lib/api3/generic/setup.js
new file mode 100644
index 00000000000..17e118658dd
--- /dev/null
+++ b/lib/api3/generic/setup.js
@@ -0,0 +1,103 @@
+'use strict';
+
+const _ = require('lodash')
+ , dateTools = require('../shared/dateTools')
+ , Collection = require('./collection')
+ ;
+
+
+function fallbackDate (doc) {
+ const m = dateTools.parseToMoment(doc.date);
+ return m == null || !m.isValid()
+ ? null
+ : m.toDate();
+}
+
+
+function fallbackCreatedAt (doc) {
+ const m = dateTools.parseToMoment(doc.created_at);
+ return m == null || !m.isValid()
+ ? null
+ : m.toDate();
+}
+
+
+function setupGenericCollections (ctx, env, app) {
+ const cols = { }
+ , enabledCols = app.get('enabledCollections');
+
+ if (_.includes(enabledCols, 'devicestatus')) {
+ cols.devicestatus = new Collection({
+ ctx, env, app,
+ colName: 'devicestatus',
+ storageColName: env.devicestatus_collection || 'devicestatus',
+ fallbackGetDate: fallbackCreatedAt,
+ dedupFallbackFields: ['created_at', 'device'],
+ fallbackDateField: 'created_at'
+ });
+ }
+
+ const entriesCollection = new Collection({
+ ctx, env, app,
+ colName: 'entries',
+ storageColName: env.entries_collection || 'entries',
+ fallbackGetDate: fallbackDate,
+ dedupFallbackFields: ['date', 'type'],
+ fallbackDateField: 'date'
+ });
+ app.set('entriesCollection', entriesCollection);
+
+ if (_.includes(enabledCols, 'entries')) {
+ cols.entries = entriesCollection;
+ }
+
+ if (_.includes(enabledCols, 'food')) {
+ cols.food = new Collection({
+ ctx, env, app,
+ colName: 'food',
+ storageColName: env.food_collection || 'food',
+ fallbackGetDate: fallbackCreatedAt,
+ dedupFallbackFields: ['created_at'],
+ fallbackDateField: 'created_at'
+ });
+ }
+
+ if (_.includes(enabledCols, 'profile')) {
+ cols.profile = new Collection({
+ ctx, env, app,
+ colName: 'profile',
+ storageColName: env.profile_collection || 'profile',
+ fallbackGetDate: fallbackCreatedAt,
+ dedupFallbackFields: ['created_at'],
+ fallbackDateField: 'created_at'
+ });
+ }
+
+ if (_.includes(enabledCols, 'settings')) {
+ cols.settings = new Collection({
+ ctx, env, app,
+ colName: 'settings',
+ storageColName: env.settings_collection || 'settings'
+ });
+ }
+
+ if (_.includes(enabledCols, 'treatments')) {
+ cols.treatments = new Collection({
+ ctx, env, app,
+ colName: 'treatments',
+ storageColName: env.treatments_collection || 'treatments',
+ fallbackGetDate: fallbackCreatedAt,
+ dedupFallbackFields: ['created_at', 'eventType'],
+ fallbackDateField: 'created_at'
+ });
+ }
+
+ _.forOwn(cols, function forMember (col) {
+ col.mapRoutes();
+ });
+
+ app.set('collections', cols);
+}
+
+
+module.exports = setupGenericCollections;
diff --git a/lib/api3/generic/update/operation.js b/lib/api3/generic/update/operation.js
new file mode 100644
index 00000000000..3e517a32d11
--- /dev/null
+++ b/lib/api3/generic/update/operation.js
@@ -0,0 +1,86 @@
+'use strict';
+
+const _ = require('lodash')
+ , dateTools = require('../../shared/dateTools')
+ , apiConst = require('../../const.json')
+ , security = require('../../security')
+ , insert = require('../create/insert')
+ , replace = require('./replace')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+/**
+ * UPDATE: Updates a document in the collection
+ */
+async function update (opCtx) {
+
+ const { col, req, res } = opCtx;
+ const doc = req.body;
+
+ if (_.isEmpty(doc)) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
+ }
+
+ col.parseDate(doc);
+ opTools.resolveIdentifier(doc);
+
+ const identifier = req.params.identifier
+ , identifyingFilter = col.storage.identifyingFilter(identifier);
+
+ const result = await col.storage.findOneFilter(identifyingFilter, { });
+
+ if (!result)
+ throw new Error('empty result');
+
+ doc.identifier = identifier;
+
+ if (result.length > 0) {
+ await updateConditional(opCtx, doc, result[0]);
+ }
+ else {
+ await insert(opCtx, doc);
+ }
+}
+
+
+async function updateConditional (opCtx, doc, storageDoc) {
+
+ const { col, req, res } = opCtx;
+
+ if (storageDoc.isValid === false) {
+ return res.status(apiConst.HTTP.GONE).end();
+ }
+
+ const modifiedDate = col.resolveDates(storageDoc)
+ , ifUnmodifiedSince = req.get('If-Unmodified-Since');
+
+ if (ifUnmodifiedSince
+ && dateTools.floorSeconds(modifiedDate) > dateTools.floorSeconds(new Date(ifUnmodifiedSince))) {
+ return res.status(apiConst.HTTP.PRECONDITION_FAILED).end();
+ }
+
+ await replace(opCtx, doc, storageDoc);
+}
+
+
+function updateOperation (ctx, env, app, col) {
+
+ return async function operation (req, res) {
+
+ const opCtx = { app, ctx, env, col, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await update(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ };
+}
+
+module.exports = updateOperation;
\ No newline at end of file
diff --git a/lib/api3/generic/update/replace.js b/lib/api3/generic/update/replace.js
new file mode 100644
index 00000000000..fdf803ed16f
--- /dev/null
+++ b/lib/api3/generic/update/replace.js
@@ -0,0 +1,53 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , validate = require('./validate.js')
+ , path = require('path')
+ ;
+
+/**
+ * Replace existing document in the collection
+ * @param {Object} opCtx
+ * @param {any} doc - new version of document to set
+ * @param {any} storageDoc - old version of document (existing in the storage)
+ * @param {Object} options
+ */
+async function replace (opCtx, doc, storageDoc, options) {
+
+ const { ctx, auth, col, req, res } = opCtx;
+ const { isDeduplication } = options || {};
+
+ await security.demandPermission(opCtx, `api:${col.colName}:update`);
+
+ if (validate(opCtx, doc, storageDoc, { isDeduplication }) !== true)
+ return;
+
+ const now = new Date;
+ doc.srvModified = now.getTime();
+ doc.srvCreated = storageDoc.srvCreated || doc.srvModified;
+
+ if (auth && auth.subject && auth.subject.name) {
+ doc.subject = auth.subject.name;
+ }
+
+ const matchedCount = await col.storage.replaceOne(storageDoc.identifier, doc);
+
+ if (!matchedCount)
+ throw new Error('empty matchedCount');
+
+ res.setHeader('Last-Modified', now.toUTCString());
+
+ if (storageDoc.identifier !== doc.identifier || isDeduplication) {
+ res.setHeader('Location', path.posix.join(req.baseUrl, req.path, doc.identifier));
+ }
+
+ res.status(apiConst.HTTP.NO_CONTENT).send({ });
+
+ ctx.bus.emit('storage-socket-update', { colName: col.colName, doc });
+ col.autoPrune();
+ ctx.bus.emit('data-received');
+}
+
+
+module.exports = replace;
\ No newline at end of file
diff --git a/lib/api3/generic/update/validate.js b/lib/api3/generic/update/validate.js
new file mode 100644
index 00000000000..b36e1410067
--- /dev/null
+++ b/lib/api3/generic/update/validate.js
@@ -0,0 +1,48 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , opTools = require('../../shared/operationTools')
+ ;
+
+
+/**
+ * Validation of document to update
+ * @param {Object} opCtx
+ * @param {Object} doc
+ * @param {Object} storageDoc
+ * @param {Object} options
+ * @returns string with error message if validation fails, true in case of success
+ */
+function validate (opCtx, doc, storageDoc, options) {
+
+ const { res } = opCtx;
+ const { isPatching, isDeduplication } = options || {};
+
+ const immutable = ['identifier', 'date', 'utcOffset', 'eventType', 'device', 'app',
+ 'srvCreated', 'subject', 'srvModified', 'modifiedBy', 'isValid'];
+
+ if (storageDoc.isReadOnly === true || storageDoc.readOnly === true || storageDoc.readonly === true) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.UNPROCESSABLE_ENTITY,
+ apiConst.MSG.HTTP_422_READONLY_MODIFICATION);
+ }
+
+ for (const field of immutable) {
+
+ // change of identifier is allowed in deduplication (for APIv1 documents)
+ if (field === 'identifier' && isDeduplication)
+ continue;
+
+ // changing deleted document is without restrictions
+ if (storageDoc.isValid === false)
+ continue;
+
+ if (typeof(doc[field]) !== 'undefined' && doc[field] !== storageDoc[field]) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
+ apiConst.MSG.HTTP_400_IMMUTABLE_FIELD.replace('{0}', field));
+ }
+ }
+
+ return opTools.validateCommon(doc, res, { isPatching });
+}
+
+module.exports = validate;
\ No newline at end of file
diff --git a/lib/api3/index.js b/lib/api3/index.js
new file mode 100644
index 00000000000..4bfe07a35fe
--- /dev/null
+++ b/lib/api3/index.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const express = require('express')
+ , bodyParser = require('body-parser')
+ , renderer = require('./shared/renderer')
+ , StorageSocket = require('./storageSocket')
+ , apiConst = require('./const.json')
+ , security = require('./security')
+ , genericSetup = require('./generic/setup')
+ , swaggerSetup = require('./swagger')
+ ;
+
+function configure (env, ctx) {
+
+ const self = { }
+ , app = express()
+ ;
+
+ self.setENVTruthy = function setENVTruthy (varName, defaultValue) {
+ //for some reason Azure uses this prefix, maybe there is a good reason
+ let value = process.env['CUSTOMCONNSTR_' + varName]
+ || process.env['CUSTOMCONNSTR_' + varName.toLowerCase()]
+ || process.env[varName]
+ || process.env[varName.toLowerCase()];
+
+ value = value != null ? value : defaultValue;
+
+ if (typeof value === 'string' && (value.toLowerCase() === 'on' || value.toLowerCase() === 'true')) { value = true; }
+ if (typeof value === 'string' && (value.toLowerCase() === 'off' || value.toLowerCase() === 'false')) { value = false; }
+
+ app.set(varName, value);
+ return value;
+ };
+ app.setENVTruthy = self.setENVTruthy;
+
+
+ self.setupApiEnvironment = function setupApiEnvironment () {
+
+ app.use(bodyParser.json({
+ limit: 1048576 * 50
+ }), function errorHandler (err, req, res, next) {
+ console.error(err);
+ res.status(apiConst.HTTP.INTERNAL_ERROR).json({
+ status: apiConst.HTTP.INTERNAL_ERROR,
+ message: apiConst.MSG.HTTP_500_INTERNAL_ERROR
+ });
+ if (next) { // we need 4th parameter next to behave like error handler, but we have to use it to prevent "unused variable" message
+ }
+ });
+
+ app.use(renderer.extension2accept);
+
+ // we don't need these here
+ app.set('etag', false);
+ app.set('x-powered-by', false); // this seems to be unreliable
+ app.use(function (req, res, next) {
+ res.removeHeader('x-powered-by');
+ next();
+ });
+
+ app.set('name', env.name);
+ app.set('version', env.version);
+ app.set('apiVersion', apiConst.API3_VERSION);
+ app.set('units', env.DISPLAY_UNITS);
+ app.set('ci', process.env['CI'] ? true: false);
+ app.set('enabledCollections', ['devicestatus', 'entries', 'food', 'profile', 'settings', 'treatments']);
+
+ self.setENVTruthy('API3_SECURITY_ENABLE', apiConst.API3_SECURITY_ENABLE);
+ self.setENVTruthy('API3_TIME_SKEW_TOLERANCE', apiConst.API3_TIME_SKEW_TOLERANCE);
+ self.setENVTruthy('API3_DEDUP_FALLBACK_ENABLED', apiConst.API3_DEDUP_FALLBACK_ENABLED);
+ self.setENVTruthy('API3_CREATED_AT_FALLBACK_ENABLED', apiConst.API3_CREATED_AT_FALLBACK_ENABLED);
+ self.setENVTruthy('API3_MAX_LIMIT', apiConst.API3_MAX_LIMIT);
+ };
+
+
+ self.setupApiRoutes = function setupApiRoutes () {
+
+ app.get('/version', require('./specific/version')(app, ctx, env));
+
+ if (app.get('env') === 'development' || app.get('ci')) { // for development and testing purposes only
+ app.get('/test', async function test (req, res) {
+
+ try {
+ const opCtx = {app, ctx, env, req, res};
+ opCtx.auth = await security.authenticate(opCtx);
+ await security.demandPermission(opCtx, 'api:entries:read');
+ res.status(apiConst.HTTP.OK).end();
+ } catch (error) {
+ console.error(error);
+ }
+ });
+ }
+
+ app.get('/lastModified', require('./specific/lastModified')(app, ctx, env));
+
+ app.get('/status', require('./specific/status')(app, ctx, env));
+ };
+
+
+ self.setupApiEnvironment();
+ genericSetup(ctx, env, app);
+ self.setupApiRoutes();
+ swaggerSetup(app);
+
+ ctx.storageSocket = new StorageSocket(app, env, ctx);
+
+ return app;
+}
+
+module.exports = configure;
diff --git a/lib/api3/security.js b/lib/api3/security.js
new file mode 100644
index 00000000000..33099d88f12
--- /dev/null
+++ b/lib/api3/security.js
@@ -0,0 +1,122 @@
+'use strict';
+
+const moment = require('moment')
+ , apiConst = require('./const.json')
+ , _ = require('lodash')
+ , shiroTrie = require('shiro-trie')
+ , dateTools = require('./shared/dateTools')
+ , opTools = require('./shared/operationTools')
+ ;
+
+
+/**
+ * Check if Date header in HTTP request (or 'now' query parameter) is present and valid (with error response sending)
+ */
+function checkDateHeader (opCtx) {
+
+ const { app, req, res } = opCtx;
+
+ let dateString = req.header('Date');
+ if (!dateString) {
+ dateString = req.query.now;
+ }
+
+ if (!dateString) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_MISSING_DATE);
+ }
+
+ let dateMoment = dateTools.parseToMoment(dateString);
+ if (!dateMoment) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_BAD_DATE);
+ }
+
+ let nowMoment = moment(new Date());
+ let diffMinutes = moment.duration(nowMoment.diff(dateMoment)).asMinutes();
+
+ if (Math.abs(diffMinutes) > app.get('API3_TIME_SKEW_TOLERANCE')) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE);
+ }
+
+ return true;
+}
+
+
+function authenticate (opCtx) {
+ return new Promise(function promise (resolve, reject) {
+
+ let { app, ctx, req, res } = opCtx;
+
+ if (!app.get('API3_SECURITY_ENABLE')) {
+ const adminShiro = shiroTrie.new();
+ adminShiro.add('*');
+ return resolve({ shiros: [ adminShiro ] });
+ }
+
+ if (req.protocol !== 'https') {
+ return reject(
+ opTools.sendJSONStatus(res, apiConst.HTTP.FORBIDDEN, apiConst.MSG.HTTP_403_NOT_USING_HTTPS));
+ }
+
+ const checkDateResult = checkDateHeader(opCtx);
+ if (checkDateResult !== true) {
+ return checkDateResult;
+ }
+
+ let token = ctx.authorization.extractToken(req);
+ if (!token) {
+ return reject(
+ opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN));
+ }
+
+ ctx.authorization.resolve({ token }, function resolveFinish (err, result) {
+ if (err) {
+ return reject(
+ opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_BAD_TOKEN));
+ }
+ else {
+ return resolve(result);
+ }
+ });
+ });
+}
+
+
+/**
+ * Checks for the permission from the authorization without error response sending
+ * @param {any} auth
+ * @param {any} permission
+ */
+function checkPermission (auth, permission) {
+
+ if (auth) {
+ const found = _.find(auth.shiros, function checkEach (shiro) {
+ return shiro && shiro.check(permission);
+ });
+ return _.isObject(found);
+ }
+ else {
+ return false;
+ }
+}
+
+
+
+function demandPermission (opCtx, permission) {
+ return new Promise(function promise (resolve, reject) {
+ const { auth, res } = opCtx;
+
+ if (checkPermission(auth, permission)) {
+ return resolve(true);
+ } else {
+ return reject(
+ opTools.sendJSONStatus(res, apiConst.HTTP.FORBIDDEN, apiConst.MSG.HTTP_403_MISSING_PERMISSION.replace('{0}', permission)));
+ }
+ });
+}
+
+
+module.exports = {
+ authenticate,
+ checkPermission,
+ demandPermission
+};
\ No newline at end of file
diff --git a/lib/api3/shared/dateTools.js b/lib/api3/shared/dateTools.js
new file mode 100644
index 00000000000..14b67f9e109
--- /dev/null
+++ b/lib/api3/shared/dateTools.js
@@ -0,0 +1,78 @@
+'use strict';
+
+const moment = require('moment')
+ , stringTools = require('./stringTools')
+ , apiConst = require('../const.json')
+ ;
+
+
+/**
+ * Floor date to whole seconds (cut off milliseconds)
+ * @param {Date} date
+ */
+function floorSeconds (date) {
+ let ms = date.getTime();
+ ms -= ms % 1000;
+ return new Date(ms);
+}
+
+
+/**
+ * Parse date as moment object from value or array of values.
+ * @param {any} value
+ */
+function parseToMoment (value)
+{
+ if (!value)
+ return null;
+
+ if (Array.isArray(value)) {
+ for (let item of value) {
+ let m = parseToMoment(item);
+
+ if (m !== null)
+ return m;
+ }
+ }
+ else {
+
+ if (typeof value === 'string' && stringTools.isNumberInString(value)) {
+ value = parseFloat(value);
+ }
+
+ if (typeof value === 'number') {
+ let m = moment(value);
+
+ if (!m.isValid())
+ return null;
+
+ if (m.valueOf() < apiConst.MIN_TIMESTAMP)
+ m = moment.unix(m);
+
+ if (!m.isValid() || m.valueOf() < apiConst.MIN_TIMESTAMP)
+ return null;
+
+ return m;
+ }
+
+ if (typeof value === 'string') {
+ let m = moment.parseZone(value, moment.ISO_8601);
+
+ if (!m.isValid())
+ m = moment.parseZone(value, moment.RFC_2822);
+
+ if (!m.isValid() || m.valueOf() < apiConst.MIN_TIMESTAMP)
+ return null;
+
+ return m;
+ }
+ }
+
+ // no parsing option succeeded => failure
+ return null;
+}
+
+module.exports = {
+ floorSeconds,
+ parseToMoment
+};
diff --git a/lib/api3/shared/fieldsProjector.js b/lib/api3/shared/fieldsProjector.js
new file mode 100644
index 00000000000..921c7cc6df8
--- /dev/null
+++ b/lib/api3/shared/fieldsProjector.js
@@ -0,0 +1,82 @@
+'use strict';
+
+const _each = require('lodash/each');
+
+/**
+ * Decoder of 'fields' parameter providing storage projections
+ * @param {string} fieldsString - fields parameter from user
+ */
+function FieldsProjector (fieldsString) {
+
+ const self = this
+ , exclude = [];
+ let specific = null;
+
+ switch (fieldsString)
+ {
+ case '_all':
+ break;
+
+ default:
+ if (fieldsString) {
+ specific = fieldsString.split(',');
+ }
+ }
+
+ const systemFields = ['identifier', 'srvCreated', 'created_at', 'date'];
+
+ /**
+ * Prepare projection definition for storage query
+ * */
+ self.storageProjection = function storageProjection () {
+ const projection = { };
+
+ if (specific) {
+ _each(specific, function include (field) {
+ projection[field] = 1;
+ });
+
+ _each(systemFields, function include (field) {
+ projection[field] = 1;
+ });
+ }
+ else {
+ _each(exclude, function exclude (field) {
+ projection[field] = 0;
+ });
+
+ _each(exclude, function exclude (field) {
+ if (systemFields.indexOf(field) >= 0) {
+ delete projection[field];
+ }
+ });
+ }
+
+ return projection;
+ };
+
+
+ /**
+ * Cut off unwanted fields from given document
+ * @param {Object} doc
+ */
+ self.applyProjection = function applyProjection (doc) {
+
+ if (specific) {
+ for(const field in doc) {
+ if (specific.indexOf(field) === -1) {
+ delete doc[field];
+ }
+ }
+ }
+ else {
+ _each(exclude, function include (field) {
+ if (typeof(doc[field]) !== 'undefined') {
+ delete doc[field];
+ }
+ });
+ }
+ };
+}
+
+module.exports = FieldsProjector;
\ No newline at end of file
diff --git a/lib/api3/shared/operationTools.js b/lib/api3/shared/operationTools.js
new file mode 100644
index 00000000000..1955b9c2068
--- /dev/null
+++ b/lib/api3/shared/operationTools.js
@@ -0,0 +1,111 @@
+'use strict';
+
+const apiConst = require('../const.json')
+ , stringTools = require('./stringTools')
+ , uuidv5 = require('uuid/v5')
+ , uuidNamespace = [...Buffer.from("NightscoutRocks!", "ascii")] // official namespace for NS :-)
+ ;
+
+function sendJSONStatus (res, status, title, description, warning) {
+
+ const json = {
+ status: status,
+ message: title,
+ description: description
+ };
+
+ // Add optional warning message.
+ if (warning) { json.warning = warning; }
+
+ res.status(status).json(json);
+
+ return title;
+}
+
+
+/**
+ * Validate document's common fields
+ * @param {Object} doc
+ * @param {any} res
+ * @param {Object} options
+ * @returns {any} - string with error message if validation fails, true in case of success
+ */
+function validateCommon (doc, res, options) {
+
+ const { isPatching } = options || {};
+
+
+ if ((!isPatching || typeof(doc.date) !== 'undefined')
+
+ && (typeof(doc.date) !== 'number'
+ || doc.date <= apiConst.MIN_TIMESTAMP)
+ ) {
+ return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_DATE);
+ }
+
+
+ if ((!isPatching || typeof(doc.utcOffset) !== 'undefined')
+
+ && (typeof(doc.utcOffset) !== 'number'
+ || doc.utcOffset < apiConst.MIN_UTC_OFFSET
+ || doc.utcOffset > apiConst.MAX_UTC_OFFSET)
+ ) {
+ return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_UTC);
+ }
+
+
+ if ((!isPatching || typeof(doc.app) !== 'undefined')
+
+ && (typeof(doc.app) !== 'string'
+ || stringTools.isNullOrWhitespace(doc.app))
+ ) {
+ return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_APP);
+ }
+
+ return true;
+}
+
+
+/**
+ * Calculate identifier for the document
+ * @param {Object} doc
+ * @returns string
+ */
+function calculateIdentifier (doc) {
+ if (!doc)
+ return undefined;
+
+ let key = doc.device + '_' + doc.date;
+ if (doc.eventType) {
+ key += '_' + doc.eventType;
+ }
+
+ return uuidv5(key, uuidNamespace);
+}
+
+
+/**
+ * Validate identifier in the document
+ * @param {Object} doc
+ */
+function resolveIdentifier (doc) {
+
+ let identifier = calculateIdentifier(doc);
+ if (doc.identifier) {
+ if (doc.identifier !== identifier) {
+ console.warn(`APIv3: Identifier mismatch (expected: ${identifier}, received: ${doc.identifier})`);
+ console.log(doc);
+ }
+ }
+ else {
+ doc.identifier = identifier;
+ }
+}
+
+
+module.exports = {
+ sendJSONStatus,
+ validateCommon,
+ calculateIdentifier,
+ resolveIdentifier
+};
\ No newline at end of file
diff --git a/lib/api3/shared/renderer.js b/lib/api3/shared/renderer.js
new file mode 100644
index 00000000000..a3588819a72
--- /dev/null
+++ b/lib/api3/shared/renderer.js
@@ -0,0 +1,99 @@
+'use strict';
+
+const apiConst = require('../const.json')
+ , mime = require('mime')
+ , url = require('url')
+ , opTools = require('./operationTools')
+ , EasyXml = require('easyxml')
+ , csvStringify = require('csv-stringify')
+ ;
+
+
+/**
+ * Middleware that converts url's extension to Accept HTTP request header
+ * @param {Object} req
+ * @param {Object} res
+ * @param {Function} next
+ */
+function extension2accept (req, res, next) {
+
+ const pathSplit = req.path.split('.');
+
+ if (pathSplit.length < 2)
+ return next();
+
+ const pathBase = pathSplit[0]
+ , extension = pathSplit.slice(1).join('.');
+
+ if (!extension)
+ return next();
+
+ const mimeType = mime.getType(extension);
+ if (!mimeType)
+ return opTools.sendJSONStatus(res, apiConst.HTTP.NOT_ACCEPTABLE, apiConst.MSG.HTTP_406_UNSUPPORTED_FORMAT);
+
+ req.extToAccept = {
+ url: req.url,
+ accept: req.headers.accept
+ };
+
+ req.headers.accept = mimeType;
+ const parsed = url.parse(req.url);
+ parsed.pathname = pathBase;
+ req.url = url.format(parsed);
+
+ next();
+}
+
+
+/**
+ * Sends data to output using the client's desired format
+ * @param {Object} res
+ * @param {any} data
+ */
+function render (res, data) {
+ res.format({
+ 'json': () => res.send(data),
+ 'csv': () => renderCsv(res, data),
+ 'xml': () => renderXml(res, data),
+ 'default': () =>
+ opTools.sendJSONStatus(res, apiConst.HTTP.NOT_ACCEPTABLE, apiConst.MSG.HTTP_406_UNSUPPORTED_FORMAT)
+ });
+}
+
+
+/**
+ * Format data to output as .csv
+ * @param {Object} res
+ * @param {any} data
+ */
+function renderCsv (res, data) {
+ const csvSource = Array.isArray(data) ? data : [data];
+ csvStringify(csvSource, {
+ header: true
+ },
+ function csvStringified (err, output) {
+ res.send(output);
+ });
+}
+
+
+/**
+ * Format data to output as .xml
+ * @param {Object} res
+ * @param {any} data
+ */
+function renderXml (res, data) {
+ const serializer = new EasyXml({
+ rootElement: 'item',
+ dateFormat: 'ISO',
+ manifest: true
+ });
+ res.send(serializer.render(data));
+}
+
+
+module.exports = {
+ extension2accept,
+ render
+};
\ No newline at end of file
diff --git a/lib/api3/shared/storageTools.js b/lib/api3/shared/storageTools.js
new file mode 100644
index 00000000000..b7d9dca6776
--- /dev/null
+++ b/lib/api3/shared/storageTools.js
@@ -0,0 +1,63 @@
+'use strict';
+
+function getStorageVersion (app) {
+
+ return new Promise(function (resolve, reject) {
+
+ try {
+ const storage = app.get('entriesCollection').storage;
+ let storageVersion = app.get('storageVersion');
+
+ if (storageVersion) {
+ process.nextTick(() => {
+ resolve(storageVersion);
+ });
+ } else {
+ storage.version()
+ .then(storageVersion => {
+
+ app.set('storageVersion', storageVersion);
+ resolve(storageVersion);
+ }, reject);
+ }
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+
+function getVersionInfo(app) {
+
+ return new Promise(function (resolve, reject) {
+
+ try {
+ const srvDate = new Date()
+ , info = { version: app.get('version')
+ , apiVersion: app.get('apiVersion')
+ , srvDate: srvDate.getTime()
+ };
+
+ getStorageVersion(app)
+ .then(storageVersion => {
+
+ if (!storageVersion)
+ throw new Error('empty storageVersion');
+
+ info.storage = storageVersion;
+
+ resolve(info);
+
+ }, reject);
+
+ } catch(error) {
+ reject(error);
+ }
+ });
+}
+
+
+module.exports = {
+ getStorageVersion,
+ getVersionInfo
+};
diff --git a/lib/api3/shared/stringTools.js b/lib/api3/shared/stringTools.js
new file mode 100644
index 00000000000..b71a4b4f1a6
--- /dev/null
+++ b/lib/api3/shared/stringTools.js
@@ -0,0 +1,28 @@
+'use strict';
+
+/**
+ * Check the string for strictly valid number (no other characters present)
+ * @param {any} str
+ */
+function isNumberInString (str) {
+ return !isNaN(parseFloat(str)) && isFinite(str);
+}
+
+
+/**
+ * Check the string for non-whitespace characters presence
+ * @param {any} input
+ */
+function isNullOrWhitespace (input) {
+
+ if (typeof input === 'undefined' || input == null) return true;
+
+ return input.replace(/\s/g, '').length < 1;
+}
+
+
+
+module.exports = {
+ isNumberInString,
+ isNullOrWhitespace
+};
diff --git a/lib/api3/specific/lastModified.js b/lib/api3/specific/lastModified.js
new file mode 100644
index 00000000000..b27ecaca852
--- /dev/null
+++ b/lib/api3/specific/lastModified.js
@@ -0,0 +1,101 @@
+'use strict';
+
+function configure (app, ctx, env) {
+ const express = require('express')
+ , api = express.Router( )
+ , apiConst = require('../const.json')
+ , security = require('../security')
+ , opTools = require('../shared/operationTools')
+ ;
+
+ api.get('/lastModified', async function getLastModified (req, res) {
+
+ async function getLastModified (col) {
+
+ let result;
+ const lastModified = await col.storage.getLastModified('srvModified');
+
+ if (lastModified) {
+ result = lastModified.srvModified ? lastModified.srvModified : null;
+ }
+
+ if (col.fallbackDateField) {
+
+ const lastModified = await col.storage.getLastModified(col.fallbackDateField);
+
+ if (lastModified && lastModified[col.fallbackDateField]) {
+ let timestamp = lastModified[col.fallbackDateField];
+ if (typeof(timestamp) === 'string') {
+ timestamp = (new Date(timestamp)).getTime();
+ }
+
+ if (result === null || result < timestamp) {
+ result = timestamp;
+ }
+ }
+ }
+
+ return { colName: col.colName, lastModified: result };
+ }
+
+
+ async function collectionsAsync (auth) {
+
+ const cols = app.get('collections')
+ , promises = []
+ , output = {}
+ ;
+
+ for (const colName in cols) {
+ const col = cols[colName];
+
+ if (security.checkPermission(auth, 'api:' + col.colName + ':read')) {
+ promises.push(getLastModified(col));
+ }
+ }
+
+ const results = await Promise.all(promises);
+
+ for (const result of results) {
+ if (result.lastModified)
+ output[result.colName] = result.lastModified;
+ }
+
+ return output;
+ }
+
+
+ async function operation (opCtx) {
+
+ const { res, auth } = opCtx;
+ const srvDate = new Date();
+
+ let info = {
+ srvDate: srvDate.getTime(),
+ collections: { }
+ };
+
+ info.collections = await collectionsAsync(auth);
+
+ res.json(info);
+ }
+
+
+ const opCtx = { app, ctx, env, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await operation(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ });
+
+ return api;
+}
+module.exports = configure;
diff --git a/lib/api3/specific/status.js b/lib/api3/specific/status.js
new file mode 100644
index 00000000000..7b70b24ab71
--- /dev/null
+++ b/lib/api3/specific/status.js
@@ -0,0 +1,71 @@
+'use strict';
+
+function configure (app, ctx, env) {
+ const express = require('express')
+ , api = express.Router( )
+ , apiConst = require('../const.json')
+ , storageTools = require('../shared/storageTools')
+ , security = require('../security')
+ , opTools = require('../shared/operationTools')
+ ;
+
+ api.get('/status', async function getStatus (req, res) {
+
+ function permsForCol (col, auth) {
+ let colPerms = '';
+
+ if (security.checkPermission(auth, 'api:' + col.colName + ':create')) {
+ colPerms += 'c';
+ }
+
+ if (security.checkPermission(auth, 'api:' + col.colName + ':read')) {
+ colPerms += 'r';
+ }
+
+ if (security.checkPermission(auth, 'api:' + col.colName + ':update')) {
+ colPerms += 'u';
+ }
+
+ if (security.checkPermission(auth, 'api:' + col.colName + ':delete')) {
+ colPerms += 'd';
+ }
+
+ return colPerms;
+ }
+
+
+ async function operation (opCtx) {
+ const cols = app.get('collections');
+
+ let info = await storageTools.getVersionInfo(app);
+
+ info.apiPermissions = {};
+ for (let col in cols) {
+ const colPerms = permsForCol(col, opCtx.auth);
+ if (colPerms) {
+ info.apiPermissions[col] = colPerms;
+ }
+ }
+
+ res.json(info);
+ }
+
+
+ const opCtx = { app, ctx, env, req, res };
+
+ try {
+ opCtx.auth = await security.authenticate(opCtx);
+
+ await operation(opCtx);
+
+ } catch (err) {
+ console.error(err);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ });
+
+ return api;
+}
+module.exports = configure;
diff --git a/lib/api3/specific/version.js b/lib/api3/specific/version.js
new file mode 100644
index 00000000000..25392fe99d7
--- /dev/null
+++ b/lib/api3/specific/version.js
@@ -0,0 +1,28 @@
+'use strict';
+
+function configure (app) {
+ const express = require('express')
+ , api = express.Router( )
+ , apiConst = require('../const.json')
+ , storageTools = require('../shared/storageTools')
+ , opTools = require('../shared/operationTools')
+ ;
+
+ api.get('/version', async function getVersion (req, res) {
+
+ try {
+ const versionInfo = await storageTools.getVersionInfo(app);
+
+ res.json(versionInfo);
+
+ } catch(error) {
+ console.error(error);
+ if (!res.headersSent) {
+ return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
+ }
+ }
+ });
+
+ return api;
+}
+module.exports = configure;
diff --git a/lib/api3/storage/mongoCollection/find.js b/lib/api3/storage/mongoCollection/find.js
new file mode 100644
index 00000000000..bc399dbce98
--- /dev/null
+++ b/lib/api3/storage/mongoCollection/find.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const utils = require('./utils')
+ , _ = require('lodash')
+ ;
+
+
+/**
+ * Find single document by identifier
+ * @param {Object} col
+ * @param {string} identifier
+ * @param {Object} projection
+ */
+function findOne (col, identifier, projection) {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.filterForOne(identifier);
+
+ col.find(filter)
+ .project(projection)
+ .sort({ identifier: -1 }) // document with identifier first (not the fallback one)
+ .toArray(function mongoDone (err, result) {
+
+ if (err) {
+ reject(err);
+ } else {
+ _.each(result, utils.normalizeDoc);
+ resolve(result);
+ }
+ });
+ });
+}
+
+
+/**
+ * Find single document by query filter
+ * @param {Object} col
+ * @param {Object} filter specific filter
+ * @param {Object} projection
+ */
+function findOneFilter (col, filter, projection) {
+
+ return new Promise(function (resolve, reject) {
+
+ col.find(filter)
+ .project(projection)
+ .sort({ identifier: -1 }) // document with identifier first (not the fallback one)
+ .toArray(function mongoDone (err, result) {
+
+ if (err) {
+ reject(err);
+ } else {
+ _.each(result, utils.normalizeDoc);
+ resolve(result);
+ }
+ });
+ });
+}
+
+
+/**
+ * Find many documents matching the filtering criteria
+ */
+function findMany (col, filterDef, sort, limit, skip, projection, onlyValid, logicalOperator = 'and') {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.parseFilter(filterDef, logicalOperator, onlyValid);
+
+ col.find(filter)
+ .sort(sort)
+ .limit(limit)
+ .skip(skip)
+ .project(projection)
+ .toArray(function mongoDone (err, result) {
+
+ if (err) {
+ reject(err);
+ } else {
+ _.each(result, utils.normalizeDoc);
+ resolve(result);
+ }
+ });
+ });
+}
+
+
+module.exports = {
+ findOne,
+ findOneFilter,
+ findMany
+};
\ No newline at end of file
diff --git a/lib/api3/storage/mongoCollection/index.js b/lib/api3/storage/mongoCollection/index.js
new file mode 100644
index 00000000000..e6ad0a6cf8b
--- /dev/null
+++ b/lib/api3/storage/mongoCollection/index.js
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * Storage implementation using mongoDB
+ * @param {Object} ctx
+ * @param {Object} env
+ * @param {string} colName - name of the collection in mongo database
+ */
+function MongoCollection (ctx, env, colName) {
+
+ const self = this
+ , utils = require('./utils')
+ , find = require('./find')
+ , modify = require('./modify')
+ ;
+
+ self.colName = colName;
+
+ self.col = ctx.store.collection(colName);
+
+ ctx.store.ensureIndexes(self.col, [ 'identifier',
+ 'srvModified',
+ 'isValid'
+ ]);
+
+
+ self.identifyingFilter = utils.identifyingFilter;
+
+ self.findOne = (...args) => find.findOne(self.col, ...args);
+
+ self.findOneFilter = (...args) => find.findOneFilter(self.col, ...args);
+
+ self.findMany = (...args) => find.findMany(self.col, ...args);
+
+ self.insertOne = (...args) => modify.insertOne(self.col, ...args);
+
+ self.replaceOne = (...args) => modify.replaceOne(self.col, ...args);
+
+ self.updateOne = (...args) => modify.updateOne(self.col, ...args);
+
+ self.deleteOne = (...args) => modify.deleteOne(self.col, ...args);
+
+ self.deleteManyOr = (...args) => modify.deleteManyOr(self.col, ...args);
+
+
+ /**
+ * Get server version
+ */
+ self.version = function version () {
+
+ return new Promise(function (resolve, reject) {
+
+ ctx.store.db.admin().buildInfo({}, function mongoDone (err, result) {
+
+ err
+ ? reject(err)
+ : resolve({
+ storage: 'mongodb',
+ version: result.version
+ });
+ });
+ });
+ };
+
+
+ /**
+ * Get timestamp (e.g. srvModified) of the last modified document
+ */
+ self.getLastModified = function getLastModified (fieldName) {
+
+ return new Promise(function (resolve, reject) {
+
+ self.col.find()
+
+ .sort({ [fieldName]: -1 })
+
+ .limit(1)
+
+ .project({ [fieldName]: 1 })
+
+ .toArray(function mongoDone (err, [ result ]) {
+ err
+ ? reject(err)
+ : resolve(result);
+ });
+ });
+ }
+}
+
+module.exports = MongoCollection;
\ No newline at end of file
diff --git a/lib/api3/storage/mongoCollection/modify.js b/lib/api3/storage/mongoCollection/modify.js
new file mode 100644
index 00000000000..6552fe40e8c
--- /dev/null
+++ b/lib/api3/storage/mongoCollection/modify.js
@@ -0,0 +1,123 @@
+'use strict';
+
+const utils = require('./utils');
+
+
+/**
+ * Insert single document
+ * @param {Object} col
+ * @param {Object} doc
+ */
+function insertOne (col, doc) {
+
+ return new Promise(function (resolve, reject) {
+
+ col.insertOne(doc, function mongoDone(err, result) {
+
+ if (err) {
+ reject(err);
+ } else {
+ const identifier = doc.identifier || result.insertedId.toString();
+ delete doc._id;
+ resolve(identifier);
+ }
+ });
+ });
+}
+
+
+/**
+ * Replace single document
+ * @param {Object} col
+ * @param {string} identifier
+ * @param {Object} doc
+ */
+function replaceOne (col, identifier, doc) {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.filterForOne(identifier);
+
+ col.replaceOne(filter, doc, { }, function mongoDone(err, result) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(result.matchedCount);
+ }
+ });
+ });
+}
+
+
+/**
+ * Update single document by identifier
+ * @param {Object} col
+ * @param {string} identifier
+ * @param {object} setFields
+ */
+function updateOne (col, identifier, setFields) {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.filterForOne(identifier);
+
+ col.updateOne(filter, { $set: setFields }, function mongoDone(err, result) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ updated: result.result.nModified });
+ }
+ });
+ });
+}
+
+
+/**
+ * Permanently remove single document by identifier
+ * @param {Object} col
+ * @param {string} identifier
+ */
+function deleteOne (col, identifier) {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.filterForOne(identifier);
+
+ col.deleteOne(filter, function mongoDone(err, result) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ deleted: result.result.n });
+ }
+ });
+ });
+}
+
+
+/**
+ * Permanently remove many documents matching any of filtering criteria
+ */
+function deleteManyOr (col, filterDef) {
+
+ return new Promise(function (resolve, reject) {
+
+ const filter = utils.parseFilter(filterDef, 'or');
+
+ col.deleteMany(filter, function mongoDone(err, result) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ deleted: result.deletedCount });
+ }
+ });
+ });
+}
+
+
+module.exports = {
+ insertOne,
+ replaceOne,
+ updateOne,
+ deleteOne,
+ deleteManyOr
+};
\ No newline at end of file
diff --git a/lib/api3/storage/mongoCollection/utils.js b/lib/api3/storage/mongoCollection/utils.js
new file mode 100644
index 00000000000..1b2ab5610d7
--- /dev/null
+++ b/lib/api3/storage/mongoCollection/utils.js
@@ -0,0 +1,178 @@
+'use strict';
+
+const _ = require('lodash')
+ , checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$")
+ , ObjectID = require('mongodb').ObjectID
+;
+
+
+/**
+ * Normalize document (make it mongoDB independent)
+ * @param {Object} doc - document loaded from mongoDB
+ */
+function normalizeDoc (doc) {
+ if (!doc.identifier) {
+ doc.identifier = doc._id.toString();
+ }
+
+ delete doc._id;
+}
+
+
+/**
+ * Parse filter definition array into mongoDB filtering object
+ * @param {any} filterDef
+ * @param {string} logicalOperator
+ * @param {bool} onlyValid
+ */
+function parseFilter (filterDef, logicalOperator, onlyValid) {
+
+ let filter = { };
+ if (!filterDef)
+ return filter;
+
+ if (!_.isArray(filterDef)) {
+ return filterDef;
+ }
+
+ let clauses = [];
+
+ for (const itemDef of filterDef) {
+ let item;
+
+ switch (itemDef.operator) {
+ case 'eq':
+ item = itemDef.value;
+ break;
+
+ case 'ne':
+ item = { $ne: itemDef.value };
+ break;
+
+ case 'gt':
+ item = { $gt: itemDef.value };
+ break;
+
+ case 'gte':
+ item = { $gte: itemDef.value };
+ break;
+
+ case 'lt':
+ item = { $lt: itemDef.value };
+ break;
+
+ case 'lte':
+ item = { $lte: itemDef.value };
+ break;
+
+ case 'in':
+ item = { $in: itemDef.value.toString().split('|') };
+ break;
+
+ case 'nin':
+ item = { $nin: itemDef.value.toString().split('|') };
+ break;
+
+ case 're':
+ item = { $regex: itemDef.value.toString() };
+ break;
+
+ default:
+ throw new Error('Unsupported or missing filter operator ' + itemDef.operator);
+ }
+
+ if (logicalOperator === 'or') {
+ let clause = { };
+ clause[itemDef.field] = item;
+ clauses.push(clause);
+ }
+ else {
+ filter[itemDef.field] = item;
+ }
+ }
+
+ if (logicalOperator === 'or') {
+ filter = { $or: clauses };
+ }
+
+ if (onlyValid) {
+ filter.isValid = { $ne: false };
+ }
+
+ return filter;
+}
+
+
+/**
+ * Create query filter for single document with identifier fallback
+ * @param {string} identifier
+ */
+function filterForOne (identifier) {
+
+ const filterOpts = [ { identifier } ];
+
+ // fallback to "identifier = _id"
+ if (checkForHexRegExp.test(identifier)) {
+ filterOpts.push({ _id: ObjectID(identifier) });
+ }
+
+ return { $or: filterOpts };
+}
+
+
+/**
+ * Create query filter to check whether the document already exists in the storage.
+ * This function resolves eventual fallback deduplication.
+ * @param {string} identifier - identifier of document to check its existence in the storage
+ * @param {Object} doc - document to check its existence in the storage
+ * @param {Array} dedupFallbackFields - fields that all need to be matched to identify document via fallback deduplication
+ * @returns {Object} - query filter for mongo or null in case of no identifying possibility
+ */
+function identifyingFilter (identifier, doc, dedupFallbackFields) {
+
+ const filterItems = [];
+
+ if (identifier) {
+ // standard identifier field (APIv3)
+ filterItems.push({ identifier: identifier });
+
+ // fallback to "identifier = _id" (APIv1)
+ if (checkForHexRegExp.test(identifier)) {
+ filterItems.push({ identifier: { $exists: false }, _id: ObjectID(identifier) });
+ }
+ }
+
+ // let's deal with eventual fallback deduplication
+ if (!_.isEmpty(doc) && _.isArray(dedupFallbackFields) && dedupFallbackFields.length > 0) {
+ let dedupFilterItems = [];
+
+ _.each(dedupFallbackFields, function addDedupField (field) {
+
+ if (doc[field] !== undefined) {
+
+ let dedupFilterItem = { };
+ dedupFilterItem[field] = doc[field];
+ dedupFilterItems.push(dedupFilterItem);
+ }
+ });
+
+ if (dedupFilterItems.length === dedupFallbackFields.length) { // all dedup fields are present
+
+ dedupFilterItems.push({ identifier: { $exists: false } }); // force not existing identifier for fallback deduplication
+ filterItems.push({ $and: dedupFilterItems });
+ }
+ }
+
+ if (filterItems.length > 0)
+ return { $or: filterItems };
+ else
+ return null; // we don't have any filtering rule to identify the document in the storage
+}
+
+
+module.exports = {
+ normalizeDoc,
+ parseFilter,
+ filterForOne,
+ identifyingFilter
+};
\ No newline at end of file
diff --git a/lib/api3/storageSocket.js b/lib/api3/storageSocket.js
new file mode 100644
index 00000000000..e8c08310d2b
--- /dev/null
+++ b/lib/api3/storageSocket.js
@@ -0,0 +1,145 @@
+'use strict';
+
+const apiConst = require('./const');
+
+/**
+ * Socket.IO broadcaster of any storage change
+ */
+function StorageSocket (app, env, ctx) {
+
+ const self = this;
+
+ const LOG_GREEN = '\x1B[32m'
+ , LOG_MAGENTA = '\x1B[35m'
+ , LOG_RESET = '\x1B[0m'
+ , LOG = LOG_GREEN + 'STORAGE SOCKET: ' + LOG_RESET
+ , LOG_ERROR = LOG_MAGENTA + 'STORAGE SOCKET: ' + LOG_RESET
+ , NAMESPACE = '/storage'
+ ;
+
+
+ /**
+ * Initialize socket namespace and bind the events
+ * @param {Object} io Socket.IO object to multiplex namespaces
+ */
+ self.init = function init (io) {
+ self.io = io;
+
+ self.namespace = io.of(NAMESPACE);
+ self.namespace.on('connection', function onConnected (socket) {
+
+ const remoteIP = socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress;
+ console.log(LOG + 'Connection from client ID: ', socket.client.id, ' IP: ', remoteIP);
+
+ socket.on('disconnect', function onDisconnect () {
+ console.log(LOG + 'Disconnected client ID: ', socket.client.id);
+ });
+
+ socket.on('subscribe', function onSubscribe (message, returnCallback) {
+ self.subscribe(socket, message, returnCallback);
+ });
+ });
+
+ ctx.bus.on('storage-socket-create', self.emitCreate);
+ ctx.bus.on('storage-socket-update', self.emitUpdate);
+ ctx.bus.on('storage-socket-delete', self.emitDelete);
+ };
+
+
+ /**
+ * Authorize Socket.IO client and subscribe him to authorized rooms
+ * @param {Object} socket
+ * @param {Object} message input message from the client
+ * @param {Function} returnCallback function for returning a value back to the client
+ */
+ self.subscribe = function subscribe (socket, message, returnCallback) {
+ const shouldCallBack = typeof(returnCallback) === 'function';
+
+ if (message && message.accessToken) {
+ return ctx.authorization.resolveAccessToken(message.accessToken, function resolveFinish (err, auth) {
+ if (err) {
+ console.log(`${LOG_ERROR} Authorization failed for accessToken:`, message.accessToken);
+
+ if (shouldCallBack) {
+ returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN });
+ }
+ return err;
+ }
+ else {
+ return self.subscribeAuthorized(socket, message, auth, returnCallback);
+ }
+ });
+ }
+
+ console.log(`${LOG_ERROR} Authorization failed for message:`, message);
+ if (shouldCallBack) {
+ returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN});
+ }
+ };
+
+
+ /**
+ * Subscribe already authorized Socket.IO client to his rooms
+ * @param {Object} socket
+ * @param {Object} message input message from the client
+ * @param {Object} auth authorization of the client
+ * @param {Function} returnCallback function for returning a value back to the client
+ */
+ self.subscribeAuthorized = function subscribeAuthorized (socket, message, auth, returnCallback) {
+ const shouldCallBack = typeof(returnCallback) === 'function';
+ const enabledCols = app.get('enabledCollections');
+ const cols = Array.isArray(message.collections) ? message.collections : enabledCols;
+ const subscribed = [];
+
+ for (const col of cols) {
+ if (enabledCols.includes(col)) {
+ const permission = (col === 'settings') ? `api:${col}:admin` : `api:${col}:read`;
+
+ if (ctx.authorization.checkMultiple(permission, auth.shiros)) {
+ socket.join(col);
+ subscribed.push(col);
+ }
+ }
+ }
+
+ const doc = subscribed.length > 0
+ ? { success: true, collections: subscribed }
+ : { success: false, message: apiConst.MSG.SOCKET_UNAUTHORIZED_TO_ANY };
+ if (shouldCallBack) {
+ returnCallback(doc);
+ }
+ return doc;
+ };
+
+
+ /**
+ * Emit create event to the subscribers (of the collection's room)
+ * @param {Object} event
+ */
+ self.emitCreate = function emitCreate (event) {
+ self.namespace.to(event.colName)
+ .emit('create', event);
+ };
+
+
+ /**
+ * Emit update event to the subscribers (of the collection's room)
+ * @param {Object} event
+ */
+ self.emitUpdate = function emitUpdate (event) {
+ self.namespace.to(event.colName)
+ .emit('update', event);
+ };
+
+
+ /**
+ * Emit delete event to the subscribers (of the collection's room)
+ * @param {Object} event
+ */
+ self.emitDelete = function emitDelete (event) {
+ self.namespace.to(event.colName)
+ .emit('delete', event);
+ }
+}
+
+module.exports = StorageSocket;
\ No newline at end of file
diff --git a/lib/api3/swagger.js b/lib/api3/swagger.js
new file mode 100644
index 00000000000..ff965061c87
--- /dev/null
+++ b/lib/api3/swagger.js
@@ -0,0 +1,41 @@
+'use strict';
+
+const express = require('express')
+ , fs = require('fs')
+ ;
+
+
+function setupSwaggerUI (app) {
+
+ const serveSwaggerDef = function serveSwaggerDef (req, res) {
+ res.sendFile(__dirname + '/swagger.yaml');
+ };
+ app.get('/swagger', serveSwaggerDef);
+
+ const swaggerUiAssetPath = require('swagger-ui-dist').getAbsoluteFSPath();
+ const swaggerFiles = express.static(swaggerUiAssetPath);
+
+ const urlRegex = /url: "[^"]*",/;
+
+ const patchIndex = function patchIndex (req, res) {
+ const indexContent = fs.readFileSync(`${swaggerUiAssetPath}/index.html`)
+ .toString()
+ .replace(urlRegex, 'url: "../swagger.yaml",');
+ res.send(indexContent);
+ };
+
+ app.get('/swagger-ui-dist', function getSwaggerRoot (req, res) {
+ let targetUrl = req.originalUrl;
+ if (!targetUrl.endsWith('/')) {
+ targetUrl += '/';
+ }
+ targetUrl += 'index.html';
+ res.redirect(targetUrl);
+ });
+ app.get('/swagger-ui-dist/index.html', patchIndex);
+
+ app.use('/swagger-ui-dist', swaggerFiles);
+}
+
+
+module.exports = setupSwaggerUI;
\ No newline at end of file
diff --git a/lib/api3/swagger.yaml b/lib/api3/swagger.yaml
new file mode 100644
index 00000000000..5cbb2a05544
--- /dev/null
+++ b/lib/api3/swagger.yaml
@@ -0,0 +1,1647 @@
+openapi: 3.0.0
+servers:
+ - url: '/api/v3'
+info:
+ version: "3.0.1"
+ title: Nightscout API
+ contact:
+ name: NS development discussion channel
+ url: https://gitter.im/nightscout/public
+ license:
+ name: AGPL 3
+ url: 'https://www.gnu.org/licenses/agpl.txt'
+ description:
+ Nightscout API v3 is a component of cgm-remote-monitor project. It aims to provide lightweight, secured and HTTP REST compliant interface for your T1D treatment data exchange.
+
+
+ API v3 uses these environment variables, among other things:
+
+ - Security switch (optional, default = `true`)
+
API3_SECURITY_ENABLE=true
+ You can turn the whole security mechanism off, e.g. for debugging or development purposes,
+ but this should never be set to false in production.
+
+
+ - Number of minutes of acceptable time skew between client's and server's clock (optional, default = 5)
+
API3_TIME_SKEW_TOLERANCE=5
+ This security parameter is used for preventing anti-replay attacks, specifically when checking the time from `Date` header.
+
+
+ - Maximum limit count of documents retrieved from single query
+
API3_MAX_LIMIT=1000
+
+
+ - Autopruning of obsolete documents (optional, default is only `DEVICESTATUS`=60)
+
+ You can specify for which collections autopruning will be activated and length of retention period in days, e.g. "Hold 60 days of devicestatus, automatically delete older documents, hold 365 days of treatments and entries, automatically delete older documents."
+
+
+ - Fallback deduplication switch (optional, default = true)
+
API3_DEDUP_FALLBACK_ENABLED=true
+ API3 uses the `identifier` field for document identification and mutual distinction within a single collection. There is automatic deduplication implemented matching the equal `identifier` field. E.g. `CREATE` operation for document having the same `identifier` as another one existing in the database is automatically transformed into `UPDATE` operation of the document found in the database.
+
+ Documents not created via API v3 usually does not have any `identifier` field, but we would like to have some form of deduplication for them, too. This fallback deduplication is turned on by having set `API3_DEDUP_FALLBACK_ENABLED` to `true`.
+ When searching the collection in database, the document is found to be a duplicate only when either he has equal `identifier` or he has no `identifier` and meets:
+
`devicestatus` collection: equal combination of `created_at` and `device`
+
+ `entries` collection: equal combination of `date` and `type`
+
+ `food` collection: equal `created_at`
+
+ `profile` collection: equal `created_at`
+
+ `treatments` collection: equal combination of `created_at` and `eventType`
+
+
+
+ - Fallback switch for adding `created_at` field along the `date` field (optional, default = true)
+
API3_CREATED_AT_FALLBACK_ENABLED=true
+ Standard APIv3 document model uses only `date` field for storing a timestamp of the event recorded by the document. But there is a fallback option to fill `created_at` field as well automatically on each insert/update, just to keep all older components working.
+
+tags:
+ - name: generic
+ description: Generic operations with each database collection (devicestatus, entries, food, profile, settings, treatments)
+ - name: other
+ description: All other various operations
+
+
+paths:
+ /{collection}:
+ parameters:
+ - in: path
+ name: collection
+ description: Collection to which the operation is targeted
+ required: true
+ schema:
+ $ref: '#/components/schemas/paramCollection'
+
+ - $ref: '#/components/parameters/dateHeader'
+ - $ref: '#/components/parameters/nowParam'
+ - $ref: '#/components/parameters/tokenParam'
+
+ ######################################################################################
+ get:
+ tags:
+ - generic
+ summary: 'SEARCH: Search documents from the collection'
+ operationId: SEARCH
+ description: General search operation through documents of one collection, matching the specified filtering criteria. You can apply:
+
+
+ 1) filtering - combining any number of filtering parameters
+
+
+ 2) ordering - using `sort` or `sort$desc` parameter
+
+
+ 3) paging - using `limit` and `skip` parameters
+
+
+ When there is no document matching the filtering criteria, HTTP status 204 is returned with empty response content. Otherwise HTTP 200 code is returned with JSON array of matching documents as a response content.
+
+
+ This operation requires `read` permission for the API and the collection (e.g. `*:*:read`, `api:*:read`, `*:treatments:read`, `api:treatments:read`).
+
+
+ The only exception is the `settings` collection which requires `admin` permission (`api:settings:admin`), because the settings of each application should be isolated and kept secret. You need to know the concrete identifier to access the app's settings.
+
+
+ parameters:
+ - $ref: '#/components/parameters/filterParams'
+ - $ref: '#/components/parameters/sortParam'
+ - $ref: '#/components/parameters/sortDescParam'
+ - $ref: '#/components/parameters/limitParam'
+ - $ref: '#/components/parameters/skipParam'
+ - $ref: '#/components/parameters/fieldsParam'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ $ref: '#/components/responses/search200'
+ 204:
+ $ref: '#/components/responses/search204'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 406:
+ $ref: '#/components/responses/406NotAcceptable'
+
+
+ ######################################################################################
+ post:
+ tags:
+ - generic
+ summary: 'CREATE: Inserts a new document into the collection'
+ description:
+ Using this operation you can insert new documents into collection. Normally the operation ends with 201 HTTP status code, `Last-Modified` and `Location` headers specified and with an empty response content. `identifier` can be parsed from the `Location` response header.
+
+
+ When the document to post is marked as a duplicate (using rules described at `API3_DEDUP_FALLBACK_ENABLED` switch), the update operation takes place instead of inserting. In this case the original document in the collection is found and it gets updated by the actual operation POST body. Finally the operation ends with 204 HTTP status code along with `Last-Modified` and correct `Location` headers.
+
+
+ This operation provides autopruning of the collection (if autopruning is enabled).
+
+
+ This operation requires `create` (and/or `update` for deduplication) permission for the API and the collection (e.g. `api:treatments:create` and `api:treatments:update`)
+
+ requestBody:
+ description: JSON with new document to insert
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocumentToPost'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 201:
+ $ref: '#/components/responses/201CreatedLocation'
+ 204:
+ $ref: '#/components/responses/204NoContentLocation'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 422:
+ $ref: '#/components/responses/422UnprocessableEntity'
+
+
+ #return HTTP STATUS 400 for all other verbs (PUT, PATCH, DELETE,...)
+
+
+ /{collection}/{identifier}:
+ parameters:
+ - in: path
+ name: collection
+ description: Collection to which the operation is targeted
+ required: true
+ schema:
+ $ref: '#/components/schemas/paramCollection'
+ - in: path
+ name: identifier
+ description: Identifier of the document to which the operation is targeted
+ required: true
+ schema:
+ $ref: '#/components/schemas/paramIdentifier'
+
+ - $ref: '#/components/parameters/dateHeader'
+ - $ref: '#/components/parameters/nowParam'
+ - $ref: '#/components/parameters/tokenParam'
+
+ ######################################################################################
+ get:
+ tags:
+ - generic
+ summary: 'READ: Retrieves a single document from the collection'
+ description:
+ Basically this operation looks for a document matching the `identifier` field returning 200 or 404 HTTP status code.
+
+
+ If the document has been found in the collection but it had already been deleted, 410 HTTP status code with empty response content is to be returned.
+
+
+ When `If-Modified-Since` header is used and its value is greater than the timestamp of the document in the collection, 304 HTTP status code with empty response content is returned. It means that the document has not been modified on server since the last retrieval to client side.
+ With `If-Modified-Since` header and less or equal timestamp `srvModified` a normal 200 HTTP status with full response is returned.
+
+
+ This operation requires `read` permission for the API and the collection (e.g. `api:treatments:read`)
+
+ parameters:
+ - $ref: '#/components/parameters/ifModifiedSinceHeader'
+ - $ref: '#/components/parameters/fieldsParam'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ $ref: '#/components/responses/read200'
+ 304:
+ $ref: '#/components/responses/304NotModified'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 406:
+ $ref: '#/components/responses/406NotAcceptable'
+ 410:
+ $ref: '#/components/responses/410Gone'
+
+
+ ######################################################################################
+ put:
+ tags:
+ - generic
+ summary: 'UPDATE: Updates a document in the collection'
+ description:
+ Normally the document with the matching `identifier` will be replaced in the collection by the whole JSON request body and 204 HTTP status code will be returned with empty response body.
+
+
+ If the document has been found in the collection but it had already been deleted, 410 HTTP status code with empty response content is to be returned.
+
+
+ When no document with `identifier` has been found in the collection, then an insert operation takes place instead of updating. Finally 201 HTTP status code is returned with only `Last-Modified` header (`identifier` is already known from the path parameter).
+
+
+ You can also specify `If-Unmodified-Since` request header including your timestamp of document's last modification. If the document has been modified by somebody else on the server afterwards (and you do not know about it), the 412 HTTP status code is returned cancelling the update operation. You can use this feature to prevent race condition problems.
+
+
+ This operation provides autopruning of the collection (if autopruning is enabled).
+
+
+ This operation requires `update` (and/or `create`) permission for the API and the collection (e.g. `api:treatments:update` and `api:treatments:create`)
+
+ parameters:
+ - $ref: '#/components/parameters/ifUnmodifiedSinceHeader'
+
+ requestBody:
+ description: JSON of new version of document (`identifier` in JSON is ignored if present)
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocumentToPost'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 201:
+ $ref: '#/components/responses/201Created'
+ 204:
+ $ref: '#/components/responses/204NoContentLocation'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 412:
+ $ref: '#/components/responses/412PreconditionFailed'
+ 410:
+ $ref: '#/components/responses/410Gone'
+ 422:
+ $ref: '#/components/responses/422UnprocessableEntity'
+
+
+ ######################################################################################
+ patch:
+ tags:
+ - generic
+ summary: 'PATCH: Partially updates document in the collection'
+ description:
+ Normally the document with the matching `identifier` will be retrieved from the collection and it will be patched by all specified fields from the JSON request body. Finally 204 HTTP status code will be returned with empty response body.
+
+
+ If the document has been found in the collection but it had already been deleted, 410 HTTP status code with empty response content is to be returned.
+
+
+ When no document with `identifier` has been found in the collection, then the operation ends with 404 HTTP status code.
+
+
+ You can also specify `If-Unmodified-Since` request header including your timestamp of document's last modification. If the document has been modified by somebody else on the server afterwards (and you do not know about it), the 412 HTTP status code is returned cancelling the update operation. You can use this feature to prevent race condition problems.
+
+
+ `PATCH` operation can save some bandwidth for incremental document updates in comparison with `GET` - `UPDATE` operation sequence.
+
+
+ While patching the document, the field `modifiedBy` is automatically set to the authorized subject's name.
+
+
+ This operation provides autopruning of the collection (if autopruning is enabled).
+
+
+ This operation requires `update` permission for the API and the collection (e.g. `api:treatments:update`)
+
+ parameters:
+ - $ref: '#/components/parameters/ifUnmodifiedSinceHeader'
+
+ requestBody:
+ description: JSON of new version of document (`identifier` in JSON is ignored if present)
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocumentToPost'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 204:
+ $ref: '#/components/responses/204NoContentLocation'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 412:
+ $ref: '#/components/responses/412PreconditionFailed'
+ 410:
+ $ref: '#/components/responses/410Gone'
+ 422:
+ $ref: '#/components/responses/422UnprocessableEntity'
+
+
+ ######################################################################################
+ delete:
+ tags:
+ - generic
+ summary: 'DELETE: Deletes a document from the collection'
+ description:
+ If the document has already been deleted, the operation will succeed anyway. Normally, documents are not really deleted from the collection but they are only marked as deleted. For special cases the deletion can be irreversible using `permanent` parameter.
+
+
+ This operation provides autopruning of the collection (if autopruning is enabled).
+
+
+ This operation requires `delete` permission for the API and the collection (e.g. `api:treatments:delete`)
+
+
+ parameters:
+ - $ref: '#/components/parameters/permanentParam'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 204:
+ description: Successful operation - empty response
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 422:
+ $ref: '#/components/responses/422UnprocessableEntity'
+
+
+ ######################################################################################
+ /{collection}/history:
+ parameters:
+ - in: path
+ name: collection
+ description: Collection to which the operation is targeted
+ required: true
+ schema:
+ $ref: '#/components/schemas/paramCollection'
+
+ - $ref: '#/components/parameters/dateHeader'
+ - $ref: '#/components/parameters/nowParam'
+ - $ref: '#/components/parameters/tokenParam'
+
+ get:
+ tags:
+ - generic
+ summary: 'HISTORY: Retrieves incremental changes since timestamp'
+ operationId: HISTORY
+ description:
+ HISTORY operation is intended for continuous data synchronization with other systems.
+
+ Every insertion, update and deletion will be included in the resulting JSON array of documents (since timestamp in `Last-Modified` request header value). All changes are listed chronologically in response with 200 HTTP status code. The maximum listed `srvModified` timestamp is also stored in `Last-Modified` and `ETag` response headers that you can use for future, directly following synchronization. You can also limit the array's length using `limit` parameter.
+
+
+ Deleted documents will appear with `isValid` = `false` field.
+
+
+ When there is no change detected since the timestamp the operation ends with 204 HTTP status code and empty response content.
+
+
+ HISTORY operation has a fallback mechanism in place for documents, which were not created by API v3. For such documents `srvModified` is virtually assigned from the `date` field (for `entries` collection) or from the `created_at` field (for other collections).
+
+
+ This operation requires `read` permission for the API and the collection (e.g. `api:treatments:read`)
+
+
+ The only exception is the `settings` collection which requires `admin` permission (`api:settings:admin`), because the settings of each application should be isolated and kept secret. You need to know the concrete identifier to access the app's settings.
+
+
+ parameters:
+ - $ref: '#/components/parameters/lastModifiedRequiredHeader'
+ - $ref: '#/components/parameters/limitParam'
+ - $ref: '#/components/parameters/fieldsParam'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ $ref: '#/components/responses/history200'
+ 204:
+ $ref: '#/components/responses/history204'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 406:
+ $ref: '#/components/responses/406NotAcceptable'
+
+
+ ######################################################################################
+ /{collection}/history/{lastModified}:
+ parameters:
+ - in: path
+ name: collection
+ description: Collection to which the operation is targeted
+ required: true
+ schema:
+ $ref: '#/components/schemas/paramCollection'
+
+ - in: path
+ name: lastModified
+ description: Starting timestamp (in UNIX epoch format, defined with respect to server's clock) since which the changes in documents are to be listed. Query for modified documents is made using "greater than" operator (not including equal timestamps).
+ required: true
+ schema:
+ type: integer
+ format: int64
+
+ - $ref: '#/components/parameters/dateHeader'
+ - $ref: '#/components/parameters/nowParam'
+ - $ref: '#/components/parameters/tokenParam'
+
+ get:
+ tags:
+ - generic
+ summary: 'HISTORY: Retrieves incremental changes since timestamp'
+ operationId: HISTORY2
+ description:
+ This HISTORY operation variant is more precise than the previous one with `Last-Modified` request HTTP header), because it does not loose milliseconds precision.
+
+
+ Since this variant queries for changed documents by timestamp precisely and exclusively, the last modified document does not repeat itself in following calls. That is the reason why is this variant more suitable for continuous synchronization with other systems.
+
+
+ This variant behaves quite the same as the previous one in all other aspects.
+
+
+ parameters:
+ - $ref: '#/components/parameters/limitParam'
+ - $ref: '#/components/parameters/fieldsParam'
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ $ref: '#/components/responses/history200'
+ 204:
+ $ref: '#/components/responses/history204'
+ 400:
+ $ref: '#/components/responses/400BadRequest'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+ 404:
+ $ref: '#/components/responses/404NotFound'
+ 406:
+ $ref: '#/components/responses/406NotAcceptable'
+
+
+ ######################################################################################
+ /version:
+
+ get:
+ tags:
+ - other
+ summary: 'VERSION: Returns actual version information'
+ description: No authentication is needed for this commnad (it is public)
+ responses:
+ 200:
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Version'
+
+
+ ######################################################################################
+ /status:
+
+ get:
+ tags:
+ - other
+ summary: 'STATUS: Returns actual version information and all permissions granted for API'
+ description:
+ This operation requires authorization in contrast with VERSION operation.
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Status'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+
+ ######################################################################################
+ /lastModified:
+ parameters:
+ - $ref: '#/components/parameters/dateHeader'
+ - $ref: '#/components/parameters/nowParam'
+ - $ref: '#/components/parameters/tokenParam'
+
+ get:
+ tags:
+ - other
+ summary: 'LAST MODIFIED: Retrieves timestamp of the last modification of every collection'
+ operationId: LAST-MODIFIED
+ description:
+ LAST MODIFIED operation inspects collections separately (in parallel) and for each of them it finds the date of any last modification (insertion, update, deletion).
+
+ Not only `srvModified`, but also `date` and `created_at` fields are inspected (as a fallback to previous API).
+
+
+ This operation requires `read` permission for the API and the collections (e.g. `api:treatments:read`). For each collection the permission is checked separately, you will get timestamps only for those collections that you have access to.
+
+ security:
+ - apiKeyAuth: []
+
+ responses:
+ 200:
+ $ref: '#/components/responses/lastModified200'
+ 401:
+ $ref: '#/components/responses/401Unauthorized'
+ 403:
+ $ref: '#/components/responses/403Forbidden'
+
+######################################################################################
+components:
+
+ parameters:
+
+ dateHeader:
+ in: header
+ name: Date
+ schema:
+ type: string
+ required: false
+ description:
+ Timestamp (defined by client's clock) when the HTTP request was constructed on client.
+ This mandatory header serves as an anti-replay precaution. After a period of time (specified by `API3_TIME_SKEW_TOLERANCE`) the message won't be valid any more and it will be denied with HTTP 401 Unauthorized code.
+ This can be set alternatively in `now` query parameter.
+
+ Example:
+
+
+
Date: Wed, 17 Oct 2018 05:13:00 GMT
+
+
+ nowParam:
+ in: query
+ name: now
+ schema:
+ type: integer
+ format: int64
+ required: false
+ description:
+ Timestamp (defined by client's clock) when the HTTP request was constructed on client.
+ This mandatory parameter serves as an anti-replay precaution. After a period of time (specified by `API3_TIME_SKEW_TOLERANCE`) the message won't be valid any more and it will be denied with HTTP 401 Unauthorized code.
+ This can be set alternatively in `Date` header.
+
+
+ Example:
+
+
+
now=1525383610088
+
+
+ tokenParam:
+ in: query
+ name: token
+ schema:
+ type: string
+ required: false
+ description:
+ An alternative way of authorization - passing accessToken in a query parameter.
+
+
+ Example:
+
+
+
token=testadmin-bf2591231bd2c042
+
+
+ limitParam:
+ in: query
+ name: limit
+ schema:
+ type: integer
+ minimum: 1
+ default: stored in API3_MAX_LIMIT environment variable (usually 1000)
+ example: 100
+ description: Maximum number of documents to get in result array
+
+ skipParam:
+ in: query
+ name: skip
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ example: 0
+ description:
+ Number of documents to skip from collection query before
+ loading them into result array (used for pagination)
+
+ sortParam:
+ in: query
+ name: sort
+ schema:
+ type: string
+ required: false
+ description:
+ Field name by which the sorting of documents is performed. This parameter cannot be combined with `sort$desc` parameter.
+
+ sortDescParam:
+ in: query
+ name: sort$desc
+ schema:
+ type: string
+ required: false
+ description:
+ Field name by which the descending (reverse) sorting of documents is performed. This parameter cannot be combined with `sort` parameter.
+
+ permanentParam:
+ in: query
+ name: permanent
+ schema:
+ type: boolean
+ required: false
+ description:
+ If true, the deletion will be irreversible and it will not appear in `HISTORY` operation. Normally there is no reason for setting this flag.
+
+
+ fieldsParam:
+ in: query
+ name: fields
+ schema:
+ type: string
+ default: '_all'
+ required: false
+ examples:
+ all:
+ value: '_all'
+ summary: All fields will be returned (default behaviour)
+ customSet:
+ value: 'date,insulin'
+ summary: Only fields date and insulin will be returned
+ description: A chosen set of fields to return in response. Either you can enumerate specific fields of interest or use the predefined set. Sample parameter values:
+
+
+ _all: All fields will be returned (default value)
+
+
+ date,insulin: Only fields `date` and `insulin` will be returned
+
+
+ filterParams:
+ in: query
+ name: filter_parameters
+ schema:
+ type: string
+ description:
+ Any number of filtering operators.
+
+
+ Each filtering operator has name like `$`, e.g. `carbs$gt=2` which represents filtering rule "The field carbs must be present and greater than 2".
+
+
+ You can choose from operators:
+
+
+ `eq`=equals, `insulin$eq=1.5`
+
+
+ `ne`=not equals, `insulin$ne=1.5`
+
+
+ `gt`=greater than, `carbs$gt=30`
+
+
+ `gte`=greater than or equal, `carbs$gte=30`
+
+
+ `lt`=less than, `carbs$lt=30`
+
+
+ `lte`=less than or equal, `carbs$lte=30`
+
+
+ `in`=in specified set, `type$in=sgv|mbg|cal`
+
+
+ `nin`=not in specified set, `eventType$nin=Temp%20Basal|Temporary%20Target`
+
+
+ `re`=regex pattern, `eventType$re=Temp.%2A`
+
+
+ When filtering by field `date`, `created_at`, `srvModified` or `srvCreated`, you can choose from three input formats
+
+ - Unix epoch in milliseconds (1525383610088)
+
+ - Unix epoch in seconds (1525383610)
+
+ - ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00')
+
+
+ The date is always queried in a normalized form - UTC with zero offset and with the correct format (1525383610088 for `date`, '2018-05-03T21:40:10.088Z' for `created_at`).
+
+ lastModifiedRequiredHeader:
+ in: header
+ name: Last-Modified
+ schema:
+ type: string
+ required: true
+ description:
+ Starting timestamp (defined with respect to server's clock) since which the changes in documents are to be listed, formatted as:
+
+
+ <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
+
+
+ Example:
+
+
+
Last-Modified: Wed, 17 Oct 2018 05:13:00 GMT
+
+
+ ifModifiedSinceHeader:
+ in: header
+ name: If-Modified-Since
+ schema:
+ type: string
+ required: false
+ description:
+ Timestamp (defined with respect to server's clock) of the last document modification formatted as:
+
+
+ <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
+
+
+ If this header is present, the operation will compare its value with the srvModified timestamp of the document at first and the operation result then may differ. The srvModified timestamp was defined by server's clock.
+
+
+ Example:
+
+
+
If-Modified-Since: Wed, 17 Oct 2018 05:13:00 GMT
+
+
+ ifUnmodifiedSinceHeader:
+ in: header
+ name: If-Unmodified-Since
+ schema:
+ type: string
+ required: false
+ description:
+ Timestamp (defined with respect to server's clock) of the last document modification formatted as:
+
+
+ <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
+
+
+ If this header is present, the operation will compare its value with the srvModified timestamp of the document at first and the operation result then may differ. The srvModified timestamp was defined by server's clock.
+
+
+ Example:
+
+
+
If-Unmodified-Since: Wed, 17 Oct 2018 05:13:00 GMT
+
+
+ ######################################################################################
+ responses:
+
+ 201Created:
+ description: Successfully created a new document in collection
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+
+ 201CreatedLocation:
+ description: Successfully created a new document in collection
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+ 'Location':
+ $ref: '#/components/schemas/headerLocation'
+
+ 204NoContent:
+ description: Successfully finished operation
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+
+ 204NoContentLocation:
+ description: Successfully finished operation
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+ 'Location':
+ $ref: '#/components/schemas/headerLocation'
+
+ 304NotModified:
+ description: The document has not been modified on the server since timestamp specified in If-Modified-Since header
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+
+ 400BadRequest:
+ description: The request is malformed. There may be some required parameters missing or there are unrecognized parameters present.
+
+ 401Unauthorized:
+ description: The request was not successfully authenticated using access token or JWT, or the request has missing `Date` header or it contains an expired timestamp, so that the request cannot continue due to the security policy.
+
+ 403Forbidden:
+ description: Insecure HTTP scheme used or the request has been successfully authenticated, but the security subject is not authorized for the operation.
+
+ 404NotFound:
+ description: The collection or document specified was not found.
+
+ 406NotAcceptable:
+ description: The requested content type (in `Accept` header) is not supported.
+
+ 412PreconditionFailed:
+ description: The document has already been modified on the server since specified timestamp (in If-Unmodified-Since header).
+
+ 410Gone:
+ description: The requested document has already been deleted.
+
+ 422UnprocessableEntity:
+ description: The client request is well formed but a server validation error occured. Eg. when trying to modify or delete a read-only document (having `isReadOnly=true`).
+
+ search200:
+ description: Successful operation returning array of documents matching the filtering criteria
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+ text/csv:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+
+ search204:
+ description: Successful operation - no documents matching the filtering criteria
+
+ read200:
+ description: The document has been succesfully found and its JSON form returned in the response content.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Document'
+ text/csv:
+ schema:
+ $ref: '#/components/schemas/Document'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/Document'
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+
+ history200:
+ description:
+ Changed documents since specified timestamp
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+ text/csv:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/DocumentArray'
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModifiedMaximum'
+ 'ETag':
+ $ref: '#/components/schemas/headerEtagLastModifiedMaximum'
+
+ history204:
+ description: No changes detected
+
+ lastModified200:
+ description: Successful operation returning the timestamps
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LastModifiedResult'
+
+ ######################################################################################
+ schemas:
+
+ headerLocation:
+ type: string
+ description:
+ Location of document - the relative part of URL. This can be used to parse the identifier
+ of just created document.
+
+ Example=/api/v3/treatments/53409478-105f-11e9-ab14-d663bd873d93
+
+ headerLastModified:
+ type: string
+ description:
+ Timestamp of the last document modification on the server, formatted as
+
+ ', :: GMT'.
+
+ This field is relevant only for documents which were somehow modified by API v3
+ (inserted, updated or deleted) and it was generated using server's clock.
+
+ Example='Wed, 17 Oct 2018 05:13:00 GMT'
+
+ headerLastModifiedMaximum:
+ type: string
+ description:
+ The latest (maximum) `srvModified` field of all returning documents, formatted as
+
+ ', :: GMT'.
+
+ Example='Wed, 17 Oct 2018 05:13:00 GMT'
+
+ headerEtagLastModifiedMaximum:
+ type: string
+ description:
+ The latest (maximum) `srvModified` field of all returning documents.
+ This header does not loose milliseconds from the date (unlike the `Last-Modified` header).
+
+ Example='W/"1525383610088"'
+
+ paramCollection:
+ type: string
+ enum:
+ - devicestatus
+ - entries
+ - food
+ - profile
+ - settings
+ - treatments
+ example: 'treatments'
+
+ paramIdentifier:
+ type: string
+ example: '53409478-105f-11e9-ab14-d663bd873d93'
+
+
+ DocumentBase:
+ description: Shared base for all documents
+ properties:
+ identifier:
+ description:
+ Main addressing, required field that identifies document in the collection.
+
+
+ The client should not create the identifier, the server automatically assigns it when the document is inserted.
+
+
+ The server calculates the identifier in such a way that duplicate records are automatically merged (deduplicating is made by `date`, `device` and `eventType` fields).
+
+
+ The best practise for all applications is not to loose identifiers from received documents, but save them carefully for other consumer applications/systems.
+
+
+ API v3 has a fallback mechanism in place, for documents without `identifier` field the `identifier` is set to internal `_id`, when reading or addressing these documents.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ type: string
+ example: '53409478-105f-11e9-ab14-d663bd873d93'
+
+ date:
+ type: integer
+ format: int64
+ description:
+ Required timestamp when the record or event occured, you can choose from three input formats
+
+ - Unix epoch in milliseconds (1525383610088)
+
+ - Unix epoch in seconds (1525383610)
+
+ - ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00')
+
+
+ The date is always stored in a normalized form - UTC with zero offset. If UTC offset was present, it is going to be set in the `utcOffset` field.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 1525383610088
+
+ utcOffset:
+ type: integer
+ description:
+ Local UTC offset (timezone) of the event in minutes. This field can be set either directly by the client (in the incoming document) or it is automatically parsed from the `date` field.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 120
+
+ app:
+ type: string
+ description:
+ Application or system in which the record was entered by human or device for the first time.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: xdrip
+
+ device:
+ type: string
+ description:
+ The device from which the data originated (including serial number of the device, if it is relevant and safe).
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 'dexcom G5'
+
+ _id:
+ description: Internally assigned database id. This field is for internal server purposes only, clients communicate with API by using identifier field.
+ type: string
+ example: '58e9dfbc166d88cc18683aac'
+
+ srvCreated:
+ type: integer
+ format: int64
+ description:
+ The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 1525383610088
+
+ subject:
+ type: string
+ description:
+ Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 'uploader'
+
+ srvModified:
+ type: integer
+ format: int64
+ description:
+ The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted).
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: 1525383610088
+
+ modifiedBy:
+ type: string
+ description:
+ Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: admin
+
+ isValid:
+ type: boolean
+ description:
+ A flag set by the server only for deleted documents. This field appears
+ only within history operation and for documents which were deleted by API v3 (and they always have a false value)
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ example: false
+
+
+ isReadOnly:
+ type: boolean
+ description:
+ A flag set by client that locks the document from any changes. Every document marked with `isReadOnly=true` is forever immutable and cannot even be deleted.
+
+
+ Any attempt to modify the read-only document will end with status 422 UNPROCESSABLE ENTITY.
+
+
+ example: true
+
+ required:
+ - date
+ - app
+
+
+ DeviceStatus:
+ description: State of physical device, which is a technical part of the whole T1D compensation system
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+ some_property:
+ type: string
+ description: ...
+
+
+ Entry:
+ description: Blood glucose measurements and CGM calibrations
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+
+ type:
+ type: string
+ description: 'sgv, mbg, cal, etc'
+
+ sgv:
+ type: number
+ description: The glucose reading. (only available for sgv types)
+
+ direction:
+ type: string
+ description: Direction of glucose trend reported by CGM. (only available for sgv types)
+ example: '"DoubleDown", "SingleDown", "FortyFiveDown", "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "NOT COMPUTABLE", "RATE OUT OF RANGE" for xdrip'
+
+ noise:
+ type: number
+ description: Noise level at time of reading. (only available for sgv types)
+ example: 'xdrip: 0, 1, 2=high, 3=high_for_predict, 4=very high, 5=extreme'
+
+ filtered:
+ type: number
+ description: The raw filtered value directly from CGM transmitter. (only available for sgv types)
+
+ unfiltered:
+ type: number
+ description: The raw unfiltered value directly from CGM transmitter. (only available for sgv types)
+
+ rssi:
+ type: number
+ description: The signal strength from CGM transmitter. (only available for sgv types)
+
+ units:
+ type: string
+ example: '"mg", "mmol"'
+ description: The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field.
+
+
+ Food:
+ description: Nutritional values of food
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+
+ food:
+ type: string
+ description: 'food, quickpick'
+
+ category:
+ type: string
+ description: Name for a group of related records
+
+ subcategory:
+ type: string
+ description: Name for a second level of groupping
+
+ name:
+ type: string
+ description: Name of the food described
+
+ portion:
+ type: number
+ description: Number of units (e.g. grams) of the whole portion described
+
+ unit:
+ type: string
+ example: '"g", "ml", "oz"'
+ description: Unit for the portion
+
+ carbs:
+ type: number
+ description: Amount of carbs in the portion in grams
+
+ fat:
+ type: number
+ description: Amount of fat in the portion in grams
+
+ protein:
+ type: number
+ description: Amount of proteins in the portion in grams
+
+ energy:
+ type: number
+ description: Amount of energy in the portion in kJ
+
+ gi:
+ type: number
+ description: 'Glycemic index (1=low, 2=medium, 3=high)'
+
+ hideafteruse:
+ type: boolean
+ description: Flag used for quickpick
+
+ hidden:
+ type: boolean
+ description: Flag used for quickpick
+
+ position:
+ type: number
+ description: Ordering field for quickpick
+
+ portions:
+ type: number
+ description: component multiplier if defined inside quickpick compound
+
+ foods:
+ type: array
+ description: Neighbour documents (from food collection) that together make a quickpick compound
+ items:
+ $ref: '#/components/schemas/Food'
+
+
+ Profile:
+ description: Parameters describing body functioning relative to T1D + compensation parameters
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+ some_property:
+ type: string
+ description: ...
+
+
+ Settings:
+ description:
+ A document representing persisted settings of some application or system (it could by Nightscout itself as well). This pack of options serves as a backup or as a shared centralized storage for multiple client instances. It is a probably good idea to `PATCH` the document instead of `UPDATE` operation, e.g. when changing one settings option in a client application.
+
+
+ `identifier` represents a client application name here, e.g. `xdrip` or `aaps`.
+
+
+ `Settings` collection has a more specific authorization required. For the `SEARCH` operation within this collection, you need an `admin` permission, such as `api:settings:admin`. The goal is to isolate individual client application settings.
+
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+ some_property:
+ type: string
+ description: ...
+
+
+ Treatment:
+ description: T1D compensation action
+ allOf:
+ - $ref: '#/components/schemas/DocumentBase'
+ - type: object
+ properties:
+ eventType:
+ type: string
+ example: '"BG Check", "Snack Bolus", "Meal Bolus", "Correction Bolus", "Carb Correction", "Combo Bolus", "Announcement", "Note", "Question", "Exercise", "Site Change", "Sensor Start", "Sensor Change", "Pump Battery Change", "Insulin Change", "Temp Basal", "Profile Switch", "D.A.D. Alert", "Temporary Target", "OpenAPS Offline", "Bolus Wizard"'
+ description: The type of treatment event.
+
+
+ Note: this field is immutable by the client (it cannot be updated or patched)
+
+
+ # created_at:
+ # type: string
+ # description: The date of the event, might be set retroactively.
+ glucose:
+ type: string
+ description: Current glucose.
+ glucoseType:
+ type: string
+ example: '"Sensor", "Finger", "Manual"'
+ description: Method used to obtain glucose, Finger or Sensor.
+ units:
+ type: string
+ example: '"mg/dl", "mmol/l"'
+ description: The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field when `glucose` is entered.
+ carbs:
+ type: number
+ description: Amount of carbs given.
+ protein:
+ type: number
+ description: Amount of protein given.
+ fat:
+ type: number
+ description: Amount of fat given.
+ insulin:
+ type: number
+ description: Amount of insulin, if any.
+ duration:
+ type: number
+ description: Duration in minutes.
+ preBolus:
+ type: number
+ description: How many minutes the bolus was given before the meal started.
+ splitNow:
+ type: number
+ description: Immediate part of combo bolus (in percent).
+ splitExt:
+ type: number
+ description: Extended part of combo bolus (in percent).
+ percent:
+ type: number
+ description: Eventual basal change in percent.
+ absolute:
+ type: number
+ description: Eventual basal change in absolute value (insulin units per hour).
+ targetTop:
+ type: number
+ description: Top limit of temporary target.
+ targetBottom:
+ type: number
+ description: Bottom limit of temporary target.
+ profile:
+ type: string
+ description: Name of the profile to which the pump has been switched.
+ reason:
+ type: string
+ description: For example the reason why the profile has been switched or why the temporary target has been set.
+ notes:
+ type: string
+ description: Description/notes of treatment.
+ enteredBy:
+ type: string
+ description: Who entered the treatment.
+
+
+ DocumentToPost:
+ description: Single document
+ type: object
+ oneOf:
+ - $ref: '#/components/schemas/DeviceStatus'
+ - $ref: '#/components/schemas/Entry'
+ - $ref: '#/components/schemas/Food'
+ - $ref: '#/components/schemas/Profile'
+ - $ref: '#/components/schemas/Settings'
+ - $ref: '#/components/schemas/Treatment'
+ example:
+ 'identifier': '53409478-105f-11e9-ab14-d663bd873d93'
+ 'date': 1532936118000
+ 'utcOffset': 120
+ 'carbs': 10
+ 'insulin': 1
+ 'eventType': 'Snack Bolus'
+ 'app': 'xdrip'
+ 'subject': 'uploader'
+
+
+ Document:
+ description: Single document
+ xml:
+ name: 'item'
+ type: object
+ oneOf:
+ - $ref: '#/components/schemas/DeviceStatus'
+ - $ref: '#/components/schemas/Entry'
+ - $ref: '#/components/schemas/Food'
+ - $ref: '#/components/schemas/Profile'
+ - $ref: '#/components/schemas/Settings'
+ - $ref: '#/components/schemas/Treatment'
+ example:
+ 'identifier': '53409478-105f-11e9-ab14-d663bd873d93'
+ 'date': 1532936118000
+ 'utcOffset': 120
+ 'carbs': 10
+ 'insulin': 1
+ 'eventType': 'Snack Bolus'
+ 'srvCreated': 1532936218000
+ 'srvModified': 1532936218000
+ 'app': 'xdrip'
+ 'subject': 'uploader'
+ 'modifiedBy': 'admin'
+
+
+ DeviceStatusArray:
+ description: Array of documents
+ type: array
+ items:
+ $ref: '#/components/schemas/DeviceStatus'
+
+
+ EntryArray:
+ description: Array of documents
+ type: array
+ items:
+ $ref: '#/components/schemas/Entry'
+
+
+ FoodArray:
+ description: Array of documents
+ type: array
+ items:
+ $ref: '#/components/schemas/Food'
+
+
+ ProfileArray:
+ description: Array of documents
+ type: array
+ items:
+ $ref: '#/components/schemas/Profile'
+
+
+ SettingsArray:
+ description: Array of settings
+ type: array
+ items:
+ $ref: '#/components/schemas/Settings'
+
+
+ TreatmentArray:
+ description: Array of documents
+ type: array
+ items:
+ $ref: '#/components/schemas/Treatment'
+
+
+ DocumentArray:
+ type: object
+ xml:
+ name: 'items'
+ oneOf:
+ - $ref: '#/components/schemas/DeviceStatusArray'
+ - $ref: '#/components/schemas/EntryArray'
+ - $ref: '#/components/schemas/FoodArray'
+ - $ref: '#/components/schemas/ProfileArray'
+ - $ref: '#/components/schemas/SettingsArray'
+ - $ref: '#/components/schemas/TreatmentArray'
+
+
+ Version:
+ description: Information about versions
+ type: object
+ properties:
+
+ version:
+ description: The whole Nightscout instance version
+ type: string
+ example: '0.10.2-release-20171201'
+
+ apiVersion:
+ description: API v3 subsystem version
+ type: string
+ example: '3.0.0'
+
+ srvDate:
+ description: Actual server date and time in UNIX epoch format
+ type: number
+ example: 1532936118000
+
+ storage:
+ type: object
+ properties:
+
+ type:
+ description: Type of storage engine used
+ type: string
+ example: 'mongodb'
+
+ version:
+ description: Version of the storage engine
+ type: string
+ example: '4.0.6'
+
+
+ Status:
+ description: Information about versions and API permissions
+ allOf:
+ - $ref: '#/components/schemas/Version'
+ - type: object
+ properties:
+
+ apiPermissions:
+ type: object
+ properties:
+ devicestatus:
+ type: string
+ example: 'crud'
+ entries:
+ type: string
+ example: 'r'
+ food:
+ type: string
+ example: 'crud'
+ profile:
+ type: string
+ example: 'r'
+ treatments:
+ type: string
+ example: 'crud'
+
+
+ LastModifiedResult:
+ description: Result of LAST MODIFIED operation
+ properties:
+ srvDate:
+ description:
+ Actual storage server date (Unix epoch in ms).
+ type: integer
+ format: int64
+ example: 1556260878776
+
+ collections:
+ type: object
+ description:
+ Collections which the user have read access to.
+ properties:
+ devicestatus:
+ description:
+ Timestamp of the last modification (Unix epoch in ms), `null` when there is no timestamp found.
+ type: integer
+ format: int64
+ example: 1556260760974
+ treatments:
+ description:
+ Timestamp of the last modification (Unix epoch in ms), `null` when there is no timestamp found.
+ type: integer
+ format: int64
+ example: 1553374184169
+ entries:
+ description:
+ Timestamp of the last modification (Unix epoch in ms), `null` when there is no timestamp found.
+ type: integer
+ format: int64
+ example: 1556260758768
+ profile:
+ description:
+ Timestamp of the last modification (Unix epoch in ms), `null` when there is no timestamp found.
+ type: integer
+ format: int64
+ example: 1548524042744
+
+ ######################################################################################
+ securitySchemes:
+
+ accessToken:
+ type: apiKey
+ name: token
+ in: query
+ description: >-
+ Add token as query item in the URL or as HTTP header. You can manage access token in
+ `/admin`.
+
+ Each operation requires a specific permission that has to be granted (via security role) to the security subject, which was authenticated by `token` parameter/header or `JWT`. E.g. for creating new `devicestatus` document via API you need `api:devicestatus:create` permission.
+
+ jwtoken:
+ type: http
+ scheme: bearer
+ description: Use this if you know the temporary json webtoken.
+ bearerFormat: JWT
\ No newline at end of file
diff --git a/lib/authorization/index.js b/lib/authorization/index.js
index 0c0c6f5f2a7..b81578e0dca 100644
--- a/lib/authorization/index.js
+++ b/lib/authorization/index.js
@@ -55,6 +55,8 @@ function init (env, ctx) {
return token;
}
+ authorization.extractToken = extractToken;
+
function authorizeAccessToken (req) {
var accessToken = req.query.token;
@@ -139,22 +141,25 @@ function init (env, ctx) {
authorization.resolve = function resolve (data, callback) {
+ var defaultShiros = storage.rolesToShiros(defaultRoles);
+
+ if (storage.doesAccessTokenExist(data.api_secret)) {
+ authorization.resolveAccessToken (data.api_secret, callback, defaultShiros);
+ return;
+ }
+
if (authorizeAdminSecret(data.api_secret)) {
var admin = shiroTrie.new();
admin.add(['*']);
return callback(null, { shiros: [ admin ] });
}
- var defaultShiros = storage.rolesToShiros(defaultRoles);
-
if (data.token) {
jwt.verify(data.token, env.api_secret, function result(err, verified) {
if (err) {
return callback(err, { shiros: [ ] });
} else {
- var resolved = storage.resolveSubjectAndPermissions(verified.accessToken);
- var shiros = resolved.shiros.concat(defaultShiros);
- return callback(null, { shiros: shiros, subject: resolved.subject });
+ authorization.resolveAccessToken (verified.accessToken, callback, defaultShiros);
}
});
} else {
@@ -163,10 +168,25 @@ function init (env, ctx) {
};
+ authorization.resolveAccessToken = function resolveAccessToken (accessToken, callback, defaultShiros) {
+
+ if (!defaultShiros) {
+ defaultShiros = storage.rolesToShiros(defaultRoles);
+ }
+
+ let resolved = storage.resolveSubjectAndPermissions(accessToken);
+ if (!resolved || !resolved.subject) {
+ return callback('Subject not found', null);
+ }
+
+ let shiros = resolved.shiros.concat(defaultShiros);
+ return callback(null, { shiros: shiros, subject: resolved.subject });
+ };
+
authorization.isPermitted = function isPermitted (permission, opts) {
- opts = mkopts(opts);
+ mkopts(opts);
authorization.seenPermissions = _.chain(authorization.seenPermissions)
.push(permission)
.sort()
@@ -177,6 +197,25 @@ function init (env, ctx) {
var remoteIP = getRemoteIP(req);
+ var secret = adminSecretFromRequest(req);
+ var defaultShiros = storage.rolesToShiros(defaultRoles);
+
+ if (storage.doesAccessTokenExist(secret)) {
+ var resolved = storage.resolveSubjectAndPermissions (secret);
+
+ if (authorization.checkMultiple(permission, resolved.shiros)) {
+ console.log(LOG_GRANTED, remoteIP, resolved.accessToken , permission);
+ next();
+ } else if (authorization.checkMultiple(permission, defaultShiros)) {
+ console.log(LOG_GRANTED, remoteIP, resolved.accessToken, permission, 'default');
+ next( );
+ } else {
+ console.log(LOG_DENIED, remoteIP, resolved.accessToken, permission);
+ res.sendJSONStatus(res, consts.HTTP_UNAUTHORIZED, 'Unauthorized', 'Invalid/Missing');
+ }
+ return;
+ }
+
if (authorizeAdminSecretWithRequest(req)) {
console.log(LOG_GRANTED, remoteIP, 'api-secret', permission);
next( );
@@ -184,7 +223,6 @@ function init (env, ctx) {
}
var token = extractToken(req);
- var defaultShiros = storage.rolesToShiros(defaultRoles);
if (token) {
jwt.verify(token, env.api_secret, function result(err, verified) {
diff --git a/lib/authorization/storage.js b/lib/authorization/storage.js
index 9999b9f3178..c032018d170 100644
--- a/lib/authorization/storage.js
+++ b/lib/authorization/storage.js
@@ -24,7 +24,7 @@ function init (env, ctx) {
function create (collection) {
function doCreate(obj, fn) {
- if (!obj.hasOwnProperty('created_at')) {
+ if (!Object.prototype.hasOwnProperty.call(obj, 'created_at')) {
obj.created_at = (new Date()).toISOString();
}
collection.insert(obj, function (err, doc) {
@@ -122,6 +122,12 @@ function init (env, ctx) {
, { name: 'activity', permissions: [ 'api:activity:create' ] }
];
+ storage.getSHA1 = function getSHA1 (message) {
+ var shasum = crypto.createHash('sha1');
+ shasum.update(message);
+ return shasum.digest('hex');
+ }
+
storage.reload = function reload (callback) {
storage.listRoles({sort: {name: 1}}, function listResults (err, results) {
@@ -152,6 +158,7 @@ function init (env, ctx) {
var abbrev = subject.name.toLowerCase().replace(/[\W]/g, '').substring(0, 10);
subject.digest = shasum.digest('hex');
subject.accessToken = abbrev + '-' + subject.digest.substring(0, 16);
+ subject.accessTokenDigest = storage.getSHA1(subject.accessToken);
}
return subject;
@@ -200,17 +207,28 @@ function init (env, ctx) {
};
storage.findSubject = function findSubject (accessToken) {
- var prefix = _.last(accessToken.split('-'));
+
+ if (!accessToken) return null;
+
+ var split_token = accessToken.split('-');
+ var prefix = split_token ? _.last(split_token) : '';
if (prefix.length < 16) {
return null;
}
return _.find(storage.subjects, function matches (subject) {
- return subject.digest.indexOf(prefix) === 0;
+ return subject.accessTokenDigest.indexOf(accessToken) === 0 || subject.digest.indexOf(prefix) === 0;
});
};
+ storage.doesAccessTokenExist = function doesAccessTokenExist(accessToken) {
+ if (storage.findSubject(accessToken)) {
+ return true;
+ }
+ return false;
+ }
+
storage.resolveSubjectAndPermissions = function resolveSubjectAndPermissions (accessToken) {
var shiros = [];
diff --git a/lib/bus.js b/lib/bus.js
index 5828130fa92..65faeea21a6 100644
--- a/lib/bus.js
+++ b/lib/bus.js
@@ -1,11 +1,11 @@
'use strict';
-
var Stream = require('stream');
function init (settings) {
var beats = 0;
var started = new Date( );
var interval = settings.heartbeat * 1000;
+ let busInterval;
var stream = new Stream;
@@ -24,9 +24,15 @@ function init (settings) {
stream.emit('tick', ictus( ));
}
+ stream.teardown = function ( ) {
+ console.log('Initiating server teardown');
+ clearInterval(busInterval);
+ stream.emit('teardown');
+ };
+
stream.readable = true;
stream.uptime = repeat;
- setInterval(repeat, interval);
+ busInterval = setInterval(repeat, interval);
return stream;
}
module.exports = init;
diff --git a/lib/client/browser-settings.js b/lib/client/browser-settings.js
index 4f990eb57f3..f45631f4b6f 100644
--- a/lib/client/browser-settings.js
+++ b/lib/client/browser-settings.js
@@ -71,6 +71,8 @@ function init (client, serverSettings, $) {
$('#basalrender').val(settings.extendedSettings.basal ? settings.extendedSettings.basal.render : 'none');
+ $('#bolusrender').val(settings.extendedSettings.bolus ? settings.extendedSettings.bolus.render : 'all');
+
if (settings.timeFormat === 24) {
$('#24-browser').prop('checked', true);
} else {
@@ -135,7 +137,24 @@ function init (client, serverSettings, $) {
});
$('#editprofilelink').toggle(settings.isEnabled('iob') || settings.isEnabled('cob') || settings.isEnabled('bwp') || settings.isEnabled('basal'));
+
+ //fetches token from url
+ var parts = (location.search || '?').substring(1).split('&');
+ var token = '';
+ parts.forEach(function (val) {
+ if (val.startsWith('token=')) {
+ token = val.substring('token='.length);
+ }
+ });
+ //if there is a token, append it to each of the links in the hamburger menu
+ if (token != '') {
+ token = '?token=' + token;
+ $('#reportlink').attr('href', 'report' + token);
+ $('#editprofilelink').attr('href', 'profile' + token);
+ $('#admintoolslink').attr('href', 'admin' + token);
+ $('#editfoodlink').attr('href', 'food' + token);
+ }
}
function wireForm () {
@@ -144,6 +163,7 @@ function init (client, serverSettings, $) {
storage.remove(name);
});
storage.remove('basalrender');
+ storage.remove('bolusrender');
event.preventDefault();
client.browserUtils.reload();
});
@@ -196,6 +216,7 @@ function init (client, serverSettings, $) {
, language: $('#language').val()
, scaleY: $('#scaleY').val()
, basalrender: $('#basalrender').val()
+ , bolusrender: $('#bolusrender').val()
, showPlugins: checkedPluginNames()
, storageVersion: STORAGE_VERSION
});
@@ -251,8 +272,15 @@ function init (client, serverSettings, $) {
settings.extendedSettings.basal = {};
}
- var stored = storage.get('basalrender');
- settings.extendedSettings.basal.render = stored !== null ? stored : settings.extendedSettings.basal.render;
+ var basalStored = storage.get('basalrender');
+ settings.extendedSettings.basal.render = basalStored !== null ? basalStored : settings.extendedSettings.basal.render;
+
+ if (!settings.extendedSettings.bolus) {
+ settings.extendedSettings.bolus = {};
+ }
+
+ var bolusStored = storage.get('bolusrender');
+ settings.extendedSettings.bolus.render = bolusStored !== null ? bolusStored : settings.extendedSettings.bolus.render;
} catch (err) {
console.error(err);
diff --git a/lib/client/browser-utils.js b/lib/client/browser-utils.js
index 4f920588f80..634afb2ab40 100644
--- a/lib/client/browser-utils.js
+++ b/lib/client/browser-utils.js
@@ -51,7 +51,7 @@ function init ($) {
function queryParms () {
var params = {};
- if (location.search) {
+ if ((typeof location !== 'undefined') && location.search) {
location.search.substr(1).split('&').forEach(function(item) {
// eslint-disable-next-line no-useless-escape
params[item.split('=')[0]] = item.split('=')[1].replace(/[_\+]/g, ' ');
diff --git a/lib/client/careportal.js b/lib/client/careportal.js
index 4a89ffcebc8..632ab24986f 100644
--- a/lib/client/careportal.js
+++ b/lib/client/careportal.js
@@ -4,6 +4,7 @@ var moment = require('moment-timezone');
var _ = require('lodash');
var parse_duration = require('parse-duration'); // https://www.npmjs.com/package/parse-duration
var times = require('../times');
+var consts = require('../constants');
var Storages = require('js-storage');
function init (client, $) {
@@ -13,12 +14,6 @@ function init (client, $) {
var storage = Storages.localStorage;
var units = client.settings.units;
- careportal.allEventTypes = client.plugins.getAllEventTypes(client.sbx);
-
- careportal.events = _.map(careportal.allEventTypes, function each (event) {
- return _.pick(event, ['val', 'name']);
- });
-
var eventTime = $('#eventTimeValue');
var eventDate = $('#eventDateValue');
@@ -44,10 +39,25 @@ function init (client, $) {
}
var inputMatrix = {};
+ var submitHooks = {};
+
+ function refreshEventTypes() {
+ careportal.allEventTypes = client.plugins.getAllEventTypes(client.sbx);
+
+ careportal.events = _.map(careportal.allEventTypes, function each (event) {
+ return _.pick(event, ['val', 'name']);
+ });
+
+ inputMatrix = {};
+ submitHooks = {};
- _.forEach(careportal.allEventTypes, function each (event) {
- inputMatrix[event.val] = _.pick(event, ['bg', 'insulin', 'carbs', 'protein', 'fat', 'prebolus', 'duration', 'percent', 'absolute', 'profile', 'split', 'reasons', 'targets']);
- });
+ _.forEach(careportal.allEventTypes, function each (event) {
+ inputMatrix[event.val] = _.pick(event, ['bg', 'insulin', 'carbs', 'protein', 'fat', 'prebolus', 'duration', 'percent', 'absolute', 'profile', 'split', 'reasons', 'targets']);
+ submitHooks[event.val] = event.submitHook;
+ });
+ }
+
+ refreshEventTypes();
careportal.filterInputs = function filterInputs (event) {
var eventType = $('#eventType').val();
@@ -84,7 +94,7 @@ function init (client, $) {
$('#reason').empty();
_.each(reasons, function eachReason (reason) {
- $('#reason').append('');
+ $('#reason').append('');
});
careportal.reasonable();
@@ -172,6 +182,8 @@ function init (client, $) {
};
careportal.prepare = function prepare () {
+ refreshEventTypes();
+
$('#profile').empty();
client.profilefunctions.listBasalProfiles().forEach(function(p) {
$('#profile').append('');
@@ -196,11 +208,14 @@ function init (client, $) {
};
function gatherData () {
+ var eventType = $('#eventType').val();
+ var selectedReason = $('#reason').val();
+
var data = {
enteredBy: $('#enteredBy').val()
- , eventType: $('#eventType').val()
+ , eventType: eventType
, glucose: $('#glucoseValue').val().replace(',', '.')
- , reason: $('#reason').val()
+ , reason: selectedReason
, targetTop: $('#targetTop').val().replace(',', '.')
, targetBottom: $('#targetBottom').val().replace(',', '.')
, glucoseType: $('#treatment-form').find('input[name=glucoseType]:checked').val()
@@ -211,14 +226,29 @@ function init (client, $) {
, duration: times.msecs(parse_duration($('#duration').val())).mins < 1 ? $('#duration').val() : times.msecs(parse_duration($('#duration').val())).mins
, percent: $('#percent').val()
, profile: $('#profile').val()
- , preBolus: parseInt($('#preBolus').val())
+ , preBolus: $('#preBolus').val()
, notes: $('#notes').val()
, units: client.settings.units
};
+ data.preBolus = parseInt(data.preBolus);
+
+ if (isNaN(data.preBolus)) {
+ delete data.preBolus;
+ }
+
+ var reasons = inputMatrix[eventType]['reasons'];
+ var reason = _.find(reasons, function matches (r) {
+ return r.name === selectedReason;
+ });
+
+ if (reason) {
+ data.reasonDisplay = reason.displayName;
+ }
+
if (units == "mmol") {
- data.targetTop = data.targetTop * 18;
- data.targetBottom = data.targetBottom * 18;
+ data.targetTop = data.targetTop * consts.MMOL_TO_MGDL;
+ data.targetBottom = data.targetBottom * consts.MMOL_TO_MGDL;
}
//special handling for absolute to support temp to 0
@@ -249,7 +279,11 @@ function init (client, $) {
data.splitExt = parseInt($('#insulinSplitExt').val()) || 0;
}
- return data;
+ let d = {};
+ Object.keys(data).forEach(function(key) {
+ if (data[key] != "" && data[key] != null) d[key] = data[key];
+ });
+ return d;
}
careportal.save = function save (event) {
@@ -272,17 +306,17 @@ function init (client, $) {
messages.push("Please enter a valid value for both top and bottom target to save a Temporary Target");
} else {
- let targetTop = data.targetTop;
- let targetBottom = data.targetBottom;
+ let targetTop = parseInt(data.targetTop);
+ let targetBottom = parseInt(data.targetBottom);
- let minTarget = 4 * 18;
- let maxTarget = 18 * 18;
+ let minTarget = 4 * consts.MMOL_TO_MGDL;
+ let maxTarget = 18 * consts.MMOL_TO_MGDL;
if (units == "mmol") {
- targetTop = Math.round(targetTop / 18.0 * 10) / 10;
- targetBottom = Math.round(targetBottom / 18.0 * 10) / 10;
- minTarget = Math.round(minTarget / 18.0 * 10) / 10;
- maxTarget = Math.round(maxTarget / 18.0 * 10) / 10;
+ targetTop = Math.round(targetTop / consts.MMOL_TO_MGDL * 10) / 10;
+ targetBottom = Math.round(targetBottom / consts.MMOL_TO_MGDL * 10) / 10;
+ minTarget = Math.round(minTarget / consts.MMOL_TO_MGDL * 10) / 10;
+ maxTarget = Math.round(maxTarget / consts.MMOL_TO_MGDL * 10) / 10;
}
if (targetTop > maxTarget) {
@@ -335,8 +369,8 @@ function init (client, $) {
var targetBottom = data.targetBottom;
if (units == "mmol") {
- targetTop = Math.round(data.targetTop / 18.0 * 10) / 10;
- targetBottom = Math.round(data.targetBottom / 18.0 * 10) / 10;
+ targetTop = Math.round(data.targetTop / consts.MMOL_TO_MGDL * 10) / 10;
+ targetBottom = Math.round(data.targetBottom / consts.MMOL_TO_MGDL * 10) / 10;
}
pushIf(data.targetTop, translate('Target Top') + ': ' + targetTop);
@@ -374,7 +408,19 @@ function init (client, $) {
window.alert(messages);
} else {
if (window.confirm(buildConfirmText(data))) {
- postTreatment(data);
+ var submitHook = submitHooks[data.eventType];
+ if (submitHook) {
+ submitHook(client, data, function (error) {
+ if (error) {
+ console.log("submit error = ", error);
+ alert(translate('Error') + ': ' + error);
+ } else {
+ client.browserUtils.closeDrawer('#treatmentDrawer');
+ }
+ });
+ } else {
+ postTreatment(data);
+ }
}
}
}
diff --git a/lib/client/chart.js b/lib/client/chart.js
index 32a15ba0155..d84dd0998b5 100644
--- a/lib/client/chart.js
+++ b/lib/client/chart.js
@@ -1,12 +1,24 @@
'use strict';
-// var _ = require('lodash');
+var _ = require('lodash');
var times = require('../times');
var d3locales = require('./d3locales');
-var padding = { bottom: 30 };
+var scrolling = false
+ , scrollNow = 0
+ , scrollBrushExtent = null
+ , scrollRange = null;
+
+var PADDING_BOTTOM = 30
+ , OPEN_TOP_HEIGHT = 8
+ , CONTEXT_MAX = 420
+ , CONTEXT_MIN = 36
+ , FOCUS_MAX = 510
+ , FOCUS_MIN = 30;
+
+var loadTime = Date.now();
function init (client, d3, $) {
- var chart = { };
+ var chart = {};
var utils = client.utils;
var renderer = client.renderer;
@@ -23,146 +35,190 @@ function init (client, d3, $) {
.attr('x', 0)
.attr('y', 0)
.append('g')
- .style('fill', 'none')
- .style('stroke', '#0099ff')
- .style('stroke-width', 2)
+ .style('fill', 'none')
+ .style('stroke', '#0099ff')
+ .style('stroke-width', 2)
.append('path').attr('d', 'M0,0 l' + dashWidth + ',' + dashWidth)
.append('path').attr('d', 'M' + dashWidth + ',0 l-' + dashWidth + ',' + dashWidth);
-
+
// arrow head
defs.append('marker')
- .attr({
- 'id': 'arrow',
- 'viewBox': '0 -5 10 10',
- 'refX': 5,
- 'refY': 0,
- 'markerWidth': 8,
- 'markerHeight': 8,
- 'orient': 'auto'
- })
+ .attr('id', 'arrow')
+ .attr('viewBox', '0 -5 10 10')
+ .attr('refX', 5)
+ .attr('refY', 0)
+ .attr('markerWidth', 8)
+ .attr('markerHeight', 8)
+ .attr('orient', 'auto')
.append('path')
- .attr('d', 'M0,-5L10,0L0,5')
- .attr('class', 'arrowHead');
+ .attr('d', 'M0,-5L10,0L0,5')
+ .attr('class', 'arrowHead');
+
+ var localeFormatter = d3.timeFormatLocale(d3locales.locale(client.settings.language));
+
+ function beforeBrushStarted () {
+ // go ahead and move the brush because
+ // a single click will not execute the brush event
+ var now = new Date();
+ var dx = chart.xScale2(now) - chart.xScale2(new Date(now.getTime() - client.focusRangeMS));
+
+ var cx = d3.mouse(this)[0];
+ var x0 = cx - dx / 2;
+ var x1 = cx + dx / 2;
- var localeFormatter = d3.locale(d3locales.locale(client.settings.language));
-
- function brushStarted ( ) {
+ var range = chart.xScale2.range();
+ var X0 = range[0];
+ var X1 = range[1];
+
+ var brush = x0 < X0 ? [X0, X0 + dx] : x1 > X1 ? [X1 - dx, X1] : [x0, x1];
+
+ chart.theBrush.call(chart.brush.move, brush);
+ }
+
+ function brushStarted () {
// update the opacity of the context data points to brush extent
chart.context.selectAll('circle')
.data(client.entries)
.style('opacity', 1);
}
- function brushEnded ( ) {
+ function brushEnded () {
// update the opacity of the context data points to brush extent
+ var selectedRange = chart.createAdjustedRange();
+ var from = selectedRange[0].getTime();
+ var to = selectedRange[1].getTime();
+
chart.context.selectAll('circle')
.data(client.entries)
- .style('opacity', function (d) { return renderer.highlightBrushPoints(d) });
+ .style('opacity', function(d) { return renderer.highlightBrushPoints(d, from, to) });
}
var extent = client.dataExtent();
var yScaleType;
if (client.settings.scaleY === 'linear') {
- yScaleType = d3.scale.linear;
+ yScaleType = d3.scaleLinear;
} else {
- yScaleType = d3.scale.log;
+ yScaleType = d3.scaleLog;
}
- var focusYDomain = [utils.scaleMgdl(30), utils.scaleMgdl(510)];
- var contextYDomain = [utils.scaleMgdl(36), utils.scaleMgdl(420)];
+ var focusYDomain = [utils.scaleMgdl(FOCUS_MIN), utils.scaleMgdl(FOCUS_MAX)];
+ var contextYDomain = [utils.scaleMgdl(CONTEXT_MIN), utils.scaleMgdl(CONTEXT_MAX)];
- function dynamicDomain() {
+ function dynamicDomain () {
// allow y-axis to extend all the way to the top of the basal area, but leave room to display highest value
var mult = 1.15
, targetTop = client.settings.thresholds.bgTargetTop
// filter to only use actual SGV's (not rawbg's) to set the view window.
// can switch to Logarithmic (non-dynamic) to see anything that doesn't fit in the dynamicDomain
- , mgdlMax = d3.max(client.entries, function (d) { if ( d.type === 'sgv') { return d.mgdl; } });
- // use the 99th percentile instead of max to avoid rescaling for 1 flukey data point
- // need to sort client.entries by mgdl first
- //, mgdlMax = d3.quantile(client.entries, 0.99, function (d) { return d.mgdl; });
+ , mgdlMax = d3.max(client.entries, function(d) { if (d.type === 'sgv') { return d.mgdl; } });
+ // use the 99th percentile instead of max to avoid rescaling for 1 flukey data point
+ // need to sort client.entries by mgdl first
+ //, mgdlMax = d3.quantile(client.entries, 0.99, function (d) { return d.mgdl; });
return [
- utils.scaleMgdl(30)
+ utils.scaleMgdl(FOCUS_MIN)
, Math.max(utils.scaleMgdl(mgdlMax * mult), utils.scaleMgdl(targetTop * mult))
];
}
- function dynamicDomainOrElse(defaultDomain) {
- if (client.settings.scaleY === 'linear' || client.settings.scaleY === 'log-dynamic') {
+ function dynamicDomainOrElse (defaultDomain) {
+ if (client.entries && (client.entries.length > 0) && (client.settings.scaleY === 'linear' || client.settings.scaleY === 'log-dynamic')) {
return dynamicDomain();
} else {
return defaultDomain;
}
}
-
+
// define the parts of the axis that aren't dependent on width or height
- var xScale = chart.xScale = d3.time.scale().domain(extent);
+ var xScale = chart.xScale = d3.scaleTime().domain(extent);
+ focusYDomain = dynamicDomainOrElse(focusYDomain);
var yScale = chart.yScale = yScaleType()
- .domain(dynamicDomainOrElse(focusYDomain));
+ .domain(focusYDomain);
- var xScale2 = chart.xScale2 = d3.time.scale().domain(extent);
+ var xScale2 = chart.xScale2 = d3.scaleTime().domain(extent);
+
+ contextYDomain = dynamicDomainOrElse(contextYDomain);
var yScale2 = chart.yScale2 = yScaleType()
- .domain(dynamicDomainOrElse(contextYDomain));
+ .domain(contextYDomain);
- chart.xScaleBasals = d3.time.scale().domain(extent);
+ chart.xScaleBasals = d3.scaleTime().domain(extent);
- chart.yScaleBasals = d3.scale.linear()
+ chart.yScaleBasals = d3.scaleLinear()
.domain([0, 5]);
- var tickFormat = localeFormatter.timeFormat.multi( [
- ['.%L', function(d) { return d.getMilliseconds(); }],
- [':%S', function(d) { return d.getSeconds(); }],
- [client.settings.timeFormat === 24 ? '%H:%M' : '%I:%M', function(d) { return d.getMinutes(); }],
- [client.settings.timeFormat === 24 ? '%H:%M' : '%-I %p', function(d) { return d.getHours(); }],
- ['%a %d', function(d) { return d.getDay() && d.getDate() !== 1; }],
- ['%b %d', function(d) { return d.getDate() !== 1; }],
- ['%B', function(d) { return d.getMonth(); }],
- ['%Y', function() { return true; }]
- ]);
+ var formatMillisecond = localeFormatter.format('.%L')
+ , formatSecond = localeFormatter.format(':%S')
+ , formatMinute = client.settings.timeFormat === 24 ? localeFormatter.format('%H:%M') :
+ localeFormatter.format('%-I:%M')
+ , formatHour = client.settings.timeFormat === 24 ? localeFormatter.format('%H:%M') :
+ localeFormatter.format('%-I %p')
+ , formatDay = localeFormatter.format('%a %d')
+ , formatWeek = localeFormatter.format('%b %d')
+ , formatMonth = localeFormatter.format('%B')
+ , formatYear = localeFormatter.format('%Y');
+
+ var tickFormat = function(date) {
+ return (d3.timeSecond(date) < date ? formatMillisecond :
+ d3.timeMinute(date) < date ? formatSecond :
+ d3.timeHour(date) < date ? formatMinute :
+ d3.timeDay(date) < date ? formatHour :
+ d3.timeMonth(date) < date ? (d3.timeWeek(date) < date ? formatDay : formatWeek) :
+ d3.timeYear(date) < date ? formatMonth :
+ formatYear)(date);
+ };
var tickValues = client.ticks(client);
- chart.xAxis = d3.svg.axis()
- .scale(xScale)
+ chart.xAxis = d3.axisBottom(xScale)
+
+ chart.xAxis = d3.axisBottom(xScale)
.tickFormat(tickFormat)
- .ticks(4)
- .orient('bottom');
+ .ticks(6);
- chart.yAxis = d3.svg.axis()
- .scale(yScale)
+ chart.yAxis = d3.axisLeft(yScale)
.tickFormat(d3.format('d'))
- .tickValues(tickValues)
- .orient('left');
+ .tickValues(tickValues);
- chart.xAxis2 = d3.svg.axis()
- .scale(xScale2)
+ chart.xAxis2 = d3.axisBottom(xScale2)
.tickFormat(tickFormat)
- .ticks(6)
- .orient('bottom');
+ .ticks(6);
- chart.yAxis2 = d3.svg.axis()
- .scale(yScale2)
+ chart.yAxis2 = d3.axisRight(yScale2)
.tickFormat(d3.format('d'))
- .tickValues(tickValues)
- .orient('right');
+ .tickValues(tickValues);
+
+ d3.select('tick')
+ .style('z-index', '10000');
// setup a brush
- chart.brush = d3.svg.brush()
- .x(xScale2)
- .on('brushstart', brushStarted)
+ chart.brush = d3.brushX()
+ .on('start', brushStarted)
.on('brush', function brush (time) {
- client.loadRetroIfNeeded();
+ // layouting the graph causes a brushed event
+ // ignore retro data load the first two seconds
+ if (Date.now() - loadTime > 2000) client.loadRetroIfNeeded();
client.brushed(time);
})
- .on('brushend', brushEnded);
+ .on('end', brushEnded);
+
+ chart.theBrush = null;
+
+ chart.futureOpacity = (function() {
+ var scale = d3.scaleLinear()
+ .domain([times.mins(25).msecs, times.mins(60).msecs])
+ .range([0.8, 0.1]);
- chart.futureOpacity = d3.scale.linear( )
- .domain([times.mins(25).msecs, times.mins(60).msecs])
- .range([0.8, 0.1]);
+ return function(delta) {
+ if (delta < 0) {
+ return null;
+ } else {
+ return scale(delta);
+ }
+ };
+ })();
// create svg and g to contain the chart contents
chart.charts = d3.select('#chartContainer').append('svg')
@@ -176,54 +232,82 @@ function init (client, d3, $) {
// create the x axis container
chart.focus.append('g')
- .attr('class', 'x axis');
+ .attr('class', 'x axis')
+ .style("font-size", "16px");
// create the y axis container
chart.focus.append('g')
- .attr('class', 'y axis');
+ .attr('class', 'y axis')
+ .style("font-size", "16px");
- chart.context = chart.charts.append('g').attr('class', 'chart-context');
+ chart.context = chart.charts.append('g')
+ .attr('class', 'chart-context');
// create the x axis container
chart.context.append('g')
- .attr('class', 'x axis');
+ .attr('class', 'x axis')
+ .style("font-size", "16px");
// create the y axis container
chart.context.append('g')
- .attr('class', 'y axis');
+ .attr('class', 'y axis')
+ .style("font-size", "16px");
+
+ chart.createBrushedRange = function() {
+ var brushedRange = chart.theBrush && d3.brushSelection(chart.theBrush.node()) || null;
- function createAdjustedRange() {
- var range = chart.brush.extent().slice();
+ var range = brushedRange && brushedRange.map(chart.xScale2.invert);
+ var dataExtent = client.dataExtent();
- var end = range[1].getTime() + client.forecastTime;
+ if (!brushedRange) {
+ // console.log('No current brushed range. Setting range to last focusRangeMS amount of available data');
+ range = dataExtent;
+ range[0] = new Date(range[1].getTime() - client.focusRangeMS);
+ }
+
+ var end = range[1].getTime();
if (!chart.inRetroMode()) {
- var lastSGVMills = client.latestSGV ? client.latestSGV.mills : client.now;
- end += (client.now - lastSGVMills);
+ end = client.now > dataExtent[1].getTime() ? client.now : dataExtent[1].getTime();
}
range[1] = new Date(end);
+ range[0] = new Date(end - client.focusRangeMS);
+
+ // console.log('createBrushedRange: ', brushedRange, range);
return range;
}
- chart.inRetroMode = function inRetroMode() {
- if (!chart.brush || !chart.xScale2) {
+ chart.createAdjustedRange = function() {
+ var adjustedRange = chart.createBrushedRange();
+
+ adjustedRange[1] = new Date(Math.max(adjustedRange[1].getTime(), client.forecastTime));
+
+ return adjustedRange;
+ }
+
+ chart.inRetroMode = function inRetroMode () {
+ var brushedRange = chart.theBrush && d3.brushSelection(chart.theBrush.node()) || null;
+
+ if (!brushedRange || !chart.xScale2) {
return false;
}
- var brushTime = chart.brush.extent()[1].getTime();
var maxTime = chart.xScale2.domain()[1].getTime();
+ var brushTime = chart.xScale2.invert(brushedRange[1]).getTime();
return brushTime < maxTime;
};
// called for initial update and updates for resize
- chart.update = function update(init) {
+ chart.update = function update (init) {
if (client.documentHidden && !init) {
console.info('Document Hidden, not updating - ' + (new Date()));
return;
}
+ chart.setForecastTime();
+
var chartContainer = $('#chartContainer');
if (chartContainer.length < 1) {
@@ -235,15 +319,16 @@ function init (client, d3, $) {
var dataRange = client.dataExtent();
var chartContainerRect = chartContainer[0].getBoundingClientRect();
var chartWidth = chartContainerRect.width;
- var chartHeight = chartContainerRect.height - padding.bottom;
+ var chartHeight = chartContainerRect.height - PADDING_BOTTOM;
// get the height of each chart based on its container size ratio
var focusHeight = chart.focusHeight = chartHeight * .7;
- var contextHeight = chart.contextHeight = chartHeight * .2;
+ var contextHeight = chart.contextHeight = chartHeight * .3;
chart.basalsHeight = focusHeight / 4;
// get current brush extent
- var currentBrushExtent = createAdjustedRange();
+ var currentRange = chart.createAdjustedRange();
+ var currentBrushExtent = chart.createBrushedRange();
// only redraw chart if chart size has changed
var widthChanged = (chart.prevChartWidth !== chartWidth);
@@ -259,14 +344,14 @@ function init (client, d3, $) {
//set the width and height of the SVG element
chart.charts.attr('width', chartWidth)
- .attr('height', chartHeight + padding.bottom);
+ .attr('height', chartHeight + PADDING_BOTTOM);
// ranges are based on the width and height available so reset
chart.xScale.range([0, chartWidth]);
chart.xScale2.range([0, chartWidth]);
chart.xScaleBasals.range([0, chartWidth]);
chart.yScale.range([focusHeight, 0]);
- chart.yScale2.range([chartHeight, chartHeight - contextHeight]);
+ chart.yScale2.range([contextHeight, 0]);
chart.yScaleBasals.range([0, focusHeight / 4]);
if (init) {
@@ -281,42 +366,49 @@ function init (client, d3, $) {
.call(chart.yAxis);
// if first run then just display axis with no transition
+ chart.context
+ .attr('transform', 'translate(0,' + focusHeight + ')')
+
chart.context.select('.x')
- .attr('transform', 'translate(0,' + chartHeight + ')')
+ .attr('transform', 'translate(0,' + contextHeight + ')')
.call(chart.xAxis2);
-// chart.basals.select('.y')
-// .attr('transform', 'translate(0,' + 0 + ')')
-// .call(chart.yAxisBasals);
-
- chart.context.append('g')
+ chart.theBrush = chart.context.append('g')
.attr('class', 'x brush')
- .call(d3.svg.brush().x(chart.xScale2).on('brush', client.brushed))
- .selectAll('rect')
- .attr('y', focusHeight)
- .attr('height', chartHeight - focusHeight);
+ .call(chart.brush)
+ .call(g => g.select(".overlay")
+ .datum({ type: 'selection' })
+ .on('mousedown touchstart', beforeBrushStarted));
+
+ chart.theBrush.selectAll('rect')
+ .attr('y', 0)
+ .attr('height', contextHeight)
+ .attr('width', '100%');
// disable resizing of brush
- d3.select('.x.brush').select('.background').style('cursor', 'move');
- d3.select('.x.brush').select('.resize.e').style('cursor', 'move');
- d3.select('.x.brush').select('.resize.w').style('cursor', 'move');
+ chart.context.select('.x.brush').select('.overlay').style('cursor', 'move');
+ chart.context.select('.x.brush').selectAll('.handle')
+ .style('cursor', 'move');
+
+ chart.context.select('.x.brush').select('.selection')
+ .style('visibility', 'hidden');
// add a line that marks the current time
chart.focus.append('line')
.attr('class', 'now-line')
.attr('x1', chart.xScale(new Date(client.now)))
- .attr('y1', chart.yScale(utils.scaleMgdl(30)))
+ .attr('y1', chart.yScale(focusYDomain[0]))
.attr('x2', chart.xScale(new Date(client.now)))
- .attr('y2', chart.yScale(utils.scaleMgdl(420)))
+ .attr('y2', chart.yScale(focusYDomain[1]))
.style('stroke-dasharray', ('3, 3'))
.attr('stroke', 'grey');
// add a y-axis line that shows the high bg threshold
chart.focus.append('line')
.attr('class', 'high-line')
- .attr('x1', chart.xScale(dataRange[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh)))
- .attr('x2', chart.xScale(dataRange[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh)))
.style('stroke-dasharray', ('1, 6'))
.attr('stroke', '#777');
@@ -324,9 +416,9 @@ function init (client, d3, $) {
// add a y-axis line that shows the high bg threshold
chart.focus.append('line')
.attr('class', 'target-top-line')
- .attr('x1', chart.xScale(dataRange[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop)))
- .attr('x2', chart.xScale(dataRange[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop)))
.style('stroke-dasharray', ('3, 3'))
.attr('stroke', 'grey');
@@ -334,9 +426,9 @@ function init (client, d3, $) {
// add a y-axis line that shows the low bg threshold
chart.focus.append('line')
.attr('class', 'target-bottom-line')
- .attr('x1', chart.xScale(dataRange[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom)))
- .attr('x2', chart.xScale(dataRange[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom)))
.style('stroke-dasharray', ('3, 3'))
.attr('stroke', 'grey');
@@ -344,9 +436,9 @@ function init (client, d3, $) {
// add a y-axis line that shows the low bg threshold
chart.focus.append('line')
.attr('class', 'low-line')
- .attr('x1', chart.xScale(dataRange[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow)))
- .attr('x2', chart.xScale(dataRange[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow)))
.style('stroke-dasharray', ('1, 6'))
.attr('stroke', '#777');
@@ -355,7 +447,7 @@ function init (client, d3, $) {
chart.context.append('line')
.attr('class', 'open-top')
.attr('stroke', '#111')
- .attr('stroke-width', 12);
+ .attr('stroke-width', OPEN_TOP_HEIGHT);
// add a x-axis line that closes the the brush container on left side
chart.context.append('line')
@@ -371,9 +463,9 @@ function init (client, d3, $) {
chart.context.append('line')
.attr('class', 'now-line')
.attr('x1', chart.xScale(new Date(client.now)))
- .attr('y1', chart.yScale2(utils.scaleMgdl(36)))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
.attr('x2', chart.xScale(new Date(client.now)))
- .attr('y2', chart.yScale2(utils.scaleMgdl(420)))
+ .attr('y2', chart.yScale2(contextYDomain[1]))
.style('stroke-dasharray', ('3, 3'))
.attr('stroke', 'grey');
@@ -400,96 +492,82 @@ function init (client, d3, $) {
} else {
// for subsequent updates use a transition to animate the axis to the new position
- var focusTransition = chart.focus.transition();
- focusTransition.select('.x')
+ chart.focus.select('.x')
.attr('transform', 'translate(0,' + focusHeight + ')')
.call(chart.xAxis);
- focusTransition.select('.y')
+ chart.focus.select('.y')
.attr('transform', 'translate(' + chartWidth + ', 0)')
.call(chart.yAxis);
- var contextTransition = chart.context.transition();
+ chart.context
+ .attr('transform', 'translate(0,' + focusHeight + ')')
- contextTransition.select('.x')
- .attr('transform', 'translate(0,' + chartHeight + ')')
+ chart.context.select('.x')
+ .attr('transform', 'translate(0,' + contextHeight + ')')
.call(chart.xAxis2);
- chart.basals.transition();
-
-// basalsTransition.select('.y')
-// .attr('transform', 'translate(0,' + 0 + ')')
-// .call(chart.yAxisBasals);
+ chart.basals;
// reset brush location
- chart.context.select('.x.brush')
- .selectAll('rect')
- .attr('y', focusHeight)
- .attr('height', chartHeight - focusHeight);
+ chart.theBrush.selectAll('rect')
+ .attr('y', 0)
+ .attr('height', contextHeight);
- // clear current brushs
- d3.select('.brush').call(chart.brush.clear());
+ // console.log('chart.update(): Redrawing old brush with new dimensions: ', currentBrushExtent);
// redraw old brush with new dimensions
- d3.select('.brush').transition().call(chart.brush.extent(currentBrushExtent));
+ chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2));
// transition lines to correct location
chart.focus.select('.high-line')
- .transition()
- .attr('x1', chart.xScale(currentBrushExtent[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh)))
- .attr('x2', chart.xScale(currentBrushExtent[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh)));
chart.focus.select('.target-top-line')
- .transition()
- .attr('x1', chart.xScale(currentBrushExtent[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop)))
- .attr('x2', chart.xScale(currentBrushExtent[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop)));
chart.focus.select('.target-bottom-line')
- .transition()
- .attr('x1', chart.xScale(currentBrushExtent[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom)))
- .attr('x2', chart.xScale(currentBrushExtent[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom)));
chart.focus.select('.low-line')
- .transition()
- .attr('x1', chart.xScale(currentBrushExtent[0]))
+ .attr('x1', chart.xScale.range()[0])
.attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow)))
- .attr('x2', chart.xScale(currentBrushExtent[1]))
+ .attr('x2', chart.xScale.range()[1])
.attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow)));
// transition open-top line to correct location
chart.context.select('.open-top')
- .transition()
- .attr('x1', chart.xScale2(currentBrushExtent[0]))
- .attr('y1', chart.yScale(utils.scaleMgdl(30)))
- .attr('x2', chart.xScale2(currentBrushExtent[1]))
- .attr('y2', chart.yScale(utils.scaleMgdl(30)));
+ .attr('x1', chart.xScale2(currentRange[0]))
+ .attr('y1', chart.yScale2(utils.scaleMgdl(CONTEXT_MAX)) + Math.floor(OPEN_TOP_HEIGHT/2.0)-1)
+ .attr('x2', chart.xScale2(currentRange[1]))
+ .attr('y2', chart.yScale2(utils.scaleMgdl(CONTEXT_MAX)) + Math.floor(OPEN_TOP_HEIGHT/2.0)-1);
// transition open-left line to correct location
chart.context.select('.open-left')
- .transition()
- .attr('x1', chart.xScale2(currentBrushExtent[0]))
- .attr('y1', focusHeight)
- .attr('x2', chart.xScale2(currentBrushExtent[0]))
- .attr('y2', chartHeight);
+ .attr('x1', chart.xScale2(currentRange[0]))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
+ .attr('x2', chart.xScale2(currentRange[0]))
+ .attr('y2', chart.yScale2(contextYDomain[1]));
// transition open-right line to correct location
chart.context.select('.open-right')
- .transition()
- .attr('x1', chart.xScale2(currentBrushExtent[1]))
- .attr('y1', focusHeight)
- .attr('x2', chart.xScale2(currentBrushExtent[1]))
- .attr('y2', chartHeight);
+ .attr('x1', chart.xScale2(currentRange[1]))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
+ .attr('x2', chart.xScale2(currentRange[1]))
+ .attr('y2', chart.yScale2(contextYDomain[1]));
// transition high line to correct location
chart.context.select('.high-line')
- .transition()
.attr('x1', chart.xScale2(dataRange[0]))
.attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetTop)))
.attr('x2', chart.xScale2(dataRange[1]))
@@ -497,7 +575,6 @@ function init (client, d3, $) {
// transition low line to correct location
chart.context.select('.low-line')
- .transition()
.attr('x1', chart.xScale2(dataRange[0]))
.attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom)))
.attr('x2', chart.xScale2(dataRange[1]))
@@ -505,64 +582,83 @@ function init (client, d3, $) {
}
}
- // update domain
- chart.xScale2.domain(dataRange);
+ chart.updateContext(dataRange);
+
chart.xScaleBasals.domain(dataRange);
- var updateBrush = d3.select('.brush').transition();
- updateBrush
- .call(chart.brush.extent([new Date(dataRange[1].getTime() - client.focusRangeMS), dataRange[1]]));
- client.brushed(true);
+ // console.log('chart.update(): Redrawing brush due to update: ', currentBrushExtent);
+
+ chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2));
+ };
+
+ chart.updateContext = function(dataRange_) {
+ if (client.documentHidden) {
+ console.info('Document Hidden, not updating - ' + (new Date()));
+ return;
+ }
+
+ // get current data range
+ var dataRange = dataRange_ || client.dataExtent();
+
+ // update domain
+ chart.xScale2.domain(dataRange);
renderer.addContextCircles();
// update x axis domain
chart.context.select('.x').call(chart.xAxis2);
-
};
- chart.scroll = function scroll (nowDate) {
- chart.xScale.domain(createAdjustedRange());
- chart.yScale.domain(dynamicDomainOrElse(focusYDomain));
- chart.xScaleBasals.domain(createAdjustedRange());
+ function scrollUpdate () {
+ var nowDate = scrollNow;
+
+ var currentBrushExtent = scrollBrushExtent;
+ var currentRange = scrollRange;
+
+ chart.setForecastTime();
+
+ chart.xScale.domain(currentRange);
+
+ focusYDomain = dynamicDomainOrElse(focusYDomain);
+
+ chart.yScale.domain(focusYDomain);
+ chart.xScaleBasals.domain(currentRange);
// remove all insulin/carb treatment bubbles so that they can be redrawn to correct location
d3.selectAll('.path').remove();
// transition open-top line to correct location
chart.context.select('.open-top')
- .attr('x1', chart.xScale2(chart.brush.extent()[0]))
- .attr('y1', chart.yScale(utils.scaleMgdl(30)))
- .attr('x2', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime)))
- .attr('y2', chart.yScale(utils.scaleMgdl(30)));
+ .attr('x1', chart.xScale2(currentRange[0]))
+ .attr('y1', chart.yScale2(contextYDomain[1]) + Math.floor(OPEN_TOP_HEIGHT / 2.0)-1)
+ .attr('x2', chart.xScale2(currentRange[1]))
+ .attr('y2', chart.yScale2(contextYDomain[1]) + Math.floor(OPEN_TOP_HEIGHT / 2.0)-1);
// transition open-left line to correct location
chart.context.select('.open-left')
- .attr('x1', chart.xScale2(chart.brush.extent()[0]))
- .attr('y1', chart.focusHeight)
- .attr('x2', chart.xScale2(chart.brush.extent()[0]))
- .attr('y2', chart.prevChartHeight);
+ .attr('x1', chart.xScale2(currentRange[0]))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
+ .attr('x2', chart.xScale2(currentRange[0]))
+ .attr('y2', chart.yScale2(contextYDomain[1]));
// transition open-right line to correct location
chart.context.select('.open-right')
- .attr('x1', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime)))
- .attr('y1', chart.focusHeight)
- .attr('x2', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime)))
- .attr('y2', chart.prevChartHeight);
+ .attr('x1', chart.xScale2(currentRange[1]))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
+ .attr('x2', chart.xScale2(currentRange[1]))
+ .attr('y2', chart.yScale2(contextYDomain[1]));
chart.focus.select('.now-line')
- .transition()
.attr('x1', chart.xScale(nowDate))
- .attr('y1', chart.yScale(utils.scaleMgdl(36)))
+ .attr('y1', chart.yScale(focusYDomain[0]))
.attr('x2', chart.xScale(nowDate))
- .attr('y2', chart.yScale(utils.scaleMgdl(420)));
+ .attr('y2', chart.yScale(focusYDomain[1]));
chart.context.select('.now-line')
- .transition()
- .attr('x1', chart.xScale2(chart.brush.extent()[1]))
- .attr('y1', chart.yScale2(utils.scaleMgdl(36)))
- .attr('x2', chart.xScale2(chart.brush.extent()[1]))
- .attr('y2', chart.yScale2(utils.scaleMgdl(420)));
+ .attr('x1', chart.xScale2(currentBrushExtent[1]))
+ .attr('y1', chart.yScale2(contextYDomain[0]))
+ .attr('x2', chart.xScale2(currentBrushExtent[1]))
+ .attr('y2', chart.yScale2(contextYDomain[1]));
// update x,y axis
chart.focus.select('.x.axis').call(chart.xAxis);
@@ -571,10 +667,79 @@ function init (client, d3, $) {
renderer.addBasals(client);
renderer.addFocusCircles();
- renderer.addTreatmentCircles();
+ renderer.addTreatmentCircles(nowDate);
renderer.addTreatmentProfiles(client);
renderer.drawTreatments(client);
+ // console.log('scrollUpdate(): Redrawing brush due to update: ', currentBrushExtent);
+
+ chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2));
+
+ scrolling = false;
+ }
+
+ chart.scroll = function scroll (nowDate) {
+ scrollNow = nowDate;
+ scrollBrushExtent = chart.createBrushedRange();
+ scrollRange = chart.createAdjustedRange();
+
+ if (!scrolling) {
+ requestAnimationFrame(scrollUpdate);
+ }
+
+ scrolling = true;
+ };
+
+ chart.getMaxForecastMills = function getMaxForecastMills () {
+ // limit lookahead to the same as lookback
+ var selectedRange = chart.createBrushedRange();
+ var to = selectedRange[1].getTime();
+ return to + client.focusRangeMS;
+ };
+
+ chart.getForecastData = function getForecastData () {
+
+ var maxForecastAge = chart.getMaxForecastMills();
+ var pointTypes = client.settings.showForecast.split(' ');
+
+ var points = pointTypes.reduce( function (points, type) {
+ return points.concat(client.sbx.pluginBase.forecastPoints[type] || []);
+ }, [] );
+
+ return _.filter(points, function isShown (point) {
+ return point.mills < maxForecastAge;
+ });
+
+ };
+
+ chart.setForecastTime = function setForecastTime () {
+
+ if (client.sbx.pluginBase.forecastPoints) {
+ var shownForecastPoints = chart.getForecastData();
+
+ // Get maximum time we will allow projected forward in time
+ // based on the number of hours the user has selected to show.
+ var maxForecastMills = chart.getMaxForecastMills();
+
+ var selectedRange = chart.createBrushedRange();
+ var to = selectedRange[1].getTime();
+
+ // Default min forecast projection times to the default amount of time to forecast
+ var minForecastMills = to + client.defaultForecastTime;
+ var availForecastMills = 0;
+
+ // Determine what the maximum forecast time is that is available in the forecast data
+ if (shownForecastPoints.length > 0) {
+ availForecastMills = _.max(_.map(shownForecastPoints, function(point) { return point.mills }));
+ }
+
+ // Limit the amount shown to the maximum time allowed to be projected forward based
+ // on the number of hours the user has selected to show
+ var forecastMills = Math.min(availForecastMills, maxForecastMills);
+
+ // Don't allow the forecast time to go below the minimum forecast time
+ client.forecastTime = Math.max(forecastMills, minForecastMills);
+ }
};
return chart;
diff --git a/lib/client/clock-client.js b/lib/client/clock-client.js
index 18b5116f94d..e6eec7190c6 100644
--- a/lib/client/clock-client.js
+++ b/lib/client/clock-client.js
@@ -37,11 +37,14 @@ client.render = function render (xhr) {
console.log('got data', xhr);
let rec;
+ let delta;
- xhr.some(element => {
- if (element.sgv) {
+ xhr.forEach(element => {
+ if (element.sgv && !rec) {
rec = element;
- return true;
+ }
+ else if (element.sgv && rec && delta==null) {
+ delta = (rec.sgv - element.sgv)/((rec.date - element.date)/(5*60*1000));
}
});
@@ -67,8 +70,14 @@ client.render = function render (xhr) {
// Convert BG to mmol/L if necessary.
if (window.serverSettings.settings.units === 'mmol') {
var displayValue = window.Nightscout.units.mgdlToMMOL(rec.sgv);
+ var deltaDisplayValue = window.Nightscout.units.mgdlToMMOL(delta);
} else {
displayValue = rec.sgv;
+ deltaDisplayValue = Math.round(delta);
+ }
+
+ if (deltaDisplayValue > 0) {
+ deltaDisplayValue = '+' + deltaDisplayValue;
}
// Insert the BG value text.
@@ -98,15 +107,7 @@ client.render = function render (xhr) {
if (m < 10) m = "0" + m;
$('#clock').text(h + ":" + m);
- var queryDict = {};
- location.search.substr(1).split("&").forEach(function(item) { queryDict[item.split("=")[0]] = item.split("=")[1] });
-
- if (!window.serverSettings.settings.showClockClosebutton || !queryDict['showClockClosebutton']) {
- $('#close').css('display', 'none');
- }
-
- // defined in the template this is loaded into
- // eslint-disable-next-line no-undef
+ /* global clockFace */
if (clockFace === 'clock-color') {
var bgHigh = window.serverSettings.settings.thresholds.bgHigh;
@@ -122,53 +123,69 @@ client.render = function render (xhr) {
var green = 'rgba(134,207,70,1)';
var blue = 'rgba(78,143,207,1)';
- var darkRed = 'rgba(183,9,21,1)';
- var darkYellow = 'rgba(214,168,0,1)';
- var darkGreen = 'rgba(110,192,70,1)';
- var darkBlue = 'rgba(78,143,187,1)';
-
var elapsedMins = Math.round(((now - last) / 1000) / 60);
// Insert the BG stale time text.
- $('#staleTime').text(elapsedMins + ' minutes ago');
+ let staleTimeText;
+ if (elapsedMins == 0) {
+ staleTimeText = 'Just now';
+ }
+ else if (elapsedMins == 1) {
+ staleTimeText = '1 minute ago';
+ }
+ else {
+ staleTimeText = elapsedMins + ' minutes ago';
+ }
+ $('#staleTime').text(staleTimeText);
+
+ // Force NS to always show 'x minutes ago'
+ if (window.serverSettings.settings.showClockLastTime) {
+ $('#staleTime').css('display', 'block');
+ }
+
+ // Insert the delta value text.
+ $('#delta').html(deltaDisplayValue);
+
+ // Show delta
+ if (window.serverSettings.settings.showClockDelta) {
+ $('#delta').css('display', 'inline-block');
+ }
// Threshold background coloring.
if (bgNum < bgLow) {
$('body').css('background-color', red);
- $('#close').css('border-color', darkRed);
- $('#close').css('color', darkRed);
}
if ((bgLow <= bgNum) && (bgNum < bgTargetBottom)) {
$('body').css('background-color', blue);
- $('#close').css('border-color', darkBlue);
- $('#close').css('color', darkBlue);
}
if ((bgTargetBottom <= bgNum) && (bgNum < bgTargetTop)) {
$('body').css('background-color', green);
- $('#close').css('border-color', darkGreen);
- $('#close').css('color', darkGreen);
}
if ((bgTargetTop <= bgNum) && (bgNum < bgHigh)) {
$('body').css('background-color', yellow);
- $('#close').css('border-color', darkYellow);
- $('#close').css('color', darkYellow);
}
if (bgNum >= bgHigh) {
$('body').css('background-color', red);
- $('#close').css('border-color', darkRed);
- $('#close').css('color', darkRed);
}
// Restyle body bg, and make the "x minutes ago" visible too.
if (now - last > threshold) {
$('body').css('background-color', 'grey');
$('body').css('color', 'black');
- $('#staleTime').css('display', 'block');
$('#arrow').css('filter', 'brightness(0%)');
+
+ if (!window.serverSettings.settings.showClockLastTime) {
+ $('#staleTime').css('display', 'block');
+ }
+
} else {
- $('#staleTime').css('display', 'none');
$('body').css('color', 'white');
$('#arrow').css('filter', 'brightness(100%)');
+
+ if (!window.serverSettings.settings.showClockLastTime) {
+ $('#staleTime').css('display', 'none');
+ }
+
}
}
};
diff --git a/lib/client/d3locales.js b/lib/client/d3locales.js
index afb04cbad5c..1af15ba3fb6 100644
--- a/lib/client/d3locales.js
+++ b/lib/client/d3locales.js
@@ -142,6 +142,21 @@ d3locales.it_IT = {
shortMonths: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic']
};
+d3locales.pl_PL = {
+ decimal: '.',
+ thousands: ',',
+ grouping: [3],
+ currency: ['', 'zł'],
+ dateTime: '%a %b %e %X %Y',
+ date: '%d.%m.%Y',
+ time: '%H:%M:%S',
+ periods: ['AM', 'PM'], // unused
+ days: ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota'],
+ shortDays: ['Nie', 'Pn', 'Wt', 'Śr', 'Czw', 'Pt', 'So'],
+ months: ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'],
+ shortMonths: ['Sty', 'Lu', 'Mar', 'Kw', 'Maj', 'Cze', 'Lip', 'Sie', 'Wrz', 'Pa', 'Lis', 'Gru']
+};
+
d3locales.pt_BR = {
decimal: ',',
thousands: '.',
@@ -212,6 +227,7 @@ d3locales.locale = function locale (language) {
, fr: 'fr_FR'
, he: 'he_IL'
, it: 'it_IT'
+ , pl: 'pl_PL'
, pt: 'pt_BR'
, ro: 'ro_RO'
, ru: 'ru_RU'
diff --git a/lib/client/index.js b/lib/client/index.js
index 979348c0cb5..c7487c6b410 100644
--- a/lib/client/index.js
+++ b/lib/client/index.js
@@ -35,6 +35,11 @@ client.headers = function headers () {
}
};
+client.crashed = function crashed () {
+ $('#centerMessagePanel').show();
+ $('#loadingMessageText').html('It appears the server has crashed. Please go to Heroku or Azure and reboot the server.');
+}
+
client.init = function init (callback) {
client.browserUtils = require('./browser-utils')($);
@@ -59,8 +64,7 @@ client.init = function init (callback) {
console.log('Application appears to be online');
$('#centerMessagePanel').hide();
client.load(serverSettings, callback);
- // eslint-disable-next-line no-unused-vars
- }).fail(function fail (jqXHR, textStatus, errorThrown) {
+ }).fail(function fail (jqXHR) {
// check if we couldn't reach the server at all, show offline message
if (!jqXHR.readyState) {
@@ -93,7 +97,7 @@ client.init = function init (callback) {
// auth failed, hide loader and request for key
$('#centerMessagePanel').hide();
client.hashauth.requestAuthentication(function afterRequest () {
- client.init(null, callback);
+ client.init(callback);
});
}
});
@@ -102,8 +106,7 @@ client.init = function init (callback) {
client.load = function load (serverSettings, callback) {
- var UPDATE_TRANS_MS = 750 // milliseconds
- , FORMAT_TIME_12 = '%-I:%M %p'
+ var FORMAT_TIME_12 = '%-I:%M %p'
, FORMAT_TIME_12_COMPACT = '%-I:%M'
, FORMAT_TIME_24 = '%H:%M%'
, FORMAT_TIME_12_SCALE = '%-I %p'
@@ -124,11 +127,16 @@ client.load = function load (serverSettings, callback) {
, urgentAlarmSound = 'alarm2.mp3'
, previousNotifyTimestamp;
- client.entryToDate = function entryToDate (entry) { return new Date(entry.mills); };
+ client.entryToDate = function entryToDate (entry) {
+ if (entry.date) return entry.date;
+ entry.date = new Date(entry.mills);
+ return entry.date;
+ };
client.now = Date.now();
client.ddata = require('../data/ddata')();
- client.forecastTime = times.mins(30).msecs;
+ client.defaultForecastTime = times.mins(30).msecs;
+ client.forecastTime = client.now + client.defaultForecastTime;
client.entries = [];
client.ticks = require('./ticks');
@@ -261,9 +269,11 @@ client.load = function load (serverSettings, callback) {
//client.ctx.bus.uptime( );
client.dataExtent = function dataExtent () {
- return client.entries.length > 0 ?
- d3.extent(client.entries, client.entryToDate) :
- d3.extent([new Date(client.now - times.hours(history).msecs), new Date(client.now)]);
+ if (client.entries.length > 0) {
+ return [client.entryToDate(client.entries[0]), client.entryToDate(client.entries[client.entries.length - 1])];
+ } else {
+ return [new Date(client.now - times.hours(history).msecs), new Date(client.now)];
+ }
};
client.bottomOfPills = function bottomOfPills () {
@@ -276,7 +286,7 @@ client.load = function load (serverSettings, callback) {
function formatTime (time, compact) {
var timeFormat = getTimeFormat(false, compact);
- time = d3.time.format(timeFormat)(time);
+ time = d3.timeFormat(timeFormat)(time);
if (client.settings.timeFormat !== 24) {
time = time.toLowerCase();
}
@@ -375,20 +385,29 @@ client.load = function load (serverSettings, callback) {
// clears the current user brush and resets to the current real time data
function updateBrushToNow (skipBrushing) {
- // get current time range
- var dataRange = client.dataExtent();
-
// update brush and focus chart with recent data
- d3.select('.brush')
- .transition()
- .duration(UPDATE_TRANS_MS)
- .call(chart.brush.extent([new Date(dataRange[1].getTime() - client.focusRangeMS), dataRange[1]]));
+ var brushExtent = client.dataExtent();
+
+ brushExtent[0] = new Date(brushExtent[1].getTime() - client.focusRangeMS);
+
+ // console.log('updateBrushToNow(): Resetting brush: ', brushExtent);
+
+ if (chart.theBrush) {
+ chart.theBrush.call(chart.brush)
+ chart.theBrush.call(chart.brush.move, brushExtent.map(chart.xScale2));
+ }
if (!skipBrushing) {
brushed();
}
}
+ function updateBolusRenderOver () {
+ var bolusRenderOver = (client.settings.bolusRenderOver || 1) + ' U and Over';
+ $('#bolusRenderOver').text(bolusRenderOver);
+ console.log('here');
+ }
+
function alarmingNow () {
return container.hasClass('alarming');
}
@@ -398,21 +417,36 @@ client.load = function load (serverSettings, callback) {
}
function brushed () {
+ // Brush not initialized
+ if (!chart.theBrush) {
+ return;
+ }
+
+ // default to most recent focus period
+ var brushExtent = client.dataExtent();
+ brushExtent[0] = new Date(brushExtent[1].getTime() - client.focusRangeMS);
+
+ var brushedRange = d3.brushSelection(chart.theBrush.node());
- var brushExtent = chart.brush.extent();
+ // console.log("brushed(): coordinates: ", brushedRange);
- // ensure that brush extent is fixed at 3.5 hours
- if (brushExtent[1].getTime() - brushExtent[0].getTime() !== client.focusRangeMS) {
+ if (brushedRange) {
+ brushExtent = brushedRange.map(chart.xScale2.invert);
+ }
+
+ console.log('brushed(): Brushed to: ', brushExtent);
+
+ if (!brushedRange || (brushExtent[1].getTime() - brushExtent[0].getTime() !== client.focusRangeMS)) {
// ensure that brush updating is with the time range
if (brushExtent[0].getTime() + client.focusRangeMS > client.dataExtent()[1].getTime()) {
brushExtent[0] = new Date(brushExtent[1].getTime() - client.focusRangeMS);
- d3.select('.brush')
- .call(chart.brush.extent([brushExtent[0], brushExtent[1]]));
} else {
brushExtent[1] = new Date(brushExtent[0].getTime() + client.focusRangeMS);
- d3.select('.brush')
- .call(chart.brush.extent([brushExtent[0], brushExtent[1]]));
}
+
+ // console.log('brushed(): updating to: ', brushExtent);
+
+ chart.theBrush.call(chart.brush.move, brushExtent.map(chart.xScale2));
}
function adjustCurrentSGVClasses (value, isCurrent) {
@@ -428,7 +462,6 @@ client.load = function load (serverSettings, callback) {
currentBG.toggleClass('icon-hourglass', value === 9);
currentBG.toggleClass('error-code', value < 39);
currentBG.toggleClass('bg-limit', value === 39 || value > 400);
- container.removeClass('loading');
}
function updateCurrentSGV (entry) {
@@ -450,6 +483,20 @@ client.load = function load (serverSettings, callback) {
adjustCurrentSGVClasses(value, isCurrent);
}
+ function mergeDeviceStatus (retro, ddata) {
+ if (!retro) {
+ return ddata;
+ }
+
+ var result = retro.map(x => Object.assign(x, ddata.find(y => y._id == x._id)));
+
+ var missingInRetro = ddata.filter(y => !retro.find(x => x._id == y._id));
+
+ result.push(...missingInRetro);
+
+ return result;
+ }
+
function updatePlugins (time) {
//TODO: doing a clone was slow, but ok to let plugins muck with data?
@@ -458,10 +505,22 @@ client.load = function load (serverSettings, callback) {
client.ddata.inRetroMode = inRetroMode();
client.ddata.profile = profile;
+ // retro data only ever contains device statuses
+ // Cleate a clone of the data for the sandbox given to plugins
+
+ var mergedStatuses = client.ddata.devicestatus;
+
+ if (client.retro.data) {
+ mergedStatuses = mergeDeviceStatus(client.retro.data.devicestatus, client.ddata.devicestatus);
+ }
+
+ var clonedData = _.clone(client.ddata);
+ clonedData.devicestatus = mergedStatuses;
+
client.sbx = sandbox.clientInit(
client.ctx
, new Date(time).getTime() //make sure we send a timestamp
- , _.merge({}, client.retro.data || {}, client.ddata)
+ , clonedData
);
//all enabled plugins get a chance to set properties, even if they aren't shown
@@ -505,7 +564,7 @@ client.load = function load (serverSettings, callback) {
function clearCurrentSGV () {
currentBG.text('---');
- container.removeClass('urgent warning inrange');
+ container.removeClass('alarming urgent warning inrange');
}
var nowDate = null;
@@ -546,6 +605,7 @@ client.load = function load (serverSettings, callback) {
var top = (client.bottomOfPills() + 5);
$('#chartContainer').css({ top: top + 'px', height: $(window).height() - top - 10 });
+ container.removeClass('loading');
}
function sgvToColor (sgv) {
@@ -724,7 +784,8 @@ client.load = function load (serverSettings, callback) {
function updateClock () {
updateClockDisplay();
- var interval = (60 - (new Date()).getSeconds()) * 1000 + 5;
+ // Update at least every 15 seconds
+ var interval = Math.min(15 * 1000, (60 - (new Date()).getSeconds()) * 1000 + 5);
setTimeout(updateClock, interval);
updateTimeAgo();
@@ -798,6 +859,12 @@ client.load = function load (serverSettings, callback) {
container.toggleClass('alarming-timeago', status !== 'current');
+ if (status === 'warn') {
+ container.addClass('warn');
+ } else if (status === 'urgent') {
+ container.addClass('urgent');
+ }
+
if (alarmingNow() && status === 'current' && isTimeAgoAlarmType()) {
stopAlarm(true, times.min().msecs);
}
@@ -903,7 +970,7 @@ client.load = function load (serverSettings, callback) {
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Client-side code to connect to server and handle incoming data
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
- // eslint-disable-next-line no-undef
+ /* global io */
client.socket = socket = io.connect();
socket.on('dataUpdate', dataUpdate);
@@ -952,18 +1019,30 @@ client.load = function load (serverSettings, callback) {
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Alarms and Text handling
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
- socket.on('connect', function() {
- console.log('Client connected to server.');
+
+
+ client.authorizeSocket = function authorizeSocket() {
+
+ console.log('Authorizing socket');
+ var auth_data = {
+ client: 'web'
+ , secret: client.authorized && client.authorized.token ? null : client.hashauth.hash()
+ , token: client.authorized && client.authorized.token
+ , history: history
+ };
+
socket.emit(
'authorize'
- , {
- client: 'web'
- , secret: client.authorized && client.authorized.token ? null : client.hashauth.hash()
- , token: client.authorized && client.authorized.token
- , history: history
- }
+ , auth_data
, function authCallback (data) {
- console.log('Client rights: ', data);
+
+ console.log('Socket auth response', data);
+
+ if (!data) {
+ console.log('Crashed!');
+ client.crashed();
+ }
+
if (!data.read || !hasRequiredPermission()) {
client.hashauth.requestAuthentication(function afterRequest () {
client.hashauth.updateSocketAuth();
@@ -976,6 +1055,11 @@ client.load = function load (serverSettings, callback) {
}
}
);
+ }
+
+ socket.on('connect', function() {
+ console.log('Client connected to server.');
+ client.authorizeSocket();
});
function hasRequiredPermission () {
@@ -1127,9 +1211,14 @@ client.load = function load (serverSettings, callback) {
point.color = 'transparent';
}
});
+
+ client.entries.sort(function sorter (a, b) {
+ return a.mills - b.mills;
+ });
}
- function dataUpdate (received) {
+ function dataUpdate (received, headless) {
+ console.info('got dataUpdate', new Date(client.now));
var lastUpdated = Date.now();
receiveDData(received, client.ddata, client.settings);
@@ -1161,16 +1250,23 @@ client.load = function load (serverSettings, callback) {
prepareEntries();
updateTitle();
+ updateBolusRenderOver();
+
+ // Don't invoke D3 in headless mode
+
+ if (headless) return;
if (!isInitialData) {
isInitialData = true;
chart = client.chart = require('./chart')(client, d3, $);
- brushed();
chart.update(true);
- } else if (!inRetroMode()) {
+ brushed();
chart.update(false);
- client.plugins.updateVisualisations(client.nowSBX);
+ } else if (!inRetroMode()) {
brushed();
+ chart.update(false);
+ } else {
+ chart.updateContext();
}
}
};
diff --git a/lib/client/receiveddata.js b/lib/client/receiveddata.js
index e0adf13fb3b..c167d9463a3 100644
--- a/lib/client/receiveddata.js
+++ b/lib/client/receiveddata.js
@@ -128,6 +128,9 @@ function receiveDData (received, ddata, settings) {
}
}
+ if (received.dbstats && received.dbstats.fileSize) {
+ ddata.dbstats = received.dbstats;
+ }
}
//expose for tests
diff --git a/lib/client/renderer.js b/lib/client/renderer.js
index ccbac62d72b..a0caed8083a 100644
--- a/lib/client/renderer.js
+++ b/lib/client/renderer.js
@@ -2,14 +2,16 @@
var _ = require('lodash');
var times = require('../times');
+var consts = require('../constants');
var DEFAULT_FOCUS = times.hours(3).msecs
, WIDTH_SMALL_DOTS = 420
, WIDTH_BIG_DOTS = 800
- , TOOLTIP_TRANS_MS = 100 // milliseconds
, TOOLTIP_WIDTH = 150 //min-width + padding
;
+const zeroDate = new Date(0);
+
function init (client, d3) {
var renderer = {};
@@ -17,6 +19,12 @@ function init (client, d3) {
var utils = client.utils;
var translate = client.translate;
+ function getOrAddDate(entry) {
+ if (entry.date) return entry.date;
+ entry.date = new Date(entry.mills);
+ return entry.date;
+ }
+
//chart isn't created till the client gets data, so can grab the var at init
function chart () {
return client.chart;
@@ -40,20 +48,18 @@ function init (client, d3) {
};
function tooltipLeft () {
- var windowWidth = $(client.tooltip).parent().parent().width();
+ var windowWidth = $(client.tooltip.node()).parent().parent().width();
var left = d3.event.pageX + TOOLTIP_WIDTH < windowWidth ? d3.event.pageX : windowWidth - TOOLTIP_WIDTH - 10;
return left + 'px';
}
function hideTooltip () {
- client.tooltip.transition()
- .duration(TOOLTIP_TRANS_MS)
- .style('opacity', 0);
+ client.tooltip.style('opacity', 0);
}
// get the desired opacity for context chart based on the brush extent
- renderer.highlightBrushPoints = function highlightBrushPoints (data) {
- if (client.latestSGV && data.mills >= chart().brush.extent()[0].getTime() && data.mills <= chart().brush.extent()[1].getTime()) {
+ renderer.highlightBrushPoints = function highlightBrushPoints (data, from, to) {
+ if (client.latestSGV && data.mills >= from && data.mills <= to) {
return chart().futureOpacity(data.mills - client.latestSGV.mills);
} else {
return 0.5;
@@ -66,36 +72,18 @@ function init (client, d3) {
};
renderer.addFocusCircles = function addFocusCircles () {
- // get slice of data so that concatenation of predictions do not interfere with subsequent updates
- var focusData = client.entries.slice();
-
- if (client.sbx.pluginBase.forecastPoints) {
- var shownForecastPoints = _.filter(client.sbx.pluginBase.forecastPoints, function isShown (point) {
- return client.settings.showForecast.indexOf(point.info.type) > -1;
- });
- var maxForecastMills = _.max(_.map(shownForecastPoints, function(point) { return point.mills }));
- // limit lookahead to the same as lookback
- var focusHoursAheadMills = chart().brush.extent()[1].getTime() + client.focusRangeMS;
- maxForecastMills = Math.min(focusHoursAheadMills, maxForecastMills);
- client.forecastTime = maxForecastMills > 0 ? maxForecastMills - client.sbx.lastSGVMills() : 0;
- focusData = focusData.concat(shownForecastPoints);
- }
-
- // bind up the focus chart data to an array of circles
- // selects all our data into data and uses date function to get current max date
- var focusCircles = chart().focus.selectAll('circle').data(focusData, client.entryToDate);
- function prepareFocusCircles (sel) {
+ function updateFocusCircles (sel) {
var badData = [];
sel.attr('cx', function(d) {
if (!d) {
console.error('Bad data', d);
- return chart().xScale(new Date(0));
+ return chart().xScale(zeroDate);
} else if (!d.mills) {
console.error('Bad data, no mills', d);
- return chart().xScale(new Date(0));
+ return chart().xScale(zeroDate);
} else {
- return chart().xScale(new Date(d.mills));
+ return chart().xScale(getOrAddDate(d));
}
})
.attr('cy', function(d) {
@@ -107,17 +95,12 @@ function init (client, d3) {
return chart().yScale(scaled);
}
})
- .attr('fill', function(d) {
- return d.type === 'forecast' ? 'none' : d.color;
- })
.attr('opacity', function(d) {
- return d.noFade || !client.latestSGV ? 100 : chart().futureOpacity(d.mills - client.latestSGV.mills);
- })
- .attr('stroke-width', function(d) {
- return d.type === 'mbg' ? 2 : d.type === 'forecast' ? 2 : 0;
- })
- .attr('stroke', function(d) {
- return (d.type === 'mbg' ? 'white' : d.color);
+ if (d.noFade) {
+ return null;
+ } else {
+ return !client.latestSGV ? 1 : chart().futureOpacity(d.mills - client.latestSGV.mills);
+ }
})
.attr('r', function(d) {
return dotRadius(d.type);
@@ -130,6 +113,21 @@ function init (client, d3) {
return sel;
}
+ function prepareFocusCircles (sel) {
+ updateFocusCircles(sel)
+ .attr('fill', function(d) {
+ return d.type === 'forecast' ? 'none' : d.color;
+ })
+ .attr('stroke-width', function(d) {
+ return d.type === 'mbg' ? 2 : d.type === 'forecast' ? 2 : 0;
+ })
+ .attr('stroke', function(d) {
+ return (d.type === 'mbg' ? 'white' : d.color);
+ });
+
+ return sel;
+ }
+
function focusCircleTooltip (d) {
if (d.type !== 'sgv' && d.type !== 'mbg' && d.type !== 'forecast') {
return;
@@ -149,51 +147,119 @@ function init (client, d3) {
var rawbgInfo = getRawbgInfo();
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9);
+ client.tooltip.style('opacity', .9);
client.tooltip.html('' + translate('BG') + ': ' + client.sbx.scaleEntry(d) +
(d.type === 'mbg' ? ' ' + translate('Device') + ': ' + d.device : '') +
(d.type === 'forecast' && d.forecastType ? ' ' + translate('Forecast Type') + ': ' + d.forecastType : '') +
(rawbgInfo.value ? ' ' + translate('Raw BG') + ': ' + rawbgInfo.value : '') +
(rawbgInfo.noise ? ' ' + translate('Noise') + ': ' + rawbgInfo.noise : '') +
- ' ' + translate('Time') + ': ' + client.formatTime(new Date(d.mills)))
+ ' ' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
}
+ // CGM data
+
+ var focusData = client.entries;
+
+ // bind up the focus chart data to an array of circles
+ // selects all our data into data and uses date function to get current max date
+ var focusCircles = chart().focus.selectAll('circle.entry-dot').data(focusData, function genKey (d) {
+ return "cgmreading." + d.mills;
+ });
+
// if already existing then transition each circle to its new position
- prepareFocusCircles(focusCircles.transition());
+ updateFocusCircles(focusCircles);
// if new circle then just display
prepareFocusCircles(focusCircles.enter().append('circle'))
+ .attr('class', 'entry-dot')
.on('mouseover', focusCircleTooltip)
.on('mouseout', hideTooltip);
focusCircles.exit().remove();
+
+ // Forecasts
+
+ var shownForecastPoints = client.chart.getForecastData();
+
+ // bind up the focus chart data to an array of circles
+ // selects all our data into data and uses date function to get current max date
+
+ var forecastCircles = chart().focus.selectAll('circle.forecast-dot').data(shownForecastPoints, function genKey (d) {
+ return d.forecastType + d.mills;
+ });
+
+ forecastCircles.exit().remove();
+
+ prepareFocusCircles(forecastCircles.enter().append('circle'))
+ .attr('class', 'forecast-dot')
+ .on('mouseover', focusCircleTooltip)
+ .on('mouseout', hideTooltip);
+
+ updateFocusCircles(forecastCircles);
+
};
- renderer.addTreatmentCircles = function addTreatmentCircles () {
+ renderer.addTreatmentCircles = function addTreatmentCircles (nowDate) {
function treatmentTooltip (d) {
var targetBottom = d.targetBottom;
var targetTop = d.targetTop;
if (client.settings.units === 'mmol') {
- targetBottom = Math.round(targetBottom / 18.0 * 10) / 10;
- targetTop = Math.round(targetTop / 18.0 * 10) / 10;
+ targetBottom = Math.round(targetBottom / consts.MMOL_TO_MGDL * 10) / 10;
+ targetTop = Math.round(targetTop / consts.MMOL_TO_MGDL * 10) / 10;
}
- return '' + translate('Time') + ': ' + client.formatTime(new Date(d.mills)) + ' ' +
+ var correctionRangeText;
+ if (d.correctionRange) {
+ var min = d.correctionRange[0];
+ var max = d.correctionRange[1];
+
+ if (client.settings.units === 'mmol') {
+ max = client.sbx.roundBGToDisplayFormat(client.sbx.scaleMgdl(max));
+ min = client.sbx.roundBGToDisplayFormat(client.sbx.scaleMgdl(min));
+ }
+
+ if (d.correctionRange[0] === d.correctionRange[1]) {
+ correctionRangeText = '' + min;
+ } else {
+ correctionRangeText = '' + min + ' - ' + max;
+ }
+ }
+
+ var durationText;
+ if (d.durationType === "indefinite") {
+ durationText = translate("Indefinite");
+ } else if (d.duration) {
+ var durationMinutes = Math.round(d.duration);
+ if (durationMinutes > 0 && durationMinutes % 60 == 0) {
+ var durationHours = durationMinutes / 60;
+ if (durationHours > 1) {
+ durationText = durationHours + ' hours';
+ } else {
+ durationText = durationHours + ' hour';
+ }
+ } else {
+ durationText = durationMinutes + ' min';
+ }
+ }
+
+ return '' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)) + ' ' +
(d.eventType ? '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(d.eventType)) + ' ' : '') +
(d.reason ? '' + translate('Reason') + ': ' + translate(d.reason) + ' ' : '') +
(d.glucose ? '' + translate('BG') + ': ' + d.glucose + (d.glucoseType ? ' (' + translate(d.glucoseType) + ')' : '') + ' ' : '') +
(d.enteredBy ? '' + translate('Entered By') + ': ' + d.enteredBy + ' ' : '') +
(d.targetTop ? '' + translate('Target Top') + ': ' + targetTop + ' ' : '') +
(d.targetBottom ? '' + translate('Target Bottom') + ': ' + targetBottom + ' ' : '') +
- (d.duration ? '' + translate('Duration') + ': ' + Math.round(d.duration) + ' min ' : '') +
+ (durationText ? '' + translate('Duration') + ': ' + durationText + ' ' : '') +
+ (d.insulinNeedsScaleFactor ? '' + translate('Insulin Scale Factor') + ': ' + d.insulinNeedsScaleFactor * 100 + '% ' : '') +
+ (correctionRangeText ? '' + translate('Correction Range') + ': ' + correctionRangeText + ' ' : '') +
(d.notes ? '' + translate('Notes') + ': ' + d.notes : '');
}
function announcementTooltip (d) {
- return '' + translate('Time') + ': ' + client.formatTime(new Date(d.mills)) + ' ' +
+ return '' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)) + ' ' +
(d.eventType ? '' + translate('Announcement') + ' ' : '') +
(d.notes && d.notes.length > 1 ? '' + translate('Message') + ': ' + d.notes + ' ' : '') +
(d.enteredBy ? '' + translate('Entered By') + ': ' + d.enteredBy + ' ' : '');
@@ -204,7 +270,7 @@ function init (client, d3) {
//NOTE: treatments with insulin or carbs are drawn by drawTreatment()
// bind up the focus chart data to an array of circles
- var treatCircles = chart().focus.selectAll('treatment-dot').data(client.ddata.treatments.filter(function(treatment) {
+ var treatCircles = chart().focus.selectAll('.treatment-dot').data(client.ddata.treatments.filter(function(treatment) {
var notCarbsOrInsulin = !treatment.carbs && !treatment.insulin;
var notTempOrProfile = !_.includes(['Temp Basal', 'Profile Switch', 'Combo Bolus', 'Temporary Target'], treatment.eventType);
@@ -216,8 +282,23 @@ function init (client, d3) {
return notes.indexOf(spam) === 0;
}));
- return notCarbsOrInsulin && !treatment.duration && notTempOrProfile && notOpenAPSSpam;
- }));
+ return notCarbsOrInsulin && !treatment.duration && treatment.durationType !== 'indefinite' && notTempOrProfile && notOpenAPSSpam;
+ }), function (d) { return d._id; });
+
+ function updateTreatCircles (sel) {
+
+ sel.attr('cx', function(d) {
+ return chart().xScale(getOrAddDate(d));
+ })
+ .attr('cy', function(d) {
+ return chart().yScale(client.sbx.scaleEntry(d));
+ })
+ .attr('r', function() {
+ return dotRadius('mbg');
+ });
+
+ return sel;
+ }
function prepareTreatCircles (sel) {
function strokeColor (d) {
@@ -240,15 +321,7 @@ function init (client, d3) {
return color;
}
- sel.attr('cx', function(d) {
- return chart().xScale(new Date(d.mills));
- })
- .attr('cy', function(d) {
- return chart().yScale(client.sbx.scaleEntry(d));
- })
- .attr('r', function() {
- return dotRadius('mbg');
- })
+ updateTreatCircles(sel)
.attr('stroke-width', 2)
.attr('stroke', strokeColor)
.attr('fill', fillColor);
@@ -257,20 +330,23 @@ function init (client, d3) {
}
// if already existing then transition each circle to its new position
- prepareTreatCircles(treatCircles.transition());
+ updateTreatCircles(treatCircles);
// if new circle then just display
prepareTreatCircles(treatCircles.enter().append('circle'))
+ .attr('class', 'treatment-dot')
.on('mouseover', function(d) {
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9);
+ client.tooltip.style('opacity', .9);
client.tooltip.html(d.isAnnouncement ? announcementTooltip(d) : treatmentTooltip(d))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
})
.on('mouseout', hideTooltip);
+ treatCircles.exit().remove();
+
var durationTreatments = client.ddata.treatments.filter(function(treatment) {
- return !treatment.carbs && !treatment.insulin && treatment.duration &&
+ return !treatment.carbs && !treatment.insulin && (treatment.duration || treatment.durationType !== undefined) &&
!_.includes(['Temp Basal', 'Profile Switch', 'Combo Bolus', 'Temporary Target'], treatment.eventType);
});
@@ -306,69 +382,95 @@ function init (client, d3) {
if (d.eventType === 'Temporary Target') {
top = d.targetTop === d.targetBottom ? d.targetTop + rectHeight(d) : d.targetTop;
}
- return 'translate(' + chart().xScale(new Date(d.mills)) + ',' + chart().yScale(utils.scaleMgdl(top)) + ')';
+ return 'translate(' + chart().xScale(getOrAddDate(d)) + ',' + chart().yScale(utils.scaleMgdl(top)) + ')';
}
- // if already existing then transition each rect to its new position
- treatRects.transition()
- .attr('transform', rectTranslate);
- chart().focus.selectAll('.g-duration-rect').transition()
- .attr('width', function(d) {
- return chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills));
- });
+ function treatmentRectWidth (d) {
+ if (d.durationType === "indefinite") {
+ return chart().xScale(chart().xScale.domain()[1].getTime()) - chart().xScale(getOrAddDate(d));
+ } else {
+ return chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(getOrAddDate(d));
+ }
+ }
- chart().focus.selectAll('.g-duration-text').transition()
- .attr('transform', function(d) {
- return 'translate(' + (chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills))) / 2 + ',' + 10 + ')';
- });
+ function treatmentTextTransform (d) {
+ if (d.durationType === "indefinite") {
+ var offset = 0;
+ if (chart().xScale(getOrAddDate(d)) < chart().xScale(chart().xScale.domain()[0].getTime())) {
+ offset = chart().xScale(nowDate) - chart().xScale(getOrAddDate(d));
+ }
+ return 'translate(' + offset + ',' + 10 + ')';
+ } else {
+ return 'translate(' + (chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(getOrAddDate(d))) / 2 + ',' + 10 + ')';
+ }
+ }
+
+ function treatmentText (d) {
+ if (d.eventType === 'Temporary Target') {
+ return '';
+ }
+ return d.notes || d.reason || d.eventType;
+ }
- // if new rect then just display
- var gs = treatRects.enter().append('g')
+ function treatmentTextAnchor (d) {
+ return d.durationType === "indefinite" ? 'left' : 'middle';
+ }
+
+ // if transitioning, update rect text, position, and width
+ var rectUpdates = treatRects;
+ rectUpdates.attr('transform', rectTranslate);
+
+ rectUpdates.select('text')
+ .text(treatmentText)
+ .attr('text-anchor', treatmentTextAnchor)
+ .attr('transform', treatmentTextTransform);
+
+ rectUpdates.select('rect')
+ .attr('width', treatmentRectWidth)
+
+ // if new rect then create new elements
+ var newRects = treatRects.enter().append('g')
.attr('class', 'g-duration')
.attr('transform', rectTranslate)
.on('mouseover', function(d) {
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9);
+ client.tooltip.style('opacity', .9);
client.tooltip.html(d.isAnnouncement ? announcementTooltip(d) : treatmentTooltip(d))
.style('left', tooltipLeft())
.style('top', (d3.event.pageY + 15) + 'px');
})
.on('mouseout', hideTooltip);
- gs.append('rect')
+ newRects.append('rect')
.attr('class', 'g-duration-rect')
- .attr('width', function(d) {
- return chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills));
- })
+ .attr('width', treatmentRectWidth)
.attr('height', rectHeight)
.attr('rx', 5)
.attr('ry', 5)
.attr('opacity', .2)
.attr('fill', fillColor);
- gs.append('text')
+ newRects.append('text')
.attr('class', 'g-duration-text')
.style('font-size', 15)
.attr('fill', 'white')
- .attr('text-anchor', 'middle')
+ .attr('text-anchor', treatmentTextAnchor)
.attr('dy', '.35em')
- .attr('transform', function(d) {
- return 'translate(' + (chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills))) / 2 + ',' + 10 + ')';
- })
- .text(function(d) {
- if (d.eventType === 'Temporary Target') {
- return '';
- }
- return d.notes || d.eventType;
- });
+ .attr('transform', treatmentTextTransform)
+ .text(treatmentText);
+
+ // Remove any rects no longer needed
+ treatRects.exit().remove();
};
+
+
renderer.addContextCircles = function addContextCircles () {
// bind up the context chart data to an array of circles
var contextCircles = chart().context.selectAll('circle').data(client.entries);
function prepareContextCircles (sel) {
var badData = [];
- sel.attr('cx', function(d) { return chart().xScale2(new Date(d.mills)); })
+ sel.attr('cx', function(d) { return chart().xScale2(getOrAddDate(d)); })
.attr('cy', function(d) {
var scaled = client.sbx.scaleEntry(d);
if (isNaN(scaled)) {
@@ -379,7 +481,7 @@ function init (client, d3) {
}
})
.attr('fill', function(d) { return d.color; })
- .style('opacity', function(d) { return renderer.highlightBrushPoints(d) })
+ //.style('opacity', function(d) { return renderer.highlightBrushPoints(d) })
.attr('stroke-width', function(d) { return d.type === 'mbg' ? 2 : 0; })
.attr('stroke', function() { return 'white'; })
.attr('r', function(d) { return d.type === 'mbg' ? 4 : 2; });
@@ -392,7 +494,7 @@ function init (client, d3) {
}
// if already existing then transition each circle to its new position
- prepareContextCircles(contextCircles.transition());
+ prepareContextCircles(contextCircles);
// if new circle then just display
prepareContextCircles(contextCircles.enter().append('circle'));
@@ -425,7 +527,7 @@ function init (client, d3) {
};
}
- function prepareArc (treatment, radius) {
+ function prepareArc (treatment, radius, renderBasal) {
var arc_data = [
// white carb half-circle on top
{ 'element': '', 'color': 'white', 'start': -1.5708, 'end': 1.5708, 'inner': 0, 'outer': radius.R1 }
@@ -436,39 +538,39 @@ function init (client, d3) {
// these used to be semicircles from 1.5708 to 4.7124, but that made the tooltip target too big
{ 'element': '', 'color': 'transparent', 'start': 3.1400, 'end': 3.1432, 'inner': radius.R2, 'outer': radius.R3 }
, { 'element': '', 'color': 'transparent', 'start': 3.1400, 'end': 3.1432, 'inner': radius.R2, 'outer': radius.R4 }
- ];
+ ]
+ , arc_data_1_elements = [];
arc_data[0].outlineOnly = !treatment.carbs;
arc_data[2].outlineOnly = !treatment.insulin;
if (treatment.carbs > 0) {
- arc_data[1].element = Math.round(treatment.carbs) + ' g';
+ arc_data_1_elements.push(Math.round(treatment.carbs) + ' g');
}
if (treatment.protein > 0) {
- arc_data[1].element = arc_data[1].element + " / " + Math.round(treatment.protein) + ' g';
+ arc_data_1_elements.push(Math.round(treatment.protein) + ' g');
}
if (treatment.fat > 0) {
- arc_data[1].element = arc_data[1].element + " / " + Math.round(treatment.fat) + ' g';
+ arc_data_1_elements.push(Math.round(treatment.fat) + ' g');
}
+ arc_data[1].element = arc_data_1_elements.join(' / ');
+
if (treatment.foodType) {
arc_data[1].element = arc_data[1].element + " " + treatment.foodType;
}
if (treatment.insulin > 0) {
var dosage_units = '' + Math.round(treatment.insulin * 100) / 100;
-
- var unit_of_measurement = ' U'; // One international unit of insulin (1 IU) is shown as '1 U'
- var enteredBy = '' + treatment.enteredBy;
-
- if (treatment.insulin < 1 && !treatment.carbs && enteredBy.indexOf('openaps') > -1) { // don't show the unit of measurement for insulin boluses < 1 without carbs (e.g. oref0 SMB's). Otherwise lot's of small insulin only dosages are often unreadable
- unit_of_measurement = '';
- // remove leading zeros to avoid overlap with adjacent boluses
+
+ if (renderBasal === 'all-remove-zero-u') {
dosage_units = (dosage_units + "").replace(/^0/, "");
}
+ var unit_of_measurement = (renderBasal === 'all-remove-zero-u' ? '' : ' U'); // One international unit of insulin (1 IU) is shown as '1 U'
+
arc_data[3].element = dosage_units + unit_of_measurement;
}
@@ -476,7 +578,7 @@ function init (client, d3) {
arc_data[4].element = translate(treatment.status);
}
- var arc = d3.svg.arc()
+ var arc = d3.arc()
.innerRadius(function(d) {
return 5 * d.inner;
})
@@ -526,15 +628,15 @@ function init (client, d3) {
function treatmentTooltip () {
var glucose = treatment.glucose;
- if (client.settings.units != client.ddata.profile.data[0].units) {
- glucose *= (client.settings.units === 'mmol' ? 0.055 : 18);
+ if (client.settings.units != client.ddata.profile.getUnits()) {
+ glucose *= (client.settings.units === 'mmol' ? (1 / consts.MMOL_TO_MGDL) : consts.MMOL_TO_MGDL);
var decimals = (client.settings.units === 'mmol' ? 10 : 1);
glucose = Math.round(glucose * decimals) / decimals;
}
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9);
- client.tooltip.html('' + translate('Time') + ': ' + client.formatTime(new Date(treatment.mills)) + ' ' + '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(treatment.eventType)) + ' ' +
+ client.tooltip.style('opacity', .9);
+ client.tooltip.html('' + translate('Time') + ': ' + client.formatTime(getOrAddDate(treatment)) + ' ' + '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(treatment.eventType)) + ' ' +
(treatment.carbs ? '' + translate('Carbs') + ': ' + treatment.carbs + ' ' : '') +
(treatment.protein ? '' + translate('Protein') + ': ' + treatment.protein + ' ' : '') +
(treatment.fat ? '' + translate('Fat') + ': ' + treatment.fat + ' ' : '') +
@@ -555,12 +657,12 @@ function init (client, d3) {
var insulinRect = { x: 0, y: 0, width: 0, height: 0 };
var carbsRect = { x: 0, y: 0, width: 0, height: 0 };
var operation;
- renderer.drag = d3.behavior.drag()
- .on('dragstart', function() {
+ renderer.drag = d3.drag()
+ .on('start', function() {
//console.log(treatment);
- var windowWidth = $(client.tooltip).parent().parent().width();
+ var windowWidth = $(client.tooltip.node()).parent().parent().width();
var left = d3.event.x + TOOLTIP_WIDTH < windowWidth ? d3.event.x : windowWidth - TOOLTIP_WIDTH - 10;
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9)
+ client.tooltip.style('opacity', .9)
.style('left', left + 'px')
.style('top', (d3.event.pageY ? d3.event.pageY + 15 : 40) + 'px');
@@ -571,29 +673,25 @@ function init (client, d3) {
, height: chart().yScale(chart().yScale.domain()[0])
};
chart().drag.append('rect')
- .attr({
- class: 'drag-droparea'
- , x: deleteRect.x
- , y: deleteRect.y
- , width: deleteRect.width
- , height: deleteRect.height
- , fill: 'red'
- , opacity: 0.4
- , rx: 10
- , ry: 10
- });
+ .attr('class', 'drag-droparea')
+ .attr('x', deleteRect.x)
+ .attr('y', deleteRect.y)
+ .attr('width', deleteRect.width)
+ .attr('height', deleteRect.height)
+ .attr('fill', 'red')
+ .attr('opacity', 0.4)
+ .attr('rx', 10)
+ .attr('ry', 10);
chart().drag.append('text')
- .attr({
- class: 'drag-droparea'
- , x: deleteRect.x + deleteRect.width / 2
- , y: deleteRect.y + deleteRect.height / 2
- , 'font-size': 15
- , 'font-weight': 'bold'
- , fill: 'red'
- , 'text-anchor': 'middle'
- , dy: '.35em'
- , transform: 'rotate(-90 ' + (deleteRect.x + deleteRect.width / 2) + ',' + (deleteRect.y + deleteRect.height / 2) + ')'
- })
+ .attr('class', 'drag-droparea')
+ .attr('x', deleteRect.x + deleteRect.width / 2)
+ .attr('y', deleteRect.y + deleteRect.height / 2)
+ .attr('font-size', 15)
+ .attr('font-weight', 'bold')
+ .attr('fill', 'red')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '.35em')
+ .attr('transform', 'rotate(-90 ' + (deleteRect.x + deleteRect.width / 2) + ',' + (deleteRect.y + deleteRect.height / 2) + ')')
.text(translate('Remove'));
if (treatment.insulin && treatment.carbs) {
@@ -610,52 +708,44 @@ function init (client, d3) {
, height: 50
};
chart().drag.append('rect')
- .attr({
- class: 'drag-droparea'
- , x: carbsRect.x
- , y: carbsRect.y
- , width: carbsRect.width
- , height: carbsRect.height
- , fill: 'white'
- , opacity: 0.4
- , rx: 10
- , ry: 10
- });
+ .attr('class', 'drag-droparea')
+ .attr('x', carbsRect.x)
+ .attr('y', carbsRect.y)
+ .attr('width', carbsRect.width)
+ .attr('height', carbsRect.height)
+ .attr('fill', 'white')
+ .attr('opacitys', 0.4)
+ .attr('rx', 10)
+ .attr('ry', 10);
chart().drag.append('text')
- .attr({
- class: 'drag-droparea'
- , x: carbsRect.x + carbsRect.width / 2
- , y: carbsRect.y + carbsRect.height / 2
- , 'font-size': 15
- , 'font-weight': 'bold'
- , fill: 'white'
- , 'text-anchor': 'middle'
- , dy: '.35em'
- })
+ .attr('class', 'drag-droparea')
+ .attr('x', carbsRect.x + carbsRect.width / 2)
+ .attr('y', carbsRect.y + carbsRect.height / 2)
+ .attr('font-size', 15)
+ .attr('font-weight', 'bold')
+ .attr('fill', 'white')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '.35em')
.text(translate('Move carbs'));
chart().drag.append('rect')
- .attr({
- class: 'drag-droparea'
- , x: insulinRect.x
- , y: insulinRect.y
- , width: insulinRect.width
- , height: insulinRect.height
- , fill: '#0099ff'
- , opacity: 0.4
- , rx: 10
- , ry: 10
- });
+ .attr('class', 'drag-droparea')
+ .attr('x', insulinRect.x)
+ .attr('y', insulinRect.y)
+ .attr('width', insulinRect.width)
+ .attr('height', insulinRect.height)
+ .attr('fill', '#0099ff')
+ .attr('opacity', 0.4)
+ .attr('rx', 10)
+ .attr('ry', 10);
chart().drag.append('text')
- .attr({
- class: 'drag-droparea'
- , x: insulinRect.x + insulinRect.width / 2
- , y: insulinRect.y + insulinRect.height / 2
- , 'font-size': 15
- , 'font-weight': 'bold'
- , fill: '#0099ff'
- , 'text-anchor': 'middle'
- , dy: '.35em'
- })
+ .attr('class', 'drag-droparea')
+ .attr('x', insulinRect.x + insulinRect.width / 2)
+ .attr('y', insulinRect.y + insulinRect.height / 2)
+ .attr('font-size', 15)
+ .attr('font-weight', 'bold')
+ .attr('fill', '#0099ff')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '.35em')
.text(translate('Move insulin'));
}
@@ -665,7 +755,7 @@ function init (client, d3) {
})
.on('drag', function() {
//console.log(d3.event);
- client.tooltip.transition().style('opacity', .9);
+ client.tooltip.style('opacity', .9);
var x = Math.min(Math.max(0, d3.event.x), chart().charts.attr('width'));
var y = Math.min(Math.max(0, d3.event.y), chart().focusHeight);
@@ -692,19 +782,16 @@ function init (client, d3) {
chart().drag.selectAll('.arrow').remove();
chart().drag.append('line')
- .attr({
- 'class': 'arrow'
- , 'marker-end': 'url(#arrow)'
- , 'x1': chart().xScale(new Date(treatment.mills))
- , 'y1': chart().yScale(client.sbx.scaleEntry(treatment))
- , 'x2': x
- , 'y2': y
- , 'stroke-width': 2
- , 'stroke': 'white'
- });
-
+ .attr('class', 'arrow')
+ .attr('marker-end', 'url(#arrow)')
+ .attr('x1', chart().xScale(getOrAddDate(treatment)))
+ .attr('y1', chart().yScale(client.sbx.scaleEntry(treatment)))
+ .attr('x2', x)
+ .attr('y2', y)
+ .attr('stroke-width', 2)
+ .attr('stroke', 'white');
})
- .on('dragend', function() {
+ .on('end', function() {
var newTreatment;
chart().drag.selectAll('.drag-droparea').remove();
hideTooltip();
@@ -719,7 +806,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -736,7 +823,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -753,7 +840,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -769,7 +856,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -797,7 +884,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -825,7 +912,7 @@ function init (client, d3) {
}
, function callback (result) {
console.log(result);
- chart().drag.selectAll('.arrow').transition().duration(5000).style('opacity', 0).remove();
+ chart().drag.selectAll('.arrow').style('opacity', 0).remove();
}
);
} else {
@@ -841,7 +928,7 @@ function init (client, d3) {
.enter()
.append('g')
.attr('class', 'draggable-treatment')
- .attr('transform', 'translate(' + chart().xScale(new Date(treatment.mills)) + ', ' + chart().yScale(client.sbx.scaleEntry(treatment)) + ')')
+ .attr('transform', 'translate(' + chart().xScale(getOrAddDate(treatment)) + ', ' + chart().yScale(client.sbx.scaleEntry(treatment)) + ')')
.on('mouseover', treatmentTooltip)
.on('mouseout', hideTooltip);
if (client.editMode) {
@@ -877,11 +964,15 @@ function init (client, d3) {
.attr('id', 'label')
.style('fill', 'white');
- // reduce the treatment label font size to make it readable with SMB
- var fontBaseSize = (opts.treatments >= 30) ? 40 : 50 - Math.floor((25 - opts.treatments) / 30 * 10);
-
label.append('text')
- .style('font-size', fontBaseSize / opts.scale)
+ .style('font-size', function(d) {
+ var fontSize = ( (opts.treatments >= 30) ? 40 : 50 - Math.floor((25 - opts.treatments) / 30 * 10) ) / opts.scale;
+ var elementValue = parseFloat(d.element);
+ if (!isNaN(elementValue) && elementValue < 1) {
+ fontSize = (25 + Math.floor(elementValue * 10)) / opts.scale;
+ }
+ return fontSize;
+ })
.style('text-shadow', '0px 0px 10px rgba(0, 0, 0, 1)')
.attr('text-anchor', 'middle')
.attr('dy', '.35em')
@@ -899,6 +990,7 @@ function init (client, d3) {
renderer.drawTreatments = function drawTreatments (client) {
var treatmentCount = 0;
+ var renderBasal = client.settings.extendedSettings.bolus.render;
chart().focus.selectAll('.draggable-treatment').remove();
_.forEach(client.ddata.treatments, function eachTreatment (d) {
@@ -907,23 +999,25 @@ function init (client, d3) {
// add treatment bubbles
_.forEach(client.ddata.treatments, function eachTreatment (d) {
+ var showLabels = ( !d.carbs && ( ( renderBasal == 'none') || ( renderBasal === 'over' && d.insulin < client.settings.bolusRenderOver) ) ) ? false : true;
renderer.drawTreatment(d, {
scale: renderer.bubbleScale()
- , showLabels: true
+ , showLabels: showLabels
, treatments: treatmentCount
- }, client.sbx.data.profile.getCarbRatio(new Date()));
+ }, client.sbx.data.profile.getCarbRatio(new Date()),
+ renderBasal);
});
};
- renderer.drawTreatment = function drawTreatment (treatment, opts, carbratio) {
- if (!treatment.carbs && !treatment.insulin) {
+ renderer.drawTreatment = function drawTreatment (treatment, opts, carbratio, renderBasal) {
+ if (!treatment.carbs && !treatment.protein && !treatment.fat && !treatment.insulin) {
return;
}
//when the tests are run window isn't available
var innerWidth = window && window.innerWidth || -1;
// don't render the treatment if it's not visible
- if (Math.abs(chart().xScale(new Date(treatment.mills))) > innerWidth) {
+ if (Math.abs(chart().xScale(getOrAddDate(treatment))) > innerWidth) {
return;
}
@@ -933,7 +1027,7 @@ function init (client, d3) {
return;
}
- var arc = prepareArc(treatment, radius);
+ var arc = prepareArc(treatment, radius, renderBasal);
var treatmentDots = appendTreatments(treatment, arc);
appendLabels(treatmentDots, arc, opts);
};
@@ -947,8 +1041,9 @@ function init (client, d3) {
var basalareadata = [];
var tempbasalareadata = [];
var comboareadata = [];
- var from = chart().brush.extent()[0].getTime();
- var to = Math.max(chart().brush.extent()[1].getTime(), client.sbx.time) + client.forecastTime;
+ var selectedRange = chart().createAdjustedRange();
+ var from = selectedRange[0].getTime();
+ var to = selectedRange[1].getTime();
var date = from;
var lastbasal = 0;
@@ -1007,16 +1102,16 @@ function init (client, d3) {
chart().basals.selectAll('.tempbasalarea').remove().data(tempbasalareadata);
chart().basals.selectAll('.comboarea').remove().data(comboareadata);
- var valueline = d3.svg.line()
- .interpolate('step-after')
+ var valueline = d3.line()
.x(function(d) { return chart().xScaleBasals(d.d); })
- .y(function(d) { return chart().yScaleBasals(d.b); });
+ .y(function(d) { return chart().yScaleBasals(d.b); })
+ .curve(d3.curveStepAfter);
- var area = d3.svg.area()
- .interpolate('step-after')
+ var area = d3.area()
.x(function(d) { return chart().xScaleBasals(d.d); })
.y0(chart().yScaleBasals(0))
- .y1(function(d) { return chart().yScaleBasals(d.b); });
+ .y1(function(d) { return chart().yScaleBasals(d.b); })
+ .curve(d3.curveStepAfter);
var g = chart().basals.append('g');
@@ -1087,7 +1182,7 @@ function init (client, d3) {
}
function profileTooltip (d) {
- return '' + translate('Time') + ': ' + client.formatTime(new Date(d.mills)) + ' ' +
+ return '' + translate('Time') + ': ' + client.formatTime(getOrAddDate(d)) + ' ' +
(d.eventType ? '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(d.eventType)) + ' ' : '') +
(d.endprofile ? '' + translate('End of profile') + ': ' + d.endprofile + ' ' : '') +
(d.profile ? '' + translate('Profile') + ': ' + d.profile + ' ' : '') +
@@ -1097,8 +1192,9 @@ function init (client, d3) {
}
// calculate position of profile on left side
- var from = chart().brush.extent()[0].getTime();
- var to = chart().brush.extent()[1].getTime();
+ var selectedRange = chart().createAdjustedRange();
+ var from = selectedRange[0].getTime();
+ var to = selectedRange[1].getTime();
var mult = (to - from) / times.hours(24).msecs;
from += times.mins(20 * mult).msecs;
@@ -1137,8 +1233,7 @@ function init (client, d3) {
return ret;
};
- treatProfiles.transition().duration(0)
- .attr('transform', function(t) {
+ treatProfiles.attr('transform', function(t) {
// change text of record on left side
return 'rotate(-90,' + chart().xScale(t.mills) + ',' + chart().yScaleBasals(topOfText) + ') ' +
'translate(' + chart().xScale(t.mills) + ',' + chart().yScaleBasals(topOfText) + ')';
@@ -1158,7 +1253,7 @@ function init (client, d3) {
})
.text(generateText)
.on('mouseover', function(d) {
- client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9);
+ client.tooltip.style('opacity', .9);
client.tooltip.html(profileTooltip(d))
.style('left', (d3.event.pageX) + 'px')
.style('top', (d3.event.pageY + 15) + 'px');
diff --git a/lib/constants.json b/lib/constants.json
index 31a4524b4d3..c2736f7e6e9 100644
--- a/lib/constants.json
+++ b/lib/constants.json
@@ -4,5 +4,6 @@
"HTTP_UNAUTHORIZED" : 401,
"HTTP_VALIDATION_ERROR" : 422,
"HTTP_INTERNAL_ERROR" : 500,
- "ENTRIES_DEFAULT_COUNT" : 10
+ "ENTRIES_DEFAULT_COUNT" : 10,
+ "MMOL_TO_MGDL": 18
}
diff --git a/lib/data/calcdelta.js b/lib/data/calcdelta.js
index e3e0fde7052..c991bd8d602 100644
--- a/lib/data/calcdelta.js
+++ b/lib/data/calcdelta.js
@@ -75,7 +75,7 @@ module.exports = function calcDelta (oldData, newData) {
var result = [];
l = newArray.length;
for (var j = 0; j < l; j++) {
- if (!seen.hasOwnProperty(newArray[j].mills)) {
+ if (!Object.prototype.hasOwnProperty.call(seen, newArray[j].mills)) {
result.push(newArray[j]);
}
}
@@ -94,12 +94,12 @@ module.exports = function calcDelta (oldData, newData) {
var changesFound = false;
for (var array in compressibleArrays) {
- if (compressibleArrays.hasOwnProperty(array)) {
+ if (Object.prototype.hasOwnProperty.call(compressibleArrays, array)) {
var a = compressibleArrays[array];
- if (newData.hasOwnProperty(a)) {
+ if (Object.prototype.hasOwnProperty.call(newData, a)) {
// if previous data doesn't have the property (first time delta?), just assign data over
- if (!oldData.hasOwnProperty(a)) {
+ if (!Object.prototype.hasOwnProperty.call(oldData, a)) {
delta[a] = newData[a];
changesFound = true;
continue;
@@ -125,9 +125,9 @@ module.exports = function calcDelta (oldData, newData) {
var changesFound = false;
for (var object in skippableObjects) {
- if (skippableObjects.hasOwnProperty(object)) {
+ if (Object.prototype.hasOwnProperty.call(skippableObjects, object)) {
var o = skippableObjects[object];
- if (newData.hasOwnProperty(o)) {
+ if (Object.prototype.hasOwnProperty.call(newData, o)) {
if (JSON.stringify(newData[o]) !== JSON.stringify(oldData[o])) {
//console.log('delta changes found on', o);
changesFound = true;
diff --git a/lib/data/dataloader.js b/lib/data/dataloader.js
index cc4426aa6ab..b0eafdd4fe0 100644
--- a/lib/data/dataloader.js
+++ b/lib/data/dataloader.js
@@ -11,8 +11,7 @@ var ONE_DAY = 86400000,
function uniq(a) {
var seen = {};
return a.filter(function(item) {
- // eslint-disable-next-line no-prototype-builtins
- return seen.hasOwnProperty(item.mills) ? false : (seen[item.mills] = true);
+ return Object.prototype.hasOwnProperty.call(seen, item.mills) ? false : (seen[item.mills] = true);
});
}
@@ -70,6 +69,7 @@ function init(env, ctx) {
// clear treatments, we're going to merge from more queries
ddata.treatments = [];
+ ddata.dbstats = {};
async.parallel([
loadEntries.bind(null, ddata, ctx)
@@ -80,6 +80,7 @@ function init(env, ctx) {
, loadFood.bind(null, ddata, ctx)
, loadDeviceStatus.bind(null, ddata, env, ctx)
, loadActivity.bind(null, ddata, ctx)
+ , loadDatabaseStats.bind(null, ddata, ctx)
], loadComplete);
};
@@ -193,7 +194,6 @@ function loadActivity(ddata, ctx, callback) {
}
};
- var activity = [];
ctx.activity.list(q, function(err, results) {
if (err) {
@@ -401,5 +401,21 @@ function loadDeviceStatus(ddata, env, ctx, callback) {
});
}
+function loadDatabaseStats(ddata, ctx, callback) {
+ ctx.store.db.stats(function mongoDone (err, result) {
+ if (err) {
+ console.log("Problem loading database stats");
+ }
+ if (!err && result) {
+ ddata.dbstats = {
+ dataSize: result.dataSize
+ , indexSize: result.indexSize
+ , fileSize: result.fileSize
+ };
+ }
+ callback();
+ });
+}
+
module.exports = init;
diff --git a/lib/data/ddata.js b/lib/data/ddata.js
index 1912ca4a6ae..b5226124ccf 100644
--- a/lib/data/ddata.js
+++ b/lib/data/ddata.js
@@ -2,6 +2,7 @@
var _ = require('lodash');
var times = require('../times');
+var consts = require('../constants');
var DEVICE_TYPE_FIELDS = ['uploader', 'pump', 'openaps', 'loop', 'xdripjs'];
@@ -16,6 +17,7 @@ function init () {
, devicestatus: []
, food: []
, activity: []
+ , dbstats: {}
, lastUpdated: 0
};
@@ -32,39 +34,11 @@ function init () {
});
};
- ddata.splitRecent = function splitRecent (time, cutoff, max, treatmentsToo) {
- var result = {
- first: {}
- , rest: {}
- };
-
- function recent (item) {
- return item.mills >= time - cutoff;
- }
-
- function filterMax (item) {
- return item.mills >= time - max;
- }
-
- function partition (field, filter) {
- var data;
- if (filter) {
- data = ddata[field].filter(filterMax);
- } else {
- data = ddata[field];
- }
-
- var parts = _.partition(data, recent);
- result.first[field] = parts[0];
- result.rest[field] = parts[1];
- }
-
- partition('treatments', treatmentsToo ? filterMax : false);
-
- result.first.devicestatus = ddata.recentDeviceStatus(time);
-
- result.first.sgvs = ddata.sgvs.filter(filterMax);
- result.first.cals = ddata.cals;
+ ddata.dataWithRecentStatuses = function dataWithRecentStatuses() {
+ var results = {};
+ results.devicestatus = ddata.recentDeviceStatus(Date.now());
+ results.sgvs = ddata.sgvs;
+ results.cals = ddata.cals;
var profiles = _.cloneDeep(ddata.profiles);
if (profiles && profiles[0]) {
@@ -74,17 +48,15 @@ function init () {
}
})
}
- result.first.profiles = profiles;
-
- result.rest.mbgs = ddata.mbgs.filter(filterMax);
- result.rest.food = ddata.food;
- result.rest.activity = ddata.activity;
+ results.profiles = profiles;
+ results.mbgs = ddata.mbgs;
+ results.food = ddata.food;
+ results.treatments = ddata.treatments;
+ results.dbstats = ddata.dbstats;
- console.log('results.first size', JSON.stringify(result.first).length, 'bytes');
- console.log('results.rest size', JSON.stringify(result.rest).length, 'bytes');
+ return results;
- return result;
- };
+ }
ddata.recentDeviceStatus = function recentDeviceStatus (time) {
@@ -244,18 +216,18 @@ function init () {
if (t.units) {
if (t.units == 'mmol') {
//convert to mgdl
- t.targetTop = t.targetTop * 18;
- t.targetBottom = t.targetBottom * 18;
+ t.targetTop = t.targetTop * consts.MMOL_TO_MGDL;
+ t.targetBottom = t.targetBottom * consts.MMOL_TO_MGDL;
t.units = 'mg/dl';
}
}
//if we have a temp target thats below 20, assume its mmol and convert to mgdl for safety.
if (t.targetTop < 20) {
- t.targetTop = t.targetTop * 18;
+ t.targetTop = t.targetTop * consts.MMOL_TO_MGDL;
t.units = 'mg/dl';
}
if (t.targetBottom < 20) {
- t.targetBottom = t.targetBottom * 18;
+ t.targetBottom = t.targetBottom * consts.MMOL_TO_MGDL;
t.units = 'mg/dl';
}
return t.eventType && t.eventType.indexOf('Temporary Target') > -1;
diff --git a/lib/data/treatmenttocurve.js b/lib/data/treatmenttocurve.js
index 8bca564d091..714fb9049e6 100644
--- a/lib/data/treatmenttocurve.js
+++ b/lib/data/treatmenttocurve.js
@@ -1,9 +1,10 @@
'use strict';
var _ = require('lodash');
+var consts = require('../constants');
const MAX_BG_MMOL = 22;
-const MAX_BG_MGDL = MAX_BG_MMOL * 18;
+const MAX_BG_MGDL = MAX_BG_MMOL * consts.MMOL_TO_MGDL;
module.exports = function fitTreatmentsToBGCurve (ddata, env, ctx) {
@@ -40,7 +41,7 @@ module.exports = function fitTreatmentsToBGCurve (ddata, env, ctx) {
calcedBG = mgdlAfter;
}
- return calcedBG || 180;
+ return Math.round(calcedBG) || 180;
}
function mgdlValue (entry) {
diff --git a/lib/hashauth.js b/lib/hashauth.js
index 0840381a24c..8848ca08ef0 100644
--- a/lib/hashauth.js
+++ b/lib/hashauth.js
@@ -9,6 +9,7 @@ var hashauth = {
, apisecrethash: null
, authenticated: false
, initialized: false
+ , tokenauthenticated: false
};
hashauth.init = function init(client, $) {
@@ -24,15 +25,27 @@ hashauth.init = function init(client, $) {
, url: '/api/v1/verifyauth?t=' + Date.now() //cache buster
, headers: client.headers()
}).done(function verifysuccess (response) {
- if (response.message === 'OK') {
+
+ if (response.message.rolefound == 'FOUND') {
+ hashauth.tokenauthenticated = true;
+ console.log('Token Authentication passed.');
+ client.authorizeSocket();
+ next(true);
+ return;
+ }
+
+ if (response.message.message === 'OK') {
hashauth.authenticated = true;
console.log('Authentication passed.');
next(true);
- } else {
- console.log('Authentication failed.', response);
+ return;
+ }
+
+ console.log('Authentication failed.', response);
hashauth.removeAuthentication();
next(false);
- }
+ return;
+
}).fail(function verifyfail (err) {
console.log('Authentication failed.', err);
hashauth.removeAuthentication();
@@ -60,7 +73,7 @@ hashauth.init = function init(client, $) {
Storages.localStorage.remove('apisecrethash');
- if (hashauth.authenticated) {
+ if (hashauth.authenticated || hashauth.tokenauthenticated) {
client.browserUtils.reload();
}
@@ -78,8 +91,15 @@ hashauth.init = function init(client, $) {
hashauth.requestAuthentication = function requestAuthentication (eventOrNext) {
var translate = client.translate;
hashauth.injectHtml();
+
+ var clientWidth = window.innerWidth
+ || document.documentElement.clientWidth
+ || document.body.clientWidth;
+
+ clientWidth = Math.min(400, clientWidth);
+
$( '#requestauthenticationdialog' ).dialog({
- width: 400
+ width: clientWidth
, height: 270
, closeText: ''
, buttons: [
@@ -139,6 +159,8 @@ hashauth.init = function init(client, $) {
if (isok) {
if (hashauth.storeapisecret) {
Storages.localStorage.set('apisecrethash',hashauth.apisecrethash);
+ // TODO show dialog first, then reload
+ if (hashauth.tokenauthenticated) client.browserUtils.reload();
}
$('#authentication_placeholder').html(hashauth.inlineCode());
if (callback) {
@@ -159,9 +181,18 @@ hashauth.init = function init(client, $) {
var status = null;
- if (client.authorized) {
- status = translate('Authorized by token') + ' (' + translate('view without token') + ')' +
- ' ' + client.authorized.sub + ': ' + client.authorized.permissionGroups.join(', ') + '';
+ if (client.authorized || hashauth.tokenauthenticated) {
+ status = translate('Authorized by token');
+ if (client.authorized && client.authorized.sub) {
+ status += ' ' + client.authorized.sub + ': ' + client.authorized.permissionGroups.join(', ') + '';
+ }
+ if (hashauth.apisecrethash)
+ {
+ status += ' (' + translate('Remove stored token') + ')';
+ } else {
+ status += ' (' + translate('view without token') + ')';
+ }
+
} else if (hashauth.isAuthenticated()) {
console.info('status isAuthenticated', hashauth);
status = translate('Admin authorized') + ' (' + translate('Remove') + ')';
@@ -171,13 +202,10 @@ hashauth.init = function init(client, $) {
var html =
'