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..23cc61e5ef7
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,32 @@
+name: CI test
+
+on: [push]
+
+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
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..c8e87e2db4a 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://discordapp.com/channels/629952586895851530/629952669967974410
## 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.
@@ -203,13 +191,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 +212,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 +221,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,13 +240,13 @@ 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%|
@@ -279,7 +267,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..f4a0452dd9d 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
@@ -97,8 +97,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-xdripjs)
- [`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)
@@ -119,7 +120,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 +130,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 +158,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 +166,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 +182,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 +192,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 +203,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 +220,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 +238,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 +256,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 +272,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 +286,12 @@ 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.
### 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 +300,13 @@ 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.
### Plugins
@@ -321,7 +316,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 +338,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.
@@ -441,7 +435,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
* `BRIDGE_USER_NAME` - Your user name 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_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).
@@ -481,14 +475,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 +492,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.
@@ -545,13 +547,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
@@ -632,6 +634,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 +649,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..7c6f67b1ee1 100644
--- a/app.js
+++ b/app.js
@@ -117,8 +117,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) {
@@ -164,13 +167,24 @@ function create (env, ctx) {
});
});
+ 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 +238,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 +247,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..3d86d59bfa2 100644
--- a/app.json
+++ b/app.json
@@ -53,22 +53,22 @@
"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
},
@@ -93,7 +93,7 @@
"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
},
diff --git a/ci.test.env b/ci.test.env
new file mode 100644
index 00000000000..c57e5eeb0c4
--- /dev/null
+++ b/ci.test.env
@@ -0,0 +1,7 @@
+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
\ 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..4e298df4b74 100644
--- a/docs/plugins/alexa-plugin.md
+++ b/docs/plugins/alexa-plugin.md
@@ -13,9 +13,9 @@
- [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 +41,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 +58,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".
@@ -238,112 +85,71 @@ Click on the "Test" tab on the top menu. This will take you to the page where yo
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..4cb10aa0643
--- /dev/null
+++ b/docs/plugins/alexa-templates/en-us.json
@@ -0,0 +1,222 @@
+{
+ "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": "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"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "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..6a8498b0b19
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..f4bddb4d9cb
--- /dev/null
+++ b/docs/plugins/googlehome-plugin.md
@@ -0,0 +1,140 @@
+
+
+**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)
+ - [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 (Ketchup)](https://github.com/nightscout/cgm-remote-monitor/releases/tag/13.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`
+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?"
+
+### 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..984a876f21c
--- /dev/null
+++ b/docs/plugins/interacting-with-virtual-assistants.md
@@ -0,0 +1,67 @@
+
+
+**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 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"
+
+## 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/alexa/index.js b/lib/api/alexa/index.js
index 65f477ad85d..337ec00f732 100644
--- a/lib/api/alexa/index.js
+++ b/lib/api/alexa/index.js
@@ -4,156 +4,176 @@ 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});
+ 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.virtAsst) {
+ if (plugin.virtAsst.intentHandlers) {
+ console.log('Alexa: Plugin ' + plugin.name + ' supports Virtual Assistants');
+ _each(plugin.virtAsst.intentHandlers, function (route) {
+ if (route) {
+ ctx.alexa.configureIntentHandler(route.intent, route.intentHandler, route.metrics);
+ }
});
- // console.log('BG results called');
- // callback(null, 'BG results');
- }, 'BG Status');
-
- 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);
+ }
+ if (plugin.virtAsst.rollupHandlers) {
+ console.log('Alexa: Plugin ' + plugin.name + ' supports rollups for Virtual Assistants');
+ _each(plugin.virtAsst.rollupHandlers, function (route) {
+ console.log('Route');
+ console.log(route);
+ if (route) {
+ ctx.alexa.addToRollup(route.rollupGroup, route.rollupHandler, route.rollupName);
+ }
});
- }, '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);
- });
- });
-
-
- function onLaunch() {
- console.log('Session launched');
+ }
+ } else {
+ console.log('Alexa: Plugin ' + plugin.name + ' does not support Virtual Assistants');
}
-
- function onIntent(intent, next) {
- console.log('Received intent request');
- console.log(JSON.stringify(intent));
- handleIntent(intent.name, intent.slots, next);
+ });
+
+ 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);
}
- function onSessionEnded() {
- console.log('Session ended');
+ 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('virtAsstStatus', {
+ params: [
+ sbx.scaleMgdl(records[0].sgv),
+ direction,
+ moment(records[0].date).from(moment(sbx.time))
+ ]
+ });
+
+ callback(null, {results: status, priority: -1});
+ });
+ }, 'BG Status');
+
+ 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('virtAsstStatus', {
+ params: [
+ sbx.scaleMgdl(records[0].sgv),
+ direction,
+ moment(records[0].date).from(moment(sbx.time))]
+ });
+
+ callback(translate('virtAsstTitleCurrentBG'), status);
+ });
+ }, ['bg', 'blood glucose', 'number']);
- 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');
- }
+ ctx.alexa.configureIntentHandler('NSStatus', function (callback, slots, sbx, locale) {
+ ctx.alexa.getRollup('Status', sbx, slots, locale, function (status) {
+ callback(translate('virtAsstTitleFullStatus'), status);
+ });
+ });
+
+
+ function onLaunch(intent, next) {
+ console.log('Session launched');
+ console.log(JSON.stringify(intent));
+ handleIntent(intent.name, intent.slots, next);
+ }
+
+ function onIntent(intent, next) {
+ console.log('Received intent request');
+ console.log(JSON.stringify(intent));
+ handleIntent(intent.name, intent.slots, next);
+ }
+
+ function onSessionEnded() {
+ console.log('Session ended');
+ }
+
+ 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'));
+ }
}
- function initializeSandbox() {
- var sbx = require('../../sandbox')();
- sbx.serverInit(env, ctx);
- ctx.plugins.setProperties(sbx);
- return sbx;
+ var handler = ctx.alexa.getIntentHandler(intentName, metric);
+ if (handler){
+ var sbx = initializeSandbox();
+ handler(next, slots, sbx);
+ } else {
+ next(translate('virtAsstUnknownIntentTitle'), translate('virtAsstUnknownIntentText'));
}
+ }
+
+ function initializeSandbox() {
+ var sbx = require('../../sandbox')();
+ sbx.serverInit(env, ctx);
+ ctx.plugins.setProperties(sbx);
+ return sbx;
+ }
- return api;
+ return api;
}
-module.exports = configure;
+module.exports = configure;
\ No newline at end of file
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/entries/index.js b/lib/api/entries/index.js
index 0c8b8fc1ef7..faf922ff0e8 100644
--- a/lib/api/entries/index.js
+++ b/lib/api/entries/index.js
@@ -408,7 +408,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 +434,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 +476,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..2b2caa2a378
--- /dev/null
+++ b/lib/api/googlehome/index.js
@@ -0,0 +1,123 @@
+'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.virtAsst) {
+ if (plugin.virtAsst.intentHandlers) {
+ console.log('Google Home: Plugin ' + plugin.name + ' supports Virtual Assistants');
+ _each(plugin.virtAsst.intentHandlers, function (route) {
+ if (route) {
+ ctx.googleHome.configureIntentHandler(route.intent, route.intentHandler, route.metrics);
+ }
+ });
+ }
+ if (plugin.virtAsst.rollupHandlers) {
+ console.log('Google Home: Plugin ' + plugin.name + ' supports rollups for Virtual Assistants');
+ _each(plugin.virtAsst.rollupHandlers, function (route) {
+ console.log('Route');
+ console.log(route);
+ if (route) {
+ ctx.googleHome.addToRollup(route.rollupGroup, route.rollupHandler, route.rollupName);
+ }
+ });
+ }
+ } else {
+ console.log('Google Home: Plugin ' + plugin.name + ' does not support Virtual Assistants');
+ }
+ });
+
+ 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( );
+ }, req.body.queryResult.parameters, sbx);
+ } else {
+ res.json(ctx.googleHome.buildSpeechletResponse('I\'m sorry. I don\'t know what you\'re asking for. Could you say that again?', true));
+ next( );
+ }
+ });
+
+ ctx.googleHome.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('virtAsstStatus', {
+ params: [
+ sbx.scaleMgdl(records[0].sgv),
+ direction,
+ moment(records[0].date).from(moment(sbx.time))
+ ]
+ });
+
+ callback(null, {results: status, priority: -1});
+ });
+ }, 'BG Status');
+
+ ctx.googleHome.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('virtAsstStatus', {
+ params: [
+ sbx.scaleMgdl(records[0].sgv),
+ direction,
+ moment(records[0].date).from(moment(sbx.time))]
+ });
+
+ callback(translate('virtAsstTitleCurrentBG'), status);
+ });
+ }, ['bg', 'blood glucose', 'number']);
+
+ ctx.googleHome.configureIntentHandler('NSStatus', function (callback, slots, sbx, locale) {
+ ctx.googleHome.getRollup('Status', sbx, slots, locale, function (status) {
+ callback(translate('virtAsstTitleFullStatus'), status);
+ });
+ });
+
+ function initializeSandbox() {
+ var sbx = require('../../sandbox')();
+ sbx.serverInit(env, ctx);
+ ctx.plugins.setProperties(sbx);
+ return sbx;
+ }
+
+ return api;
+}
+
+module.exports = configure;
\ No newline at end of file
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/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..a6ab2e82750 100644
--- a/lib/api/status.js
+++ b/lib/api/status.js
@@ -14,6 +14,9 @@ function configure (app, wares, env, ctx) {
// Status badge/text/json
api.get('/status', function (req, res) {
+
+ var authToken = req.query.token || req.query.secret || '';
+
var date = new Date();
var info = { status: 'ok'
, name: app.get('name')
@@ -25,7 +28,7 @@ function configure (app, wares, env, ctx) {
, boluscalcEnabled: app.enabled('api') && env.settings.enable.indexOf('boluscalc') > -1
, settings: env.settings
, extendedSettings: app.extendedClientSettings
- , authorized: ctx.authorization.authorize(req.query.token || '')
+ , authorized: ctx.authorization.authorize(authToken)
};
var badge = 'http://img.shields.io/badge/Nightscout-OK-green';
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..1c3dfd873ee
--- /dev/null
+++ b/lib/api3/const.json
@@ -0,0 +1,53 @@
+{
+ "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,
+ "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_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/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..4ac80a37e94
--- /dev/null
+++ b/lib/api3/generic/create/insert.js
@@ -0,0 +1,45 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , validate = require('./validate.js')
+ ;
+
+/**
+ * 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', `${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..0929a09cc4f
--- /dev/null
+++ b/lib/api3/generic/history/operation.js
@@ -0,0 +1,151 @@
+'use strict';
+
+const dateTools = require('../../shared/dateTools')
+ , 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).send(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..04d6f03bc70
--- /dev/null
+++ b/lib/api3/generic/read/operation.js
@@ -0,0 +1,75 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ , dateTools = require('../../shared/dateTools')
+ , 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).send(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..074f864d58a
--- /dev/null
+++ b/lib/api3/generic/search/operation.js
@@ -0,0 +1,77 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , opTools = require('../../shared/operationTools')
+ , 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).send(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..ca490b31136
--- /dev/null
+++ b/lib/api3/generic/update/replace.js
@@ -0,0 +1,52 @@
+'use strict';
+
+const apiConst = require('../../const.json')
+ , security = require('../../security')
+ , validate = require('./validate.js')
+ ;
+
+/**
+ * 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', `${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..5b1799c78fd
--- /dev/null
+++ b/lib/api3/index.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const express = require('express')
+ , bodyParser = require('body-parser')
+ , 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
+ }
+ });
+
+ // 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/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..2d434e97f53
--- /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.yaml', 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..17db893e0ef
--- /dev/null
+++ b/lib/api3/swagger.yaml
@@ -0,0 +1,1614 @@
+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'
+
+
+ ######################################################################################
+ 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'
+ 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'
+
+
+ ######################################################################################
+ /{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'
+
+
+ ######################################################################################
+ /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.
+
+ 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'
+
+ 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'
+ headers:
+ 'Last-Modified':
+ $ref: '#/components/schemas/headerLastModified'
+
+ history200:
+ description:
+ Changed documents since specified timestamp
+ content:
+ application/json:
+ 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
+ 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
+ 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..feaed739b42 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,6 +168,21 @@ 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) {
@@ -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..3a4c4490876 100644
--- a/lib/authorization/storage.js
+++ b/lib/authorization/storage.js
@@ -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/client/browser-settings.js b/lib/client/browser-settings.js
index 4f990eb57f3..2119b39da40 100644
--- a/lib/client/browser-settings.js
+++ b/lib/client/browser-settings.js
@@ -135,7 +135,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 () {
diff --git a/lib/client/careportal.js b/lib/client/careportal.js
index 4a89ffcebc8..a5c86232d8e 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']);
+ });
- _.forEach(careportal.allEventTypes, function each (event) {
- inputMatrix[event.val] = _.pick(event, ['bg', 'insulin', 'carbs', 'protein', 'fat', 'prebolus', 'duration', 'percent', 'absolute', 'profile', 'split', 'reasons', 'targets']);
- });
+ 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']);
+ 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()
@@ -216,9 +231,18 @@ function init (client, $) {
, units: client.settings.units
};
+ 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
@@ -272,17 +296,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 +359,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 +398,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..a5db09f416a 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,186 @@ 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 localeFormatter = d3.locale(d3locales.locale(client.settings.language));
-
- function brushStarted ( ) {
+ var cx = d3.mouse(this)[0];
+ var x0 = cx - dx / 2;
+ var x1 = cx + dx / 2;
+
+ 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
chart.context.selectAll('circle')
.data(client.entries)
- .style('opacity', function (d) { return renderer.highlightBrushPoints(d) });
+ .style('opacity', function(d) { return renderer.highlightBrushPoints(d) });
}
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 = d3.scale.linear( )
- .domain([times.mins(25).msecs, times.mins(60).msecs])
- .range([0.8, 0.1]);
+ chart.futureOpacity = (function() {
+ var scale = d3.scaleLinear()
+ .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 +228,79 @@ 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');
-
- function createAdjustedRange() {
- var range = chart.brush.extent().slice();
+ .attr('class', 'y axis')
+ .style("font-size", "16px");
+
+ chart.createBrushedRange = function() {
+ var brushedRange = chart.theBrush && d3.brushSelection(chart.theBrush.node()) || null;
+ var range = brushedRange && brushedRange.map(chart.xScale2.invert);
+ var dataExtent = client.dataExtent();
+
+ 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() + client.forecastTime;
+ 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);
return range;
}
- chart.inRetroMode = function inRetroMode() {
- if (!chart.brush || !chart.xScale2) {
+ chart.createAdjustedRange = function() {
+ var adjustedRange = chart.createBrushedRange();
+
+ adjustedRange[1] = new Date(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 +312,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 +337,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 +359,48 @@ 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);
// 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 +408,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 +418,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 +428,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 +439,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 +455,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 +484,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('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 +567,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 +574,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('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 () {
+ scrolling = false;
+
+ var nowDate = scrollNow;
+
+ var currentBrushExtent = scrollBrushExtent;
+ var currentRange = scrollRange;
+
+ 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 +659,67 @@ function init (client, d3, $) {
renderer.addBasals(client);
renderer.addFocusCircles();
- renderer.addTreatmentCircles();
+ renderer.addTreatmentCircles(nowDate);
renderer.addTreatmentProfiles(client);
renderer.drawTreatments(client);
+ }
+
+ 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();
+
+ var focusHoursAheadMills = chart.getMaxForecastMills();
+
+ var selectedRange = chart.createBrushedRange();
+ var to = selectedRange[1].getTime();
+ var maxForecastMills = to + times.mins(30).msecs;
+
+ if (shownForecastPoints.length > 0) {
+ maxForecastMills = _.max(_.map(shownForecastPoints, function(point) { return point.mills }));
+ }
+
+ maxForecastMills = Math.min(focusHoursAheadMills, maxForecastMills);
+
+ var lastSGVMills = client.sbx.lastSGVMills();
+
+ client.forecastTime = ((maxForecastMills > 0) && lastSGVMills) ? maxForecastMills - lastSGVMills : client.defaultForecastTime;
+ }
};
return chart;
diff --git a/lib/client/clock-client.js b/lib/client/clock-client.js
index 18b5116f94d..324a5168693 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,13 +107,6 @@ 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
if (clockFace === 'clock-color') {
@@ -122,53 +124,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/index.js b/lib/client/index.js
index 979348c0cb5..eb6c4a9fd93 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')($);
@@ -102,8 +107,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 +128,15 @@ 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.forecastTime = client.defaultForecastTime = times.mins(30).msecs;
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,14 +385,14 @@ 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('Resetting brush in updateBrushToNow: ', brushExtent);
+
+ chart.theBrush && chart.theBrush.call(chart.brush.move, brushExtent.map(chart.xScale2));
if (!skipBrushing) {
brushed();
@@ -398,21 +408,35 @@ client.load = function load (serverSettings, callback) {
}
function brushed () {
+ // Brush not initialized
+ console.log("brushed");
+ 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());
+
+ if (brushedRange) {
+ brushExtent = brushedRange.map(chart.xScale2.invert);
+ }
- var brushExtent = chart.brush.extent();
+ // console.log('Brushed to: ', brushExtent);
- // ensure that brush extent is fixed at 3.5 hours
- if (brushExtent[1].getTime() - brushExtent[0].getTime() !== client.focusRangeMS) {
+ 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('Updating brushed to: ', brushExtent);
+
+ chart.theBrush.call(chart.brush.move, brushExtent.map(chart.xScale2));
}
function adjustCurrentSGVClasses (value, isCurrent) {
@@ -428,7 +452,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 +473,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 +495,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 +554,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 +595,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) {
@@ -798,6 +848,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);
}
@@ -952,18 +1008,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 +1044,11 @@ client.load = function load (serverSettings, callback) {
}
}
);
+ }
+
+ socket.on('connect', function() {
+ console.log('Client connected to server.');
+ client.authorizeSocket();
});
function hasRequiredPermission () {
@@ -1127,9 +1200,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);
@@ -1162,15 +1240,21 @@ client.load = function load (serverSettings, callback) {
prepareEntries();
updateTitle();
+ // 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/renderer.js b/lib/client/renderer.js
index ccbac62d72b..f058d3de860 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,22 @@ 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()) {
+ var selectedRange = chart().createAdjustedRange();
+ var from = selectedRange[0].getTime();
+ var to = selectedRange[1].getTime();
+
+ if (client.latestSGV && data.mills >= from && data.mills <= to) {
return chart().futureOpacity(data.mills - client.latestSGV.mills);
} else {
return 0.5;
@@ -66,36 +76,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 +99,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 +117,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 +151,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;
+ }
+
+ 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(new Date(d.mills)) + ' ' +
+ 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 +274,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 +286,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 +325,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 +334,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 +386,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;
+ }
+
+ function treatmentTextAnchor (d) {
+ return d.durationType === "indefinite" ? 'left' : 'middle';
+ }
- // if new rect then just display
- var gs = treatRects.enter().append('g')
+ // 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 +485,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 +498,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'));
@@ -463,7 +569,7 @@ function init (client, d3) {
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
+ if ((treatment.insulin < 1 && !treatment.carbs && enteredBy.indexOf('openaps') > -1) || treatment.isSMB) { // 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
dosage_units = (dosage_units + "").replace(/^0/, "");
@@ -476,7 +582,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 +632,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 +661,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 +677,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 +712,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 +759,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 +786,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 +810,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 +827,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 +844,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 +860,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 +888,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 +916,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 +932,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) {
@@ -923,7 +1014,7 @@ function init (client, d3) {
//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;
}
@@ -947,8 +1038,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 +1099,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 +1179,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 +1189,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 +1230,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 +1250,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/ddata.js b/lib/data/ddata.js
index 1912ca4a6ae..9fa470e3f16 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'];
@@ -32,39 +33,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 +47,14 @@ 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;
- 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 +214,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..afa17b397ec 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) {
diff --git a/lib/hashauth.js b/lib/hashauth.js
index 0840381a24c..1421c076535 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,10 @@ hashauth.init = function init(client, $) {
hashauth.requestAuthentication = function requestAuthentication (eventOrNext) {
var translate = client.translate;
hashauth.injectHtml();
+ var clientWidth = Math.min(400, $( '#container')[0].clientWidth);
+
$( '#requestauthenticationdialog' ).dialog({
- width: 400
+ width: clientWidth
, height: 270
, closeText: ''
, buttons: [
@@ -139,6 +154,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 +176,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 +197,10 @@ hashauth.init = function init(client, $) {
var html =
'