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) +
API3_AUTOPRUNE_DEVICESTATUS=60
+
+      API3_AUTOPRUNE_ENTRIES=365
+
+      API3_AUTOPRUNE_TREATMENTS=120
+      
+ 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 = ''+ '
' + status + '
'; @@ -206,7 +229,7 @@ hashauth.init = function init(client, $) { }; hashauth.isAuthenticated = function isAuthenticated() { - return hashauth.authenticated; + return hashauth.authenticated || hashauth.tokenauthenticated; }; hashauth.initialized = true; diff --git a/lib/language.js b/lib/language.js index bd84df3acd0..87fb871e1d1 100644 --- a/lib/language.js +++ b/lib/language.js @@ -667,6 +667,81 @@ function init() { ,tr: 'Son 3 ay' ,zh_cn: '过去3个月' } + , 'between': { + cs: 'between' + ,de: 'between' + ,es: 'between' + ,fr: 'between' + ,el: 'between' + ,pt: 'between' + ,sv: 'between' + ,ro: 'between' + ,bg: 'between' + ,hr: 'between' + ,it: 'between' + ,ja: 'between' + ,dk: 'between' + ,fi: 'between' + ,nb: 'between' + ,he: 'between' + ,pl: 'between' + ,ru: 'between' + ,sk: 'between' + ,nl: 'between' + ,ko: 'between' + ,tr: 'between' + ,zh_cn: 'between' + } + , 'around': { + cs: 'around' + ,de: 'around' + ,es: 'around' + ,fr: 'around' + ,el: 'around' + ,pt: 'around' + ,sv: 'around' + ,ro: 'around' + ,bg: 'around' + ,hr: 'around' + ,it: 'around' + ,ja: 'around' + ,dk: 'around' + ,fi: 'around' + ,nb: 'around' + ,he: 'around' + ,pl: 'around' + ,ru: 'around' + ,sk: 'around' + ,nl: 'around' + ,ko: 'around' + ,tr: 'around' + ,zh_cn: 'around' + } + , 'and': { + cs: 'and' + ,de: 'and' + ,es: 'and' + ,fr: 'and' + ,el: 'and' + ,pt: 'and' + ,sv: 'and' + ,ro: 'and' + ,bg: 'and' + ,hr: 'and' + ,it: 'and' + ,ja: 'and' + ,dk: 'and' + ,fi: 'and' + ,nb: 'and' + ,he: 'and' + ,pl: 'and' + ,ru: 'and' + ,sk: 'and' + ,nl: 'and' + ,ko: 'and' + ,tr: 'and' + ,zh_cn: 'and' + } ,'From' : { cs: 'Od' ,de: 'Von' @@ -2946,57 +3021,11 @@ function init() { ,tr: 'Gizli göster' ,zh_cn: '显示隐藏值' } - ,'Your API secret' : { - cs: 'Vaše API heslo' - ,he: 'הסיסמא הסודית שלך' - ,de: 'Deine API-Prüfsumme' - ,es: 'Su API secreto' - ,fr: 'Votre secret API' - ,el: 'Το συνθηματικό σας' - ,pt: 'Seu segredo de API' - ,sv: 'Din API-nyckel' - ,ro: 'Cheia API' - ,bg: 'Твоята API парола' - ,hr: 'Vaš tajni API' - ,it: 'Il tuo API secreto' - ,ja: 'あなたのAPI Secret' - ,dk: 'Din API-nøgle' - ,fi: 'Sinun API-avaimesi' - ,nb: 'Din API nøkkel' - ,pl: 'Twoje poufne hasło API' - ,ru: 'Ваш пароль API' - ,sk: 'Vaše API heslo' - ,nl: 'Uw API wachtwoord' - ,ko: 'API secret' - ,tr: 'API secret parolanız' - ,zh_cn: 'API密钥' - ,zh_tw: 'API密鑰' - } - ,'Remember the API Secret on this device. (Do not enable this on public computers.)' : { - cs: 'Ulož hash na tomto počítači (používejte pouze na soukromých počítačích)' - ,he: 'אחסן את הסיסמא הסודית שלך על מחשב זה.מומלץ לעשות כן רק אם המחשב בשימושך הפרטי' - ,de: 'Speichere Prüfsumme auf diesem Computer (nur auf privaten Computern verwenden)' - ,es: 'Guardar hash en este ordenador (Usar solo en ordenadores privados)' - ,fr: 'Sauvegarder le hash sur cet ordinateur (privé uniquement)' - ,el: 'Αποθήκευση συνθηματικού σε αυτό τον υπολογιστή (μόνο για υπολογιστές προσωπικής χρήσης)' - ,pt: 'Salvar hash nesse computador (Somente em computadores privados)' - ,ro: 'Salvează cheia pe acest PC (Folosiți doar PC de încredere)' - ,bg: 'Запамети данните на този компютър. ( Използвай само на собствен компютър)' - ,hr: 'Pohrani hash na ovom računalu (Koristiti samo na osobnom računalu)' - ,sv: 'Lagra hashvärde på denna dator (använd endast på privat dator)' - ,it: 'Conservare hash su questo computer (utilizzare solo su computer privati)' - ,ja: 'このコンピューターにハッシュ値を保存する(このコンピューターでのみ使用する)' - ,dk: 'Gemme hash på denne computer (brug kun på privat computer)' - ,fi: 'Tallenna avain tälle tietokoneelle (käytä vain omalla tietokoneellasi)' - ,nb: 'Lagre hash på denne pc (bruk kun på privat pc)' - ,pl: 'Zapisz na tym komputerze (korzystaj tylko na komputerach prywatnych)' - ,ru: 'Сохранить хеш на этом ПК (только для личных компьютеров)' - ,sk: 'Uložiť hash na tomto počítači (Používajte iba na súkromných počítačoch)' - ,nl: 'Sla wachtwoord op. (gebruik dit alleen op prive computers)' - ,ko: '이 컴퓨터에 hash를 저장하세요.(단, 개인 컴퓨터를 사용하세요.)' - ,tr: 'Bu bilgisayarda hash parolasını sakla (Yalnızca özel bilgisayarlarda kullanın)' - ,zh_cn: '在本机存储API密钥\n(请勿在公用电脑上使用本功能)' - ,zh_tw: '在本機存儲API密鑰\n(請勿在公用電腦上使用本功能)' + ,'Your API secret or token' : { + fi: 'API salaisuus tai avain' + } + ,'Remember this device. (Do not enable this on public computers.)' : { + fi: 'Muista tämä laite (Älä valitse julkisilla tietokoneilla)' } ,'Treatments' : { cs: 'Ošetření' @@ -4871,135 +4900,6 @@ function init() { ,zh_cn: '静音2小时' ,zh_tw: '靜音2小時' } - ,'2HR' : { - cs: '2hod' - ,de: '2h' - ,es: '2h' - ,fr: '2hr' - ,el: '2 ώρες' - ,pt: '2h' - ,sv: '2tim' - ,ro: '2h' - ,bg: '2часа' - ,hr: '2h' - ,it: '2ORE' - ,ja: '2時間' - ,dk: '2t' - ,fi: '2h' - ,nb: '2t' - ,pl: '2h' - ,ru: '2ч' - ,sk: '2 hod' - ,nl: '2uur' - ,ko: '2시간' - ,tr: '2sa.' - ,zh_cn: '2小时' - ,zh_tw: '2小時' - } - ,'3HR' : { - cs: '3hod' - ,he: 'שלוש שעות' - ,de: '3h' - ,es: '3h' - ,fr: '3hr' - ,el: '3 ώρες' - ,pt: '3h' - ,sv: '3tim' - ,ro: '3h' - ,bg: '3часа' - ,hr: '3h' - ,it: '3ORE' - ,ja: '3時間' - ,dk: '3t' - ,fi: '3h' - ,nb: '3t' - ,pl: '3h' - ,ru: '3ч' - ,sk: '3 hod' - ,nl: '3uur' - ,ko: '3시간' - ,tr: '3sa.' - ,zh_cn: '3小时' - ,zh_tw: '3小時' - } - ,'6HR' : { - cs: '6hod' - ,he: 'שש שעות' - ,de: '6h' - ,es: '6h' - ,fr: '6hr' - ,el: '6 ώρες' - ,pt: '6h' - ,sv: '6tim' - ,ro: '6h' - ,bg: '6часа' - ,hr: '6h' - ,it: '6ORE' - ,ja: '6時間' - ,dk: '6t' - ,fi: '6h' - ,nb: '6t' - ,pl: '6h' - ,ru: '6ч' - ,sk: '6 hod' - ,nl: '6uur' - ,ko: '6시간' - ,tr: '6sa.' - ,zh_cn: '6小时' - ,zh_tw: '6小時' - } - ,'12HR' : { - cs: '12hod' - ,he: 'שתים עשרה שעות' - ,de: '12h' - ,es: '12h' - ,fr: '12hr' - ,el: '12 ώρες' - ,pt: '12h' - ,sv: '12t' - ,ro: '12h' - ,bg: '12часа' - ,hr: '12h' - ,it: '12ORE' - ,ja: '12時間' - ,dk: '12t' - ,fi: '12h' - ,nb: '12t' - ,pl: '12h' - ,ru: '12ч' - ,sk: '12 hod' - ,nl: '12uur' - ,ko: '12시간' - ,tr: '12sa.' - ,zh_cn: '12小时' - ,zh_tw: '12小時' - } - ,'24HR' : { - cs: '24hod' - ,he: 'עשרים וארבע שעות' - ,de: '24h' - ,es: '24h' - ,fr: '24hr' - ,el: '24 ώρες' - ,pt: '24h' - ,sv: '24tim' - ,ro: '24h' - ,bg: '24часа' - ,hr: '24h' - ,it: '24ORE' - ,ja: '24時間' - ,dk: '24t' - ,fi: '24h' - ,nb: '24t' - ,pl: '24h' - ,ru: '24ч' - ,sk: '24 hod' - ,nl: '24uur' - ,ko: '24시간' - ,tr: '24sa.' - ,zh_cn: '24小时' - ,zh_tw: '24小時' - } ,'Settings' : { cs: 'Nastavení' ,he: 'הגדרות' @@ -6778,6 +6678,7 @@ function init() { ,tr: 'Ağır' ,zh_cn: '重度' ,zh_tw: '嚴重' + ,he: 'כבד' } ,'Treatment type' : { cs: 'Typ ošetření' @@ -6802,6 +6703,7 @@ function init() { ,ko: 'Treatment 타입' ,tr: 'Tedavi tipi' ,zh_cn: '操作类型' + ,he: 'סוג הטיפול' } ,'Raw BG' : { cs: 'Glykémie z RAW dat' @@ -6849,6 +6751,7 @@ function init() { ,ko: '기기' ,tr: 'Cihaz' ,zh_cn: '设备' + ,he: 'התקן' } ,'Noise' : { cs: 'Šum' @@ -7698,6 +7601,7 @@ function init() { } ,'%1 records deleted' : { hr: 'obrisano %1 zapisa' + ,de: '%1 Einträge gelöscht' } ,'Clean Mongo status database' : { cs: 'Vyčištění Mongo databáze statusů' @@ -7869,52 +7773,68 @@ function init() { ,'Delete all documents from devicestatus collection older than 30 days' : { hr: 'Obriši sve statuse starije od 30 dana' ,ru: 'Удалить все записи коллекции devicestatus' + ,de: 'Alle Dokumente der Gerätestatus-Sammlung löschen, die älter als 30 Tage sind' } ,'Number of Days to Keep:' : { hr: 'Broj dana za sačuvati:' ,ru: 'Оставить дней' + ,de: 'Daten löschen, die älter sind (in Tagen) als:' } ,'This task removes all documents from devicestatus collection that are older than 30 days. Useful when uploader battery status is not properly updated.' : { hr: 'Ovo uklanja sve statuse starije od 30 dana. Korisno kada se status baterije uploadera ne osvježava ispravno.' ,ru: 'Это удалит все документы коллекции devicestatus которым более 30 дней. Полезно, когда статус батареи не обновляется или обновляется неверно.' + ,de: 'Diese Aufgabe entfernt alle Dokumente aus der Gerätestatus-Sammlung, die älter sind als 30 Tage. Nützlich wenn der Uploader-Batteriestatus sich nicht aktualisiert.' } ,'Delete old documents from devicestatus collection?' : { hr: 'Obriši stare statuse' + ,de: 'Alte Dokumente aus der Gerätestatus-Sammlung entfernen?' } ,'Clean Mongo entries (glucose entries) database' : { hr: 'Obriši GUK zapise iz baze' + ,de: 'Mongo-Einträge (Glukose-Einträge) Datenbank bereinigen' } ,'Delete all documents from entries collection older than 180 days' : { hr: 'Obriši sve zapise starije od 180 dana' + ,de: 'Alle Dokumente aus der Einträge-Sammlung löschen, die älter sind als 180 Tage' } ,'This task removes all documents from entries collection that are older than 180 days. Useful when uploader battery status is not properly updated.' : { hr: 'Ovo briše sve zapise starije od 180 dana. Korisno kada se status baterije uploadera ne osvježava.' + ,de: 'Diese Aufgabe entfernt alle Dokumente aus der Einträge-Sammlung, die älter sind als 180 Tage. Nützlich wenn der Uploader-Batteriestatus sich nicht aktualisiert.' } ,'Delete old documents' : { hr: 'Obriši stare zapise' + ,de: 'Alte Dokumente löschen' } ,'Delete old documents from entries collection?' : { hr: 'Obriši stare zapise?' + ,de: 'Alte Dokumente aus der Einträge-Sammlung entfernen?' } ,'%1 is not a valid number' : { hr: '%1 nije valjan broj' + ,de: '%1 ist keine gültige Zahl' + ,he: 'זה לא מיספר %1' } ,'%1 is not a valid number - must be more than 2' : { hr: '%1 nije valjan broj - mora biti veći od 2' + ,de: '%1 ist keine gültige Zahl - Eingabe muss größer als 2 sein' } ,'Clean Mongo treatments database' : { hr: 'Obriši tretmane iz baze' + ,de: 'Mongo-Behandlungsdatenbank bereinigen' } ,'Delete all documents from treatments collection older than 180 days' : { hr: 'Obriši tretmane starije od 180 dana iz baze' + ,de: 'Alle Dokumente aus der Behandlungs-Sammlung löschen, die älter sind als 180 Tage' } ,'This task removes all documents from treatments collection that are older than 180 days. Useful when uploader battery status is not properly updated.' : { hr: 'Ovo briše sve tretmane starije od 180 dana iz baze. Korisno kada se status baterije uploadera ne osvježava.' + ,de: 'Diese Aufgabe entfernt alle Dokumente aus der Behandlungs-Sammlung, die älter sind als 180 Tage. Nützlich wenn der Uploader-Batteriestatus sich nicht aktualisiert.' } ,'Delete old documents from treatments collection?' : { hr: 'Obriši stare tretmane?' ,ru: 'Удалить старые документы из коллекции лечения?' + ,de: 'Alte Dokumente aus der Behandlungs-Sammlung entfernen?' } ,'Admin Tools' : { cs: 'Nástroje pro správu' @@ -8530,7 +8450,7 @@ function init() { ,hr: 'Pohranjeni profili' ,pl: 'Zachowane profile' ,pt: 'Perfis guardados' - ,ru: 'Запомненные профили' + ,ru: 'Сохраненные профили' ,sk: 'Uložené profily' ,nl: 'Opgeslagen profielen' ,ko: '저장된 프로파일' @@ -8634,6 +8554,30 @@ function init() { ,tr: 'İnsülin/Karbonhidrat oranı (I:C)' ,zh_cn: '碳水化合物系数(ICR)' } + ,'Hours:' : { + cs: 'Hodin:' + ,he: 'שעות:' + ,ro: 'Ore:' + ,el: 'ώρες:' + ,fr: 'Heures:' + ,de: 'Stunden:' + ,es: 'Horas:' + ,dk: 'Timer:' + ,sv: 'Timmar:' + ,nb: 'Timer:' + ,fi: 'Tunnit:' + ,bg: 'часове:' + ,hr: 'Sati:' + ,pl: 'Godziny:' + ,pt: 'Horas:' + ,ru: 'час:' + ,sk: 'Hodiny:' + ,nl: 'Uren:' + ,ko: '시간:' + ,it: 'Ore:' + ,tr: 'Saat:' + ,zh_cn: '小时:' + } ,'hours' : { cs: 'hodin' ,he: 'שעות ' @@ -8914,7 +8858,7 @@ function init() { ,hr: 'Iscrtaj bazale' ,pl: 'Zmiana dawki bazowej' ,pt: 'Renderizar basal' - ,ru: 'показывать базал' + ,ru: 'отрисовать базал' ,sk: 'Zobrazenie bazálu' ,nl: 'Toon basaal' ,ko: 'Basal 사용하기' @@ -8962,7 +8906,7 @@ function init() { ,hr: 'Izračun je u ciljanom rasponu.' ,pl: 'Obliczenie mieści się w zakresie docelowym' ,pt: 'O cálculo está dentro da meta' - ,ru: 'Расчет в целевых пределах ' + ,ru: 'Расчет в целевом диапазоне ' ,sk: 'Výpočet je v cieľovom rozsahu.' ,nl: 'Berekening valt binnen doelwaards' ,ko: '계산은 목표 범위 안에 있습니다.' @@ -9106,7 +9050,7 @@ function init() { ,hr: 'Vrijedi od:' ,pl: 'Ważne od:' ,pt: 'Válido desde:' - ,ru: 'Действует с' + ,ru: 'Действительно с' ,sk: 'Platné od:' ,nl: 'Geldig van:' ,ko: '유효' @@ -9344,7 +9288,7 @@ function init() { ,bg: 'Когато е активно ,иконката за редактиране ще се вижда' ,hr: 'Kada je omogućeno, mod uređivanje je omogućen' ,fi: 'Muokkausmoodin ikoni tulee näkyviin kun laitat tämän päälle' - ,ru: 'При активации видна икона начать режим редактирования' + ,ru: 'При активации видна пиктограмма начать режим редактирования' ,sk: 'Keď je povolený, je zobrazená ikona editačného módu' ,pl: 'Po aktywacji, widoczne ikony, aby uruchomić tryb edycji' ,pt: 'Quando ativado, o ícone iniciar modo de edição estará visível' @@ -9538,7 +9482,7 @@ function init() { ,bg: 'Да променя ли времето на събитието с %1?' ,hr: 'Promijeni vrijeme tretmana na %1?' ,fi: 'Muuta hoidon aika? Uusi: %1' - ,ru: 'Изменить время события на %1?' + ,ru: 'Изменить время события на %1 ?' ,sk: 'Zmeniť čas ošetrenia na %1 ?' ,pl: 'Zmień czas zdarzenia na %1 ?' ,pt: 'Alterar horário do tratamento para %1 ?' @@ -9562,7 +9506,7 @@ function init() { ,bg: 'Да променя ли времето на ВХ с %1?' ,hr: 'Promijeni vrijeme UGH na %1?' ,fi: 'Muuta hiilihydraattien aika? Uusi: %1' - ,ru: 'Изменить время подачи углеводов на %?' + ,ru: 'Изменить время подачи углеводов на % ?' ,sk: 'Zmeniť čas sacharidov na %1 ?' ,pl: 'Zmień czas węglowodanów na %1 ?' ,pt: 'Alterar horário do carboidrato para %1 ?' @@ -9586,7 +9530,7 @@ function init() { ,bg: 'Да променя ли времето на инсулина с %1?' ,hr: 'Promijeni vrijeme inzulina na %1?' ,fi: 'Muuta insuliinin aika? Uusi: %1' - ,ru: 'Изменить время подачи инсулина на %?' + ,ru: 'Изменить время подачи инсулина на % ?' ,sk: 'Zmeniť čas inzulínu na %1 ?' ,pl: 'Zmień czas insuliny na %1 ?' ,pt: 'Alterar horário da insulina para %1 ?' @@ -9610,7 +9554,7 @@ function init() { ,bg: 'Изтрий събитието' ,hr: 'Obriši tretman?' ,fi: 'Poista hoito?' - ,ru: 'Удалить событие?' + ,ru: 'Удалить событие ?' ,sk: 'Odstrániť ošetrenie?' ,pl: 'Usunąć wydarzenie?' ,pt: 'Remover tratamento?' @@ -9634,7 +9578,7 @@ function init() { ,bg: 'Да изтрия ли инсулина от събитието?' ,hr: 'Obriši inzulin iz tretmana?' ,fi: 'Poista insuliini hoidosta?' - ,ru: 'Удалить инсулин из событий?' + ,ru: 'Удалить инсулин из событий ?' ,sk: 'Odstrániť inzulín z ošetrenia?' ,pl: 'Usunąć insulinę z wydarzenia?' ,pt: 'Remover insulina do tratamento?' @@ -9658,7 +9602,7 @@ function init() { ,bg: 'Да изтрия ли ВХ от събитието?' ,hr: 'Obriši UGH iz tretmana?' ,fi: 'Poista hiilihydraatit hoidosta?' - ,ru: 'Удалить углеводы из событий?' + ,ru: 'Удалить углеводы из событий ?' ,sk: 'Odstrániť sacharidy z ošetrenia?' ,pl: 'Usunąć węglowodany z wydarzenia?' ,pt: 'Remover carboidratos do tratamento?' @@ -9802,7 +9746,7 @@ function init() { ,bg: 'Възраст на сензора (ВС)' ,hr: 'Starost senzora' ,ro: 'Vechimea senzorului' - ,ru: 'Сенсор проработал' + ,ru: 'Сенсор отработал' ,nl: 'Sensor leeftijd' ,ko: '센서 사용 기간' ,fi: 'Sensorin ikä' @@ -9827,7 +9771,7 @@ function init() { ,bg: 'Възраст на инсулина (ВИ)' ,hr: 'Starost inzulina' ,ro: 'Vechimea insulinei' - ,ru: 'инсулин проработал' + ,ru: 'инсулин отработал' ,ko: '인슐린 사용 기간' ,fi: 'Insuliinin ikä' ,pt: 'Idade da insulina' @@ -9887,7 +9831,7 @@ function init() { } ,'Eating soon' : { cs: 'Následuje jídlo' - ,he: 'אוכל בקרוב ' + ,he: 'אוכל בקרוב' ,sk: 'Jesť čoskoro' ,fr: 'Repas sous peu' ,sv: 'Snart matdags' @@ -9898,7 +9842,7 @@ function init() { ,bg: 'Ядене скоро' ,hr: 'Uskoro jelo' ,ro: 'Mâncare în curând' - ,ru: 'Скоро еда' + ,ru: 'Приближается прием пищи' ,nl: 'Binnenkort eten' ,ko: '편집 중' ,fi: 'Syödään pian' @@ -10012,7 +9956,7 @@ function init() { ,ro: 'Insulină bolusată:' ,el: 'Ινσουλίνη' ,es: 'Bolo de Insulina' - ,ru: 'Болюс инсулин' + ,ru: 'Болюсный инсулин' ,sv: 'Bolusinsulin:' ,nb: 'Bolusinsulin:' ,hr: 'Bolus:' @@ -10036,7 +9980,7 @@ function init() { ,ro: 'Bazala obișnuită:' ,el: 'Βασική Ινσουλίνη' ,es: 'Insulina basal básica' - ,ru: 'Основной базал инсулин' + ,ru: 'Основной базальный инсулин' ,sv: 'Basalinsulin:' ,nb: 'Basalinsulin:' ,hr: 'Osnovni bazal:' @@ -10156,7 +10100,7 @@ function init() { ,fr: 'Incapable de %1 rôle' ,ro: 'Imposibil de %1 Rolul' ,es: 'Incapaz de %1 Rol' - ,ru: 'Невозможно %1 Роль' + ,ru: 'Невозможно назначить %1 Роль' ,sv: 'Kan inte ta bort roll %1' ,nb: 'Kan ikke %1 rolle' ,fi: '%1 operaatio roolille opäonnistui' @@ -10293,7 +10237,7 @@ function init() { ,hr: 'Sigurno želite obrisati?' ,ro: 'Confirmați ștergerea: ' ,fr: 'Êtes-vous sûr de vouloir effacer:' - ,ru: 'Вы уверены, что хотите удалить' + ,ru: 'Подтвердите удаление' ,sv: 'Är du säker att du vill ta bort:' ,nb: 'Er du sikker på at du vil slette:' ,fi: 'Oletko varmat että haluat tuhota: ' @@ -10412,7 +10356,7 @@ function init() { ,sv: 'Administratorgodkänt' ,nb: 'Administratorgodkjent' ,fi: 'Ylläpitäjä valtuutettu' - ,de: 'Admin Authorisierung' + ,de: 'als Administrator autorisiert' ,dk: 'Administrator godkendt' ,pt: 'Administrador autorizado' ,sk: 'Admin autorizovaný' @@ -10500,7 +10444,7 @@ function init() { ,hr: 'Ne mogu %1 subjekt' ,ro: 'Imposibil de %1 Subiectul' ,fr: 'Impossible de créer l\'Utilisateur %1' - ,ru: 'Невозможно %1 субъекта' + ,ru: 'Невозможно создать %1 субъект' ,sv: 'Kan ej %1 ämne' ,nb: 'Kan ikke %1 ressurs' ,fi: '%1 operaatio käyttäjälle epäonnistui' @@ -10523,7 +10467,7 @@ function init() { ,hr: 'Ne mogu obrisati subjekt' ,ro: 'Imposibil de șters Subiectul' ,fr: 'Impossible d\'effacer l\'Utilisateur' - ,ru: 'Невозможно удалить ' + ,ru: 'Невозможно удалить Субъект ' ,sv: 'Kan ej ta bort ämne' ,nb: 'Kan ikke slette ressurs' ,fi: 'Käyttäjän poistaminen epäonnistui' @@ -10570,7 +10514,7 @@ function init() { ,ro: 'Editează Subiectul' ,es: 'Editar sujeto' ,fr: 'Éditer l\'Utilisateur' - ,ru: 'Редактировать субъекта' + ,ru: 'Редактировать Субъект' ,sv: 'Editera ämne' ,nb: 'Editer ressurs' ,fi: 'Muokkaa käyttäjää' @@ -10638,7 +10582,7 @@ function init() { ,hr: 'Uredi ovaj subjekt' ,ro: 'Editează acest subiect' ,fr: 'Éditer cet utilisateur' - ,ru: 'Редактировать этого субъекта' + ,ru: 'Редактировать этот субъект' ,sv: 'Editera ämnet' ,es: 'Editar este sujeto' ,nb: 'Editer ressurs' @@ -10661,7 +10605,7 @@ function init() { ,hr: 'Obriši ovaj subjekt' ,ro: 'Șterge acest subiect' ,fr: 'Effacer cet utilisateur:' - ,ru: 'Удалить этого субъекта' + ,ru: 'Удалить этот субъект' ,sv: 'Ta bort ämnet' ,nb: 'Slett ressurs' ,fi: 'Poista tämä käyttäjä' @@ -10870,7 +10814,7 @@ function init() { ,dk: 'Insulinfølsomhed (ISF)' ,ro: 'Sensibilitate la insulină (ISF)' ,fr: 'Sensibilité à l\'insuline (ISF)' - ,ru: 'Чуствительность к инсулину' + ,ru: 'Чуствительность к инсулину ISF' ,sk: 'Citlivosť (ISF)' ,sv: 'Insulinkönslighet (ISF)' ,nb: 'Insulinsensitivitet (ISF)' @@ -10893,7 +10837,7 @@ function init() { ,dk: 'Nuværende kulhydratratio' ,fr: 'Rapport Insuline-glucides actuel (I:C)' ,ro: 'Raport Insulină:Carbohidrați (ICR)' - ,ru: 'Актуальное соотношение инсулин:углеводы' + ,ru: 'Актуальное соотношение инсулин:углеводы I:C' ,sk: 'Aktuálny sacharidový pomer (I"C)' ,sv: 'Gällande kolhydratkvot' ,nb: 'Gjeldende karbohydratforhold' @@ -11053,7 +10997,7 @@ function init() { ,de: 'Basal-Profil Wert' ,dk: 'Basalprofil værdi' ,ro: 'Valoarea profilului bazalei' - ,ru: 'значение профиля базала' + ,ru: 'Величина профильного базала' ,fr: 'Valeur du débit basal' ,sv: 'Basalprofil värde' ,es: 'Valor perfil Basal' @@ -11264,7 +11208,7 @@ function init() { ,de: 'BWP' ,dk: 'Bolusberegner (BWP)' ,ro: 'Ajutor bolusare (BWP)' - ,ru: 'Предпросмотр калькулятора болюса' + ,ru: 'Калькулятор болюса' ,fr: 'Calculateur de bolus (BWP)' ,sv: 'Boluskalkylator' ,es: 'VistaPreviaCalculoBolo (BWP)' @@ -11411,7 +11355,7 @@ function init() { ,fr: 'Vérifier la glycémie, bolus nécessaire ?' ,bg: 'Провери КЗ, не е ли време за болус?' ,hr: 'Provjeri GUK, vrijeme je za bolus?' - ,ru: 'Проверьте СК, не пора ли ввести болюс?' + ,ru: 'Проверьте СК, дать болюс?' ,sv: 'Kontrollera BS, dags att ge bolus?' ,nb: 'Sjekk blodsukker, på tide med bolus?' ,fi: 'Tarkista VS, aika bolustaa?' @@ -11596,7 +11540,7 @@ function init() { ,fr: 'Insuline en excès: %1U de plus que nécessaire pour atteindre la cible inférieure, sans prendre en compte les glucides' ,bg: 'Излишният инсулин %1U е повече от необходимия за достигане до долната граница, ВХ не се вземат под внимание' ,hr: 'Višak inzulina je %1U više nego li je potrebno da se postigne donja ciljana granica, ne uzevši u obzir UGH' - ,ru: 'Избыток инсулина равного %1U, необходимого для достижения нижнего целевого значения, углеводы не учитываются' + ,ru: 'Избыток инсулина равного %1U, необходимого для достижения нижнего целевого значения, углеводы не приняты в расчет' ,sv: 'Överskott av insulin motsvarande %1U mer än nödvändigt för att nå lågt målvärde, kolhydrater ej medräknade' ,nb: 'Insulin tilsvarende %1U mer enn det trengs for å nå lavt mål, karbohydrater ikke medregnet' ,nl: 'Insulineoverschot van %1U om laag doel te behalen (excl. koolhydraten)' @@ -11708,7 +11652,7 @@ function init() { ,dk: 'over højt grænse' ,ro: 'peste ținta superioară' ,fr: 'plus haut que la limite supérieure' - ,ru: 'Выше верхнего' + ,ru: 'Выше верхней границы' ,bg: 'над горната' ,hr: 'iznad gornje granice' ,sv: 'över hög nivå' @@ -11734,7 +11678,7 @@ function init() { ,fr: 'plus bas que la limite inférieure' ,bg: 'под долната' ,hr: 'ispod donje granice' - ,ru: 'Ниже нижнего' + ,ru: 'Ниже нижней границы' ,sv: 'under låg nivå' ,nb: 'under lav grense' ,fi: 'alle matalan' @@ -11850,7 +11794,7 @@ function init() { ,fr: 'Vérifier la glycémie avec un glucomètre avant de corriger!' ,bg: 'Провери КЗ с глюкомер, преди кореция!' ,hr: 'Provjeri GUK glukometrom prije korekcije!' - ,ru: 'Перед корректировкой сверьте СК с глюкометром' + ,ru: 'Перед корректировкой сверьте ГК с глюкометром' ,sv: 'Kontrollera blodglukos med fingerstick före korrigering!' ,nb: 'Sjekk blodsukker før korrigering!' ,fi: 'Tarkista VS mittarilla ennen korjaamista!' @@ -11873,7 +11817,7 @@ function init() { ,fr: 'Réduction du débit basal pour obtenir l\'effet d\' %1 unité' ,bg: 'Намаляне на базала с %1 единици' ,hr: 'Smanjeni bazal da uračuna %1 jedinica:' - ,ru: 'Снижение базы на %1 единиц' + ,ru: 'Снижение базы из-за %1 единиц болюса' ,sv: 'Basalsänkning för att nå %1 enheter' ,nb: 'Basalredusering for å nå %1 enheter' ,fi: 'Basaalin vähennys saadaksesi %1 yksikön vaikutuksen:' @@ -11942,7 +11886,7 @@ function init() { ,bg: 'Времето за смяна на сет просрочено' ,hr: 'Prošao rok za zamjenu kanile!' ,fr: 'Dépassement de date de changement de canule!' - ,ru: 'Срок работы катеттера истек' + ,ru: 'Срок замены катетера истек' ,sv: 'Infusionsset, bytestid överskriden' ,nb: 'Byttetid for infusjonssett overskredet' ,fi: 'Kanyylin ikä yli määräajan!' @@ -11965,7 +11909,7 @@ function init() { ,ro: 'Este vremea să schimbați canula' ,bg: 'Време за смяна на сет' ,hr: 'Vrijeme za zamjenu kanile' - ,ru: 'Пора заменить катеттер' + ,ru: 'Пора заменить катетер' ,sv: 'Dags att byta infusionsset' ,nb: 'På tide å bytte infusjonssett' ,fi: 'Aika vaihtaa kanyyli' @@ -11988,7 +11932,7 @@ function init() { ,fr: 'Changement de canule bientòt' ,bg: 'Смени сета скоро' ,hr: 'Zamijena kanile uskoro' - ,ru: 'Подходит время замены катеттера' + ,ru: 'Приближается время замены катетера' ,sv: 'Byt infusionsset snart' ,nb: 'Bytt infusjonssett snart' ,fi: 'Vaihda kanyyli pian' @@ -12010,7 +11954,7 @@ function init() { ,ro: 'Vechimea canulei în ore: %1' ,bg: 'Сетът е на %1 часове' ,hr: 'Staros kanile %1 sati' - ,ru: 'Возраст катеттера %1 час' + ,ru: 'Катетер отработал %1 час' ,sv: 'Infusionsset tid %1 timmar' ,nb: 'infusjonssett alder %1 timer' ,fi: 'Kanyylin ikä %1 tuntia' @@ -12033,7 +11977,7 @@ function init() { ,fr: 'Insérée' ,bg: 'Поставен' ,hr: 'Postavljanje' - ,ru: 'Введено' + ,ru: 'Установлен' ,sv: 'Applicerad' ,nb: 'Satt inn' ,fi: 'Asetettu' @@ -12058,7 +12002,7 @@ function init() { ,bg: 'ВС' ,hr: 'Starost kanile' ,fr: 'CAGE' - ,ru: 'ВозрКат' + ,ru: 'ОтрабКат' ,sv: 'Nål' ,nb: 'Nål alder' ,fi: 'KIKÄ' @@ -12082,7 +12026,7 @@ function init() { ,bg: 'АВХ' ,hr: 'Aktivni UGH' ,fr: 'COB' - ,ru: 'Активн углеводы' + ,ru: 'Активн углеводы COB' ,sv: 'COB' ,nb: 'Aktive karbohydrater' ,fi: 'AH' @@ -12126,9 +12070,9 @@ function init() { ,dk: 'Insulinalder' ,ro: 'VI' ,fr: 'IAGE' - ,bg: 'ВИнс' + ,bg: 'ИнсСрок' ,hr: 'Starost inzulina' - ,ru: 'ВозрИнс' + ,ru: 'ИнсСрок' ,sv: 'Insulinålder' ,nb: 'Insulinalder' ,fi: 'IIKÄ' @@ -12494,7 +12438,7 @@ function init() { ,de: 'RETRO' ,dk: 'RETRO' ,ro: 'VECHI' - ,ru: 'РЕТРО' + ,ru: 'ПРОШЛОЕ' ,bg: 'РЕТРО' ,hr: 'RETRO' ,fr: 'RETRO' @@ -12542,7 +12486,7 @@ function init() { ,dk: 'Sensor skift/genstart overskredet!' ,ro: 'Depășire termen schimbare/restart senzor!' ,fr: 'Changement/Redémarrage du senseur dépassé!' - ,ru: 'Рестарт сенсора просрочен' + ,ru: 'Рестарт сенсора пропущен' ,bg: 'Смяната/рестартът на сензора са пресрочени' ,hr: 'Prošao rok za zamjenu/restart senzora!' ,sv: 'Sensor byte/omstart överskriden!' @@ -12611,7 +12555,7 @@ function init() { ,dk: 'Sensoralder %1 dage %2 timer' ,ro: 'Senzori vechi de %1 zile și %2 ore' ,fr: 'Âge su senseur %1 jours et %2 heures' - ,ru: 'Сенсор проработал % дн % час' + ,ru: 'Сенсор отработал % дн % час' ,bg: 'Сензорът е на %1 дни %2 часа ' ,hr: 'Starost senzora %1 dana i %2 sati' ,sv: 'Sensorålder %1 dagar %2 timmar' @@ -12656,7 +12600,7 @@ function init() { ,de: 'Sensorstart' ,dk: 'Sensor start' ,ro: 'Pornirea senzorului' - ,ru: 'Запуск сенсора' + ,ru: 'Старт сенсора' ,fr: 'Démarrage du senseur' ,bg: 'Стартиране на сензора' ,hr: 'Pokretanje senzora' @@ -13021,7 +12965,7 @@ function init() { ,es: '">here.' ,fr: '">ici.' ,ro: '">aici.' - ,ru: '">here.' + ,ru: '">здесь.' ,nl: '">is hier te vinden.' ,zh_cn: '">here.' ,sv: '">här.' @@ -13255,7 +13199,371 @@ function init() { , zh_cn: '快速上升' , zh_tw: 'rapidly rising' }, - 'alexaStatus': { + 'virtAsstUnknown': { + bg: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , cs: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , de: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , dk: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , el: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , en: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , es: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , fi: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , fr: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , he: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , hr: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , it: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , ko: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , nb: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , pl: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , pt: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , ro: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , nl: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , ru: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , sk: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , sv: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , tr: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , zh_cn: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + , zh_tw: 'That value is unknown at the moment. Please see your Nightscout site for more details.' + }, + 'virtAsstTitleAR2Forecast': { + bg: 'AR2 Forecast' + , cs: 'AR2 Forecast' + , de: 'AR2 Forecast' + , dk: 'AR2 Forecast' + , el: 'AR2 Forecast' + , en: 'AR2 Forecast' + , es: 'AR2 Forecast' + , fi: 'AR2 Forecast' + , fr: 'AR2 Forecast' + , he: 'AR2 Forecast' + , hr: 'AR2 Forecast' + , it: 'AR2 Forecast' + , ko: 'AR2 Forecast' + , nb: 'AR2 Forecast' + , pl: 'AR2 Forecast' + , pt: 'AR2 Forecast' + , ro: 'AR2 Forecast' + , nl: 'AR2 Forecast' + , ru: 'AR2 Forecast' + , sk: 'AR2 Forecast' + , sv: 'AR2 Forecast' + , tr: 'AR2 Forecast' + , zh_cn: 'AR2 Forecast' + , zh_tw: 'AR2 Forecast' + }, + 'virtAsstTitleCurrentBasal': { + bg: 'Current Basal' + , cs: 'Current Basal' + , de: 'Current Basal' + , dk: 'Current Basal' + , el: 'Current Basal' + , en: 'Current Basal' + , es: 'Current Basal' + , fi: 'Current Basal' + , fr: 'Current Basal' + , he: 'Current Basal' + , hr: 'Current Basal' + , it: 'Current Basal' + , ko: 'Current Basal' + , nb: 'Current Basal' + , pl: 'Current Basal' + , pt: 'Current Basal' + , ro: 'Current Basal' + , nl: 'Current Basal' + , ru: 'Current Basal' + , sk: 'Current Basal' + , sv: 'Current Basal' + , tr: 'Current Basal' + , zh_cn: 'Current Basal' + , zh_tw: 'Current Basal' + }, + 'virtAsstTitleCurrentCOB': { + bg: 'Current COB' + , cs: 'Current COB' + , de: 'Current COB' + , dk: 'Current COB' + , el: 'Current COB' + , en: 'Current COB' + , es: 'Current COB' + , fi: 'Current COB' + , fr: 'Current COB' + , he: 'Current COB' + , hr: 'Current COB' + , it: 'Current COB' + , ko: 'Current COB' + , nb: 'Current COB' + , pl: 'Current COB' + , pt: 'Current COB' + , ro: 'Current COB' + , nl: 'Current COB' + , ru: 'Current COB' + , sk: 'Current COB' + , sv: 'Current COB' + , tr: 'Current COB' + , zh_cn: 'Current COB' + , zh_tw: 'Current COB' + }, + 'virtAsstTitleCurrentIOB': { + bg: 'Current IOB' + , cs: 'Current IOB' + , de: 'Current IOB' + , dk: 'Current IOB' + , el: 'Current IOB' + , en: 'Current IOB' + , es: 'Current IOB' + , fi: 'Current IOB' + , fr: 'Current IOB' + , he: 'Current IOB' + , hr: 'Current IOB' + , it: 'Current IOB' + , ko: 'Current IOB' + , nb: 'Current IOB' + , pl: 'Current IOB' + , pt: 'Current IOB' + , ro: 'Current IOB' + , nl: 'Current IOB' + , ru: 'Current IOB' + , sk: 'Current IOB' + , sv: 'Current IOB' + , tr: 'Current IOB' + , zh_cn: 'Current IOB' + , zh_tw: 'Current IOB' + }, + 'virtAsstTitleLoopForecast': { + bg: 'Loop Forecast' + , cs: 'Loop Forecast' + , de: 'Loop Forecast' + , dk: 'Loop Forecast' + , el: 'Loop Forecast' + , en: 'Loop Forecast' + , es: 'Loop Forecast' + , fi: 'Loop Forecast' + , fr: 'Loop Forecast' + , he: 'Loop Forecast' + , hr: 'Loop Forecast' + , it: 'Loop Forecast' + , ko: 'Loop Forecast' + , nb: 'Loop Forecast' + , pl: 'Loop Forecast' + , pt: 'Loop Forecast' + , ro: 'Loop Forecast' + , nl: 'Loop Forecast' + , ru: 'Loop Forecast' + , sk: 'Loop Forecast' + , sv: 'Loop Forecast' + , tr: 'Loop Forecast' + , zh_cn: 'Loop Forecast' + , zh_tw: 'Loop Forecast' + }, + 'virtAsstTitleLastLoop': { + bg: 'Last Loop' + , cs: 'Last Loop' + , de: 'Last Loop' + , dk: 'Last Loop' + , el: 'Last Loop' + , en: 'Last Loop' + , es: 'Last Loop' + , fi: 'Last Loop' + , fr: 'Last Loop' + , he: 'Last Loop' + , hr: 'Last Loop' + , it: 'Last Loop' + , ko: 'Last Loop' + , nb: 'Last Loop' + , pl: 'Last Loop' + , pt: 'Last Loop' + , ro: 'Last Loop' + , nl: 'Last Loop' + , ru: 'Last Loop' + , sk: 'Last Loop' + , sv: 'Last Loop' + , tr: 'Last Loop' + , zh_cn: 'Last Loop' + , zh_tw: 'Last Loop' + }, + 'virtAsstTitleOpenAPSForecast': { + bg: 'OpenAPS Forecast' + , cs: 'OpenAPS Forecast' + , de: 'OpenAPS Forecast' + , dk: 'OpenAPS Forecast' + , el: 'OpenAPS Forecast' + , en: 'OpenAPS Forecast' + , es: 'OpenAPS Forecast' + , fi: 'OpenAPS Forecast' + , fr: 'OpenAPS Forecast' + , he: 'OpenAPS Forecast' + , hr: 'OpenAPS Forecast' + , it: 'OpenAPS Forecast' + , ko: 'OpenAPS Forecast' + , nb: 'OpenAPS Forecast' + , pl: 'OpenAPS Forecast' + , pt: 'OpenAPS Forecast' + , ro: 'OpenAPS Forecast' + , nl: 'OpenAPS Forecast' + , ru: 'OpenAPS Forecast' + , sk: 'OpenAPS Forecast' + , sv: 'OpenAPS Forecast' + , tr: 'OpenAPS Forecast' + , zh_cn: 'OpenAPS Forecast' + , zh_tw: 'OpenAPS Forecast' + }, + 'virtAsstTitlePumpReservoir': { + bg: 'Insulin Remaining' + , cs: 'Insulin Remaining' + , de: 'Insulin Remaining' + , dk: 'Insulin Remaining' + , el: 'Insulin Remaining' + , en: 'Insulin Remaining' + , es: 'Insulin Remaining' + , fi: 'Insulin Remaining' + , fr: 'Insulin Remaining' + , he: 'Insulin Remaining' + , hr: 'Insulin Remaining' + , it: 'Insulin Remaining' + , ko: 'Insulin Remaining' + , nb: 'Insulin Remaining' + , pl: 'Insulin Remaining' + , pt: 'Insulin Remaining' + , ro: 'Insulin Remaining' + , nl: 'Insulin Remaining' + , ru: 'Insulin Remaining' + , sk: 'Insulin Remaining' + , sv: 'Insulin Remaining' + , tr: 'Insulin Remaining' + , zh_cn: 'Insulin Remaining' + , zh_tw: 'Insulin Remaining' + }, + 'virtAsstTitlePumpBattery': { + bg: 'Pump Battery' + , cs: 'Pump Battery' + , de: 'Pump Battery' + , dk: 'Pump Battery' + , el: 'Pump Battery' + , en: 'Pump Battery' + , es: 'Pump Battery' + , fi: 'Pump Battery' + , fr: 'Pump Battery' + , he: 'Pump Battery' + , hr: 'Pump Battery' + , it: 'Pump Battery' + , ko: 'Pump Battery' + , nb: 'Pump Battery' + , pl: 'Pump Battery' + , pt: 'Pump Battery' + , ro: 'Pump Battery' + , nl: 'Pump Battery' + , ru: 'Pump Battery' + , sk: 'Pump Battery' + , sv: 'Pump Battery' + , tr: 'Pump Battery' + , zh_cn: 'Pump Battery' + , zh_tw: 'Pump Battery' + }, + 'virtAsstTitleRawBG': { + bg: 'Current Raw BG' + , cs: 'Current Raw BG' + , de: 'Current Raw BG' + , dk: 'Current Raw BG' + , el: 'Current Raw BG' + , en: 'Current Raw BG' + , es: 'Current Raw BG' + , fi: 'Current Raw BG' + , fr: 'Current Raw BG' + , he: 'Current Raw BG' + , hr: 'Current Raw BG' + , it: 'Current Raw BG' + , ko: 'Current Raw BG' + , nb: 'Current Raw BG' + , pl: 'Current Raw BG' + , pt: 'Current Raw BG' + , ro: 'Current Raw BG' + , nl: 'Current Raw BG' + , ru: 'Current Raw BG' + , sk: 'Current Raw BG' + , sv: 'Current Raw BG' + , tr: 'Current Raw BG' + , zh_cn: 'Current Raw BG' + , zh_tw: 'Current Raw BG' + }, + 'virtAsstTitleUploaderBattery': { + bg: 'Uploader Battery' + , cs: 'Uploader Battery' + , de: 'Uploader Battery' + , dk: 'Uploader Battery' + , el: 'Uploader Battery' + , en: 'Uploader Battery' + , es: 'Uploader Battery' + , fi: 'Uploader Battery' + , fr: 'Uploader Battery' + , he: 'Uploader Battery' + , hr: 'Uploader Battery' + , it: 'Uploader Battery' + , ko: 'Uploader Battery' + , nb: 'Uploader Battery' + , pl: 'Uploader Battery' + , pt: 'Uploader Battery' + , ro: 'Uploader Battery' + , nl: 'Uploader Battery' + , ru: 'Uploader Battery' + , sk: 'Uploader Battery' + , sv: 'Uploader Battery' + , tr: 'Uploader Battery' + , zh_cn: 'Uploader Battery' + , zh_tw: 'Uploader Battery' + }, + 'virtAsstTitleCurrentBG': { + bg: 'Current BG' + , cs: 'Current BG' + , de: 'Current BG' + , dk: 'Current BG' + , el: 'Current BG' + , en: 'Current BG' + , es: 'Current BG' + , fi: 'Current BG' + , fr: 'Current BG' + , he: 'Current BG' + , hr: 'Current BG' + , it: 'Current BG' + , ko: 'Current BG' + , nb: 'Current BG' + , pl: 'Current BG' + , pt: 'Current BG' + , ro: 'Current BG' + , nl: 'Current BG' + , ru: 'Current BG' + , sk: 'Current BG' + , sv: 'Current BG' + , tr: 'Current BG' + , zh_cn: 'Current BG' + , zh_tw: 'Current BG' + }, + 'virtAsstTitleFullStatus': { + bg: 'Full Status' + , cs: 'Full Status' + , de: 'Full Status' + , dk: 'Full Status' + , el: 'Full Status' + , en: 'Full Status' + , es: 'Full Status' + , fi: 'Full Status' + , fr: 'Full Status' + , he: 'Full Status' + , hr: 'Full Status' + , it: 'Full Status' + , ko: 'Full Status' + , nb: 'Full Status' + , pl: 'Full Status' + , pt: 'Full Status' + , ro: 'Full Status' + , nl: 'Full Status' + , ru: 'Full Status' + , sk: 'Full Status' + , sv: 'Full Status' + , tr: 'Full Status' + , zh_cn: 'Full Status' + , zh_tw: 'Full Status' + }, + 'virtAsstStatus': { bg: '%1 and %2 as of %3.' , cs: '%1 %2 čas %3.' , de: '%1 und bis %3 %2.' @@ -13281,7 +13589,7 @@ function init() { , zh_cn: '%1 和 %2 到 %3.' , zh_tw: '%1 and %2 as of %3.' }, - 'alexaBasal': { + 'virtAsstBasal': { bg: '%1 současný bazál je %2 jednotek za hodinu' , cs: '%1 current basal is %2 units per hour' , de: '%1 aktuelle Basalrate ist %2 Einheiten je Stunde' @@ -13307,7 +13615,7 @@ function init() { , zh_cn: '%1 当前基础率是 %2 U/小时' , zh_tw: '%1 current basal is %2 units per hour' }, - 'alexaBasalTemp': { + 'virtAsstBasalTemp': { bg: '%1 dočasný bazál %2 jednotek za hodinu skončí %3' , cs: '%1 temp basal of %2 units per hour will end %3' , de: '%1 temporäre Basalrate von %2 Einheiten endet %3' @@ -13333,7 +13641,7 @@ function init() { , zh_cn: '%1 临时基础率 %2 U/小时将会在 %3结束' , zh_tw: '%1 temp basal of %2 units per hour will end %3' }, - 'alexaIob': { + 'virtAsstIob': { bg: 'a máte %1 jednotek aktivního inzulínu.' , cs: 'and you have %1 insulin on board.' , de: 'und du hast %1 Insulin wirkend.' @@ -13359,33 +13667,33 @@ function init() { , zh_cn: '并且你有 %1 的活性胰岛素.' , zh_tw: 'and you have %1 insulin on board.' }, - 'alexaIobIntent': { - bg: 'Máte %1 jednotek aktivního inzulínu' - , cs: 'You have %1 insulin on board' - , de: 'Du hast noch %1 Insulin wirkend' - , dk: 'Du har %1 insulin i kroppen' - , el: 'You have %1 insulin on board' - , en: 'You have %1 insulin on board' - , es: 'Tienes %1 insulina activa' - , fi: 'Sinulla on %1 aktiivista insuliinia' - , fr: 'You have %1 insulin on board' - , he: 'You have %1 insulin on board' - , hr: 'You have %1 insulin on board' - , it: 'Tu hai %1 insulina attiva' - , ko: 'You have %1 insulin on board' - , nb: 'You have %1 insulin on board' - , pl: 'Masz %1 aktywnej insuliny' - , pt: 'You have %1 insulin on board' - , ro: 'Aveți %1 insulină activă' - , ru: 'вы имеете %1 инсулина в организме' - , sk: 'You have %1 insulin on board' - , sv: 'You have %1 insulin on board' - , nl: 'You have %1 insulin on board' - , tr: 'Sizde %1 aktif insülin var' - , zh_cn: '你有 %1 的活性胰岛素' - , zh_tw: 'You have %1 insulin on board' - }, - 'alexaIobUnits': { + 'virtAsstIobIntent': { + bg: 'Máte %1 jednotek aktivního inzulínu' + , cs: 'You have %1 insulin on board' + , de: 'Du hast noch %1 Insulin wirkend' + , dk: 'Du har %1 insulin i kroppen' + , el: 'You have %1 insulin on board' + , en: 'You have %1 insulin on board' + , es: 'Tienes %1 insulina activa' + , fi: 'Sinulla on %1 aktiivista insuliinia' + , fr: 'You have %1 insulin on board' + , he: 'You have %1 insulin on board' + , hr: 'You have %1 insulin on board' + , it: 'Tu hai %1 insulina attiva' + , ko: 'You have %1 insulin on board' + , nb: 'You have %1 insulin on board' + , pl: 'Masz %1 aktywnej insuliny' + , pt: 'You have %1 insulin on board' + , ro: 'Aveți %1 insulină activă' + , ru: 'вы имеете %1 инсулина в организме' + , sk: 'You have %1 insulin on board' + , sv: 'You have %1 insulin on board' + , nl: 'You have %1 insulin on board' + , tr: 'Sizde %1 aktif insülin var' + , zh_cn: '你有 %1 的活性胰岛素' + , zh_tw: 'You have %1 insulin on board' + }, + 'virtAsstIobUnits': { bg: '%1 units of' , cs: '%1 jednotek' , de: 'noch %1 Einheiten' @@ -13411,7 +13719,7 @@ function init() { , zh_cn: '%1 单位' , zh_tw: '%1 units of' }, - 'alexaPreamble': { + 'virtAsstPreamble': { bg: 'Your' , cs: 'Vaše' , de: 'Deine' @@ -13437,7 +13745,7 @@ function init() { , zh_cn: '你的' , zh_tw: 'Your' }, - 'alexaPreamble3person': { + 'virtAsstPreamble3person': { bg: '%1 has a ' , cs: '%1 má ' , de: '%1 hat eine' @@ -13463,7 +13771,7 @@ function init() { , zh_cn: '%1 有一个 ' , zh_tw: '%1 has a ' }, - 'alexaNoInsulin': { + 'virtAsstNoInsulin': { bg: 'no' , cs: 'žádný' , de: 'kein' @@ -13489,75 +13797,92 @@ function init() { , zh_cn: '否' , zh_tw: 'no' }, - 'alexaUploadBattery': { - bg: 'Your uploader battery is at %1' - ,cs: 'Baterie mobilu má %1' - , en: 'Your uploader battery is at %1' - , hr: 'Your uploader battery is at %1' - , de: 'Der Akku deines Uploader Handys ist bei %1' - , dk: 'Din uploaders batteri er %1' - , ko: 'Your uploader battery is at %1' - , nl: 'De batterij van je mobiel is bij %l' - ,zh_cn: '你的手机电池电量是 %1 ' - , sv: 'Din uppladdares batteri är %1' - , fi: 'Lähettimen paristoa jäljellä %1' - , ro: 'Bateria uploaderului este la %1' - , pl: 'Twoja bateria ma %1' - , ru: 'батарея загрузчика %1' - , tr: 'Yükleyici piliniz %1' - }, - 'alexaReservoir': { - bg: 'You have %1 units remaining' - , cs: 'V zásobníku zbývá %1 jednotek' - , en: 'You have %1 units remaining' - , hr: 'You have %1 units remaining' - , de: 'Du hast %1 Einheiten übrig' - , dk: 'Du har %1 enheder tilbage' - , ko: 'You have %1 units remaining' - , nl: 'Je hebt nog %l eenheden in je reservoir' - ,zh_cn: '你剩余%1 U的胰岛素' - , sv: 'Du har %1 enheter kvar' - , fi: '%1 yksikköä insuliinia jäljellä' - , ro: 'Mai aveți %1 unități rămase' - , pl: 'W zbiorniku pozostało %1 jednostek' - , ru: 'остается %1 ед' - , tr: '%1 birim kaldı' - }, - 'alexaPumpBattery': { - bg: 'Your pump battery is at %1 %2' - , cs: 'Baterie v pumpě má %1 %2' - , en: 'Your pump battery is at %1 %2' - , hr: 'Your pump battery is at %1 %2' - , de: 'Der Batteriestand deiner Pumpe ist bei %1 %2' - , dk: 'Din pumpes batteri er %1 %2' - , ko: 'Your pump battery is at %1 %2' - , nl: 'Je pomp batterij is bij %1 %2' - ,zh_cn: '你的泵电池电量是%1 %2' - , sv: 'Din pumps batteri är %1 %2' - , fi: 'Pumppu on %1 %2' - , ro: 'Bateria pompei este la %1 %2' - , pl: 'Bateria pompy jest w %1 %2' - , ru: 'батарея помпы %1 %2' - , tr: 'Pompa piliniz %1 %2' - }, - 'alexaLastLoop': { - bg: 'The last successful loop was %1' - , cs: 'Poslední úšpěšné provedení smyčky %1' - , en: 'The last successful loop was %1' - , hr: 'The last successful loop was %1' - , de: 'Der letzte erfolgreiche Loop war %1' - , dk: 'Seneste successfulde loop var %1' - , ko: 'The last successful loop was %1' - , nl: 'De meest recente goede loop was %1' - ,zh_cn: '最后一次成功闭环的是在%1' - , sv: 'Senaste lyckade loop var %1' - , fi: 'Viimeisin onnistunut loop oli %1' - , ro: 'Ultima decizie loop implementată cu succes a fost %1' - , pl: 'Ostatnia pomyślna pętla była %1' - , ru: 'недавний успешный цикл был %1' - , tr: 'Son başarılı döngü %1 oldu' - }, - 'alexaLoopNotAvailable': { + 'virtAsstUploadBattery': { + bg: 'Your uploader battery is at %1' + , cs: 'Baterie mobilu má %1' + , en: 'Your uploader battery is at %1' + , hr: 'Your uploader battery is at %1' + , de: 'Der Akku deines Uploader Handys ist bei %1' + , dk: 'Din uploaders batteri er %1' + , ko: 'Your uploader battery is at %1' + , nl: 'De batterij van je mobiel is bij %l' + , zh_cn: '你的手机电池电量是 %1 ' + , sv: 'Din uppladdares batteri är %1' + , fi: 'Lähettimen paristoa jäljellä %1' + , ro: 'Bateria uploaderului este la %1' + , pl: 'Twoja bateria ma %1' + , ru: 'батарея загрузчика %1' + , tr: 'Yükleyici piliniz %1' + }, + 'virtAsstReservoir': { + bg: 'You have %1 units remaining' + , cs: 'V zásobníku zbývá %1 jednotek' + , en: 'You have %1 units remaining' + , hr: 'You have %1 units remaining' + , de: 'Du hast %1 Einheiten übrig' + , dk: 'Du har %1 enheder tilbage' + , ko: 'You have %1 units remaining' + , nl: 'Je hebt nog %l eenheden in je reservoir' + , zh_cn: '你剩余%1 U的胰岛素' + , sv: 'Du har %1 enheter kvar' + , fi: '%1 yksikköä insuliinia jäljellä' + , ro: 'Mai aveți %1 unități rămase' + , pl: 'W zbiorniku pozostało %1 jednostek' + , ru: 'остается %1 ед' + , tr: '%1 birim kaldı' + }, + 'virtAsstPumpBattery': { + bg: 'Your pump battery is at %1 %2' + , cs: 'Baterie v pumpě má %1 %2' + , en: 'Your pump battery is at %1 %2' + , hr: 'Your pump battery is at %1 %2' + , de: 'Der Batteriestand deiner Pumpe ist bei %1 %2' + , dk: 'Din pumpes batteri er %1 %2' + , ko: 'Your pump battery is at %1 %2' + , nl: 'Je pomp batterij is bij %1 %2' + , zh_cn: '你的泵电池电量是%1 %2' + , sv: 'Din pumps batteri är %1 %2' + , fi: 'Pumppu on %1 %2' + , ro: 'Bateria pompei este la %1 %2' + , pl: 'Bateria pompy jest w %1 %2' + , ru: 'батарея помпы %1 %2' + , tr: 'Pompa piliniz %1 %2' + }, + 'virtAsstUploaderBattery': { + bg: 'Your uploader battery is at %1' + , cs: 'Your uploader battery is at %1' + , en: 'Your uploader battery is at %1' + , hr: 'Your uploader battery is at %1' + , de: 'Your uploader battery is at %1' + , dk: 'Your uploader battery is at %1' + , ko: 'Your uploader battery is at %1' + , nl: 'Your uploader battery is at %1' + , zh_cn: 'Your uploader battery is at %1' + , sv: 'Your uploader battery is at %1' + , fi: 'Your uploader battery is at %1' + , ro: 'Your uploader battery is at %1' + , pl: 'Your uploader battery is at %1' + , ru: 'Your uploader battery is at %1' + , tr: 'Your uploader battery is at %1' + }, + 'virtAsstLastLoop': { + bg: 'The last successful loop was %1' + , cs: 'Poslední úšpěšné provedení smyčky %1' + , en: 'The last successful loop was %1' + , hr: 'The last successful loop was %1' + , de: 'Der letzte erfolgreiche Loop war %1' + , dk: 'Seneste successfulde loop var %1' + , ko: 'The last successful loop was %1' + , nl: 'De meest recente goede loop was %1' + , zh_cn: '最后一次成功闭环的是在%1' + , sv: 'Senaste lyckade loop var %1' + , fi: 'Viimeisin onnistunut loop oli %1' + , ro: 'Ultima decizie loop implementată cu succes a fost %1' + , pl: 'Ostatnia pomyślna pętla była %1' + , ru: 'недавний успешный цикл был %1' + , tr: 'Son başarılı döngü %1 oldu' + }, + 'virtAsstLoopNotAvailable': { bg: 'Loop plugin does not seem to be enabled' , cs: 'Plugin smyčka není patrně povolený' , en: 'Loop plugin does not seem to be enabled' @@ -13566,32 +13891,83 @@ function init() { , dk: 'Loop plugin lader ikke til at være slået til' , ko: 'Loop plugin does not seem to be enabled' , nl: 'De Loop plugin is niet geactiveerd' - ,zh_cn: 'Loop插件看起来没有被启用' + , zh_cn: 'Loop插件看起来没有被启用' , sv: 'Loop plugin verkar inte vara aktiverad' , fi: 'Loop plugin ei ole aktivoitu' , ro: 'Extensia loop pare a fi dezactivată' , pl: 'Plugin Loop prawdopodobnie nie jest włączona' - , ru: 'плагин ЗЦ Loop не активирован ' + , ru: 'плагин ЗЦ Loop не активирован' , tr: 'Döngü eklentisi etkin görünmüyor' }, - 'alexaLoopForecast': { - bg: 'According to the loop forecast you are expected to be %1 over the next %2' - , cs: 'Podle přepovědi smyčky je očekávána glykémie %1 během následujících %2' - , en: 'According to the loop forecast you are expected to be %1 over the next %2' - , hr: 'According to the loop forecast you are expected to be %1 over the next %2' - , de: 'Entsprechend der Loop Vorhersage landest du bei %1 während der nächsten %2' - , dk: 'Ifølge Loops forudsigelse forventes du at blive %1 i den næste %2' - , ko: 'According to the loop forecast you are expected to be %1 over the next %2' - , nl: 'Volgens de Loop voorspelling is je waarde %1 over de volgnede %2' - ,zh_cn: '根据loop的预测,在接下来的%2你的血糖将会是%1' - , sv: 'Enligt Loops förutsägelse förväntas du bli %1 inom %2' - , fi: 'Ennusteen mukaan olet %1 seuraavan %2 ajan' - , ro: 'Potrivit previziunii date de loop se estiemază %1 pentru următoarele %2' - , pl: 'Zgodnie z prognozą pętli, glikemia %1 będzie podczas następnego %2' - , ru: 'по прогнозу алгоритма ЗЦ ожидается %1 за последующие %2' - , tr: 'Döngü tahminine göre sonraki %2 ye göre %1 olması bekleniyor' + 'virtAsstLoopForecastAround': { + bg: 'According to the loop forecast you are expected to be around %1 over the next %2' + , cs: 'Podle přepovědi smyčky je očekávána glykémie around %1 během následujících %2' + , en: 'According to the loop forecast you are expected to be around %1 over the next %2' + , hr: 'According to the loop forecast you are expected to be around %1 over the next %2' + , de: 'Entsprechend der Loop Vorhersage landest du bei around %1 während der nächsten %2' + , dk: 'Ifølge Loops forudsigelse forventes du at blive around %1 i den næste %2' + , ko: 'According to the loop forecast you are expected to be around %1 over the next %2' + , nl: 'Volgens de Loop voorspelling is je waarde around %1 over de volgnede %2' + , zh_cn: '根据loop的预测,在接下来的%2你的血糖将会是around %1' + , sv: 'Enligt Loops förutsägelse förväntas du bli around %1 inom %2' + , fi: 'Ennusteen mukaan olet around %1 seuraavan %2 ajan' + , ro: 'Potrivit previziunii date de loop se estiemază around %1 pentru următoarele %2' + , pl: 'Zgodnie z prognozą pętli, glikemia around %1 będzie podczas następnego %2' + , ru: 'по прогнозу алгоритма ЗЦ ожидается around %1 за последующие %2' + , tr: 'Döngü tahminine göre sonraki %2 ye göre around %1 olması bekleniyor' + }, + 'virtAsstLoopForecastBetween': { + bg: 'According to the loop forecast you are expected to be between %1 and %2 over the next %3' + , cs: 'Podle přepovědi smyčky je očekávána glykémie between %1 and %2 během následujících %3' + , en: 'According to the loop forecast you are expected to be between %1 and %2 over the next %3' + , hr: 'According to the loop forecast you are expected to be between %1 and %2 over the next %3' + , de: 'Entsprechend der Loop Vorhersage landest du bei between %1 and %2 während der nächsten %3' + , dk: 'Ifølge Loops forudsigelse forventes du at blive between %1 and %2 i den næste %3' + , ko: 'According to the loop forecast you are expected to be between %1 and %2 over the next %3' + , nl: 'Volgens de Loop voorspelling is je waarde between %1 and %2 over de volgnede %3' + , zh_cn: '根据loop的预测,在接下来的%3你的血糖将会是between %1 and %2' + , sv: 'Enligt Loops förutsägelse förväntas du bli between %1 and %2 inom %3' + , fi: 'Ennusteen mukaan olet between %1 and %2 seuraavan %3 ajan' + , ro: 'Potrivit previziunii date de loop se estiemază between %1 and %2 pentru următoarele %3' + , pl: 'Zgodnie z prognozą pętli, glikemia between %1 and %2 będzie podczas następnego %3' + , ru: 'по прогнозу алгоритма ЗЦ ожидается between %1 and %2 за последующие %3' + , tr: 'Döngü tahminine göre sonraki %3 ye göre between %1 and %2 olması bekleniyor' + }, + 'virtAsstAR2ForecastAround': { + bg: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , cs: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , en: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , hr: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , de: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , dk: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , ko: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , nl: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , zh_cn: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , sv: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , fi: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , ro: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , pl: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , ru: 'According to the AR2 forecast you are expected to be around %1 over the next %2' + , tr: 'According to the AR2 forecast you are expected to be around %1 over the next %2' }, - 'alexaForecastUnavailable': { + 'virtAsstAR2ForecastBetween': { + bg: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , cs: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , en: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , hr: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , de: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , dk: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , ko: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , nl: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , zh_cn: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , sv: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , fi: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , ro: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , pl: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , ru: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + , tr: 'According to the AR2 forecast you are expected to be between %1 and %2 over the next %3' + }, + 'virtAsstForecastUnavailable': { bg: 'Unable to forecast with the data that is available' , cs: 'S dostupnými daty přepověď není možná' , en: 'Unable to forecast with the data that is available' @@ -13600,7 +13976,7 @@ function init() { , dk: 'Det er ikke muligt at forudsige md de tilgængelige data' , ko: 'Unable to forecast with the data that is available' , nl: 'Niet mogelijk om een voorspelling te doen met de data die beschikbaar is' - ,zh_cn: '血糖数据不可用,无法预测未来走势' + , zh_cn: '血糖数据不可用,无法预测未来走势' , sv: 'Förutsägelse ej möjlig med tillgänlig data' , fi: 'Ennusteet eivät ole toiminnassa puuttuvan tiedon vuoksi' , ro: 'Estimarea este imposibilă pe baza datelor disponibile' @@ -13608,14 +13984,14 @@ function init() { , ru: 'прогноз при таких данных невозможен' , tr: 'Mevcut verilerle tahmin edilemedi' }, - 'alexaRawBG': { - en: 'Your raw bg is %1' + 'virtAsstRawBG': { + en: 'Your raw bg is %1' , cs: 'Raw glykémie je %1' , de: 'Dein Rohblutzucker ist %1' , dk: 'Dit raw blodsukker er %1' , ko: 'Your raw bg is %1' , nl: 'Je raw bloedwaarde is %1' - ,zh_cn: '你的血糖是 %1' + , zh_cn: '你的血糖是 %1' , sv: 'Ditt raw blodsocker är %1' , fi: 'Suodattamaton verensokeriarvo on %1' , ro: 'Glicemia brută este %1' @@ -13625,39 +14001,108 @@ function init() { , ru: 'ваши необработанные данные RAW %1' , tr: 'Ham kan şekeriniz %1' }, - 'alexaOpenAPSForecast': { + 'virtAsstOpenAPSForecast': { en: 'The OpenAPS Eventual BG is %1' , cs: 'OpenAPS Eventual BG je %1' , de: 'Der von OpenAPS vorhergesagte Blutzucker ist %1' , dk: 'OpenAPS forventet blodsukker er %1' , ko: 'The OpenAPS Eventual BG is %1' , nl: 'OpenAPS uiteindelijke bloedglucose van %1' - ,zh_cn: 'OpenAPS 预测最终血糖是 %1' + , zh_cn: 'OpenAPS 预测最终血糖是 %1' , sv: 'OpenAPS slutgiltigt blodsocker är %1' , fi: 'OpenAPS verensokeriarvio on %1' , ro: 'Glicemia estimată de OpenAPS este %1' - ,bg: 'The OpenAPS Eventual BG is %1' - ,hr: 'The OpenAPS Eventual BG is %1' + , bg: 'The OpenAPS Eventual BG is %1' + , hr: 'The OpenAPS Eventual BG is %1' , pl: 'Glikemia prognozowana przez OpenAPS wynosi %1' , ru: 'OpenAPS прогнозирует ваш СК как %1 ' , tr: 'OpenAPS tarafından tahmin edilen kan şekeri %1' }, - 'alexaCOB': { - en: '%1 %2 carbohydrates on board' - , cs: '%1 %2 aktivních sachridů' - , de: '%1 %2 Gramm Kohlenhydrate wirkend.' - , dk: '%1 %2 gram aktive kulhydrater' - , ko: '%1 %2 carbohydrates on board' - , nl: '%1 %2 actieve koolhydraten' - ,zh_cn: '%1 %2 活性碳水化合物' - , sv: '%1 %2 gram aktiva kolhydrater' - , fi: '%1 %2 aktiivista hiilihydraattia' - , ro: '%1 %2 carbohidrați activi în corp' - ,bg: '%1 %2 carbohydrates on board' - ,hr: '%1 %2 carbohydrates on board' - , pl: '%1 %2 aktywnych węglowodanów' - , ru: '%1 $2 активных углеводов' - , tr: '%1 %2 aktif karbonhidrat' + 'virtAsstCob3person': { + bg: '%1 has $2 carbohydrates on board' + , cs: '%1 has $2 carbohydrates on board' + , de: '%1 has $2 carbohydrates on board' + , dk: '%1 has $2 carbohydrates on board' + , el: '%1 has $2 carbohydrates on board' + , en: '%1 has $2 carbohydrates on board' + , es: '%1 has $2 carbohydrates on board' + , fi: '%1 has $2 carbohydrates on board' + , fr: '%1 has $2 carbohydrates on board' + , he: '%1 has $2 carbohydrates on board' + , hr: '%1 has $2 carbohydrates on board' + , it: '%1 has $2 carbohydrates on board' + , ko: '%1 has $2 carbohydrates on board' + , nb: '%1 has $2 carbohydrates on board' + , nl: '%1 has $2 carbohydrates on board' + , pl: '%1 has $2 carbohydrates on board' + , pt: '%1 has $2 carbohydrates on board' + , ro: '%1 has $2 carbohydrates on board' + , ru: '%1 has $2 carbohydrates on board' + , sk: '%1 has $2 carbohydrates on board' + , sv: '%1 has $2 carbohydrates on board' + , tr: '%1 has $2 carbohydrates on board' + , zh_cn: '%1 has $2 carbohydrates on board' + , zh_tw: '%1 has $2 carbohydrates on board' + }, + 'virtAsstCob': { + bg: 'You have %1 carbohydrates on board' + , cs: 'You have %1 carbohydrates on board' + , de: 'You have %1 carbohydrates on board' + , dk: 'You have %1 carbohydrates on board' + , el: 'You have %1 carbohydrates on board' + , en: 'You have %1 carbohydrates on board' + , es: 'You have %1 carbohydrates on board' + , fi: 'You have %1 carbohydrates on board' + , fr: 'You have %1 carbohydrates on board' + , he: 'You have %1 carbohydrates on board' + , hr: 'You have %1 carbohydrates on board' + , it: 'You have %1 carbohydrates on board' + , ko: 'You have %1 carbohydrates on board' + , nb: 'You have %1 carbohydrates on board' + , nl: 'You have %1 carbohydrates on board' + , pl: 'You have %1 carbohydrates on board' + , pt: 'You have %1 carbohydrates on board' + , ro: 'You have %1 carbohydrates on board' + , ru: 'You have %1 carbohydrates on board' + , sk: 'You have %1 carbohydrates on board' + , sv: 'You have %1 carbohydrates on board' + , tr: 'You have %1 carbohydrates on board' + , zh_cn: 'You have %1 carbohydrates on board' + , zh_tw: 'You have %1 carbohydrates on board' + }, + 'virtAsstUnknownIntentTitle': { + en: 'Unknown Intent' + , cs: 'Unknown Intent' + , de: 'Unknown Intent' + , dk: 'Unknown Intent' + , ko: 'Unknown Intent' + , nl: 'Unknown Intent' + , zh_cn: 'Unknown Intent' + , sv: 'Unknown Intent' + , fi: 'Unknown Intent' + , ro: 'Unknown Intent' + , bg: 'Unknown Intent' + , hr: 'Unknown Intent' + , pl: 'Unknown Intent' + , ru: 'Unknown Intent' + , tr: 'Unknown Intent' + }, + 'virtAsstUnknownIntentText': { + en: 'I\'m sorry, I don\'t know what you\'re asking for.' + , cs: 'I\'m sorry, I don\'t know what you\'re asking for.' + , de: 'I\'m sorry, I don\'t know what you\'re asking for.' + , dk: 'I\'m sorry, I don\'t know what you\'re asking for.' + , ko: 'I\'m sorry, I don\'t know what you\'re asking for.' + , nl: 'I\'m sorry, I don\'t know what you\'re asking for.' + , zh_cn: 'I\'m sorry, I don\'t know what you\'re asking for.' + , sv: 'I\'m sorry, I don\'t know what you\'re asking for.' + , fi: 'I\'m sorry, I don\'t know what you\'re asking for.' + , ro: 'I\'m sorry, I don\'t know what you\'re asking for.' + , bg: 'I\'m sorry, I don\'t know what you\'re asking for.' + , hr: 'I\'m sorry, I don\'t know what you\'re asking for.' + , pl: 'I\'m sorry, I don\'t know what you\'re asking for.' + , ru: 'I\'m sorry, I don\'t know what you\'re asking for.' + , tr: 'I\'m sorry, I don\'t know what you\'re asking for.' }, 'Fat [g]': { cs: 'Tuk [g]' @@ -13677,6 +14122,7 @@ function init() { ,hr: 'Masnoće [g]' ,pl: 'Tłuszcz [g]' ,tr: 'Yağ [g]' + ,he: '[g] שמן' }, 'Protein [g]': { cs: 'Proteiny [g]' @@ -13696,6 +14142,7 @@ function init() { ,hr: 'Proteini [g]' ,pl: 'Białko [g]' ,tr: 'Protein [g]' + ,he: '[g] חלבון' }, 'Energy [kJ]': { cs: 'Energie [kJ]' @@ -13705,7 +14152,7 @@ function init() { ,es: 'Energía [Kj]' ,fr: 'Énergie [kJ]' ,ro: 'Energie [g]' - ,ru: 'энергия [kJ' + ,ru: 'энергия [kJ]' ,it: 'Energia [kJ]' ,zh_cn: '能量 [kJ]' ,ko: 'Energy [kJ]' @@ -13715,6 +14162,7 @@ function init() { ,hr: 'Energija [kJ]' ,pl: 'Energia [kJ}' ,tr: 'Enerji [kJ]' + ,he: '[kJ] אנרגיה' }, 'Clock Views:': { cs: 'Hodiny:' @@ -13734,6 +14182,7 @@ function init() { ,hr: 'Satovi:' ,pl: 'Widoki zegarów' ,tr: 'Saat Görünümü' + ,he: 'צגים השעון' }, 'Clock': { cs: 'Hodiny' @@ -13752,6 +14201,7 @@ function init() { ,pl: 'Zegar' ,ru: 'часы' ,tr: 'Saat' + ,he: 'שעון' }, 'Color': { cs: 'Barva' @@ -13770,6 +14220,7 @@ function init() { ,pl: 'Kolor' ,ru: 'цвет' ,tr: 'Renk' + ,he: 'צבע' }, 'Simple': { cs: 'Jednoduchý' @@ -13788,6 +14239,7 @@ function init() { ,pl: 'Prosty' ,ru: 'простой' ,tr: 'Basit' + ,he: 'פשוט' }, 'TDD average': { cs: 'Průměrná denní dávka' @@ -13822,6 +14274,7 @@ function init() { , pl: 'Średnia ilość węglowodanów' , ru: 'среднее кол-во углеводов за сутки' , tr: 'Günde ortalama karbonhidrat' + ,he: 'פחמימות ממוצע' }, 'Eating Soon': { cs: 'Blížící se jídlo' @@ -13839,6 +14292,7 @@ function init() { , pl: 'Przed jedzeniem' , ru: 'скоро прием пищи' , tr: 'Yakında Yenecek' + , he: 'אוכל בקרוב' }, 'Last entry {0} minutes ago': { cs: 'Poslední hodnota {0} minut zpět' @@ -13873,6 +14327,7 @@ function init() { , pl: 'zmiana' , ru: 'замена' , tr: 'değişiklik' + , he: 'שינוי' }, 'Speech': { cs: 'Hlas' @@ -13890,6 +14345,7 @@ function init() { , pl: 'Głos' , ru: 'речь' , tr: 'Konuş' + , he: 'דיבור' }, 'Target Top': { cs: 'Horní cíl' @@ -13907,6 +14363,7 @@ function init() { , ru: 'верхняя граница цели' , de: 'Oberes Ziel' , tr: 'Hedef Üst' + , he: 'ראש היעד' }, 'Target Bottom': { cs: 'Dolní cíl' @@ -13924,6 +14381,7 @@ function init() { , ru: 'нижняя граница цели' , de: 'Unteres Ziel' , tr: 'Hedef Alt' + , he: 'תחתית היעד' }, 'Canceled': { cs: 'Zrušený' @@ -13941,6 +14399,7 @@ function init() { , ru: 'отменено' , de: 'Abgebrochen' , tr: 'İptal edildi' + , he: 'מבוטל' }, 'Meter BG': { cs: 'Hodnota z glukoměru' @@ -13955,9 +14414,10 @@ function init() { , bg: 'Измерена КЗ' , hr: 'GUK iz krvi' , pl: 'Glikemia z krwi' - , ru: 'СК по глюкометру' + , ru: 'ГК по глюкометру' , de: 'Wert Blutzuckermessgerät' , tr: 'Glikometre KŞ' + , he: 'סוכר הדם של מד' }, 'predicted': { cs: 'přepověď' @@ -13975,6 +14435,7 @@ function init() { , ru: 'прогноз' , de: 'vorhergesagt' , tr: 'tahmin' + , he: 'חזה' }, 'future': { cs: 'budoucnost' @@ -13992,6 +14453,7 @@ function init() { , ru: 'будущее' , de: 'Zukunft' , tr: 'gelecek' + , he: 'עתיד' }, 'ago': { cs: 'zpět' @@ -14005,9 +14467,10 @@ function init() { , bg: 'преди' , hr: 'prije' , pl: 'temu' - , ru: 'назад' + , ru: 'в прошлом' , de: 'vor' , tr: 'önce' + , he: 'לפני' }, 'Last data received': { cs: 'Poslední data přiajata' @@ -14022,9 +14485,10 @@ function init() { , bg: 'Последни данни преди' , hr: 'Podaci posljednji puta primljeni' , pl: 'Ostatnie otrzymane dane' - , ru: 'недавние данные получены' + , ru: 'последние данные получены' , de: 'Zuletzt Daten empfangen' , tr: 'Son veri alındı' + , he: 'הנתונים המקבל אחרונים' }, 'Clock View': { cs: 'Hodiny' @@ -14044,35 +14508,63 @@ function init() { ,de: 'Uhr-Anzeigen' ,pl: 'Widok zegara' ,tr: 'Saat Görünümü' + ,he: 'צג השעון' }, 'Protein': { fi: 'Proteiini' - , de: 'Protein' + ,de: 'Protein' + ,tr: 'Protein' + ,hr: 'Proteini' + ,ru: 'Белки' + ,he: 'חלבון' }, 'Fat': { fi: 'Rasva' - , de: 'Fett' + ,de: 'Fett' + ,tr: 'Yağ' + ,hr: 'Masti' + ,ru: 'Жиры' + ,he: 'שמן' }, 'Protein average': { fi: 'Proteiini keskiarvo' - , de: 'Proteine Durchschnitt' + ,de: 'Proteine Durchschnitt' + ,tr: 'Protein Ortalaması' + ,hr: 'Prosjek proteina' + ,ru: 'Средний белок' + ,he: 'חלבון ממוצע' }, 'Fat average': { fi: 'Rasva keskiarvo' - , de: 'Fett Durchschnitt' - + ,de: 'Fett Durchschnitt' + ,tr: 'Yağ Ortalaması' + ,hr: 'Prosjek masti' + ,ru: 'Средний жир' + ,he: 'שמן ממוצע' }, 'Total carbs': { fi: 'Hiilihydraatit yhteensä' , de: 'Kohlenhydrate gesamt' + ,tr: 'Toplam Karbonhidrat' + ,hr: 'Ukupno ugh' + ,ru: 'Всего углеводов' + ,he: 'כל פחמימות' }, 'Total protein': { fi: 'Proteiini yhteensä' , de: 'Protein gesamt' + ,tr: 'Toplam Protein' + ,hr: 'Ukupno proteini' + ,ru: 'Всего белков' + ,he: 'כל חלבונים' }, 'Total fat': { fi: 'Rasva yhteensä' , de: 'Fett gesamt' + ,tr: 'Toplam Yağ' + ,hr: 'Ukupno masti' + ,ru: 'Всего жиров' + ,he: 'כל שומנים' } }; diff --git a/lib/plugins/alexa.js b/lib/plugins/alexa.js index 38ae449c249..da7bbdca2f1 100644 --- a/lib/plugins/alexa.js +++ b/lib/plugins/alexa.js @@ -1,60 +1,58 @@ var _ = require('lodash'); var async = require('async'); -function init(env, ctx) { - console.log('Configuring Alexa.'); +function init (env, ctx) { + console.log('Configuring Alexa...'); function alexa() { return alexa; } var intentHandlers = {}; var rollup = {}; - // This configures a router/handler. A routable slot the name of a slot that you wish to route on and the slotValues - // are the values that determine the routing. This allows for specific intent handlers based on the value of a - // specific slot. Routing is only supported on one slot for now. - // There is no protection for a previously configured handler - one plugin can overwrite the handler of another - // plugin. - alexa.configureIntentHandler = function configureIntentHandler(intent, handler, routableSlot, slotValues) { - if (! intentHandlers[intent]) { + // There is no protection for a previously handled metric - one plugin can overwrite the handler of another plugin. + alexa.configureIntentHandler = function configureIntentHandler(intent, handler, metrics) { + if (!intentHandlers[intent]) { intentHandlers[intent] = {}; } - if (routableSlot && slotValues) { - for (var i = 0, len = slotValues.length; i < len; i++) { - if (! intentHandlers[intent][routableSlot]) { - intentHandlers[intent][routableSlot] = {}; + if (metrics) { + for (var i = 0, len = metrics.length; i < len; i++) { + if (!intentHandlers[intent][metrics[i]]) { + intentHandlers[intent][metrics[i]] = {}; } - if (!intentHandlers[intent][routableSlot][slotValues[i]]) { - intentHandlers[intent][routableSlot][slotValues[i]] = {}; - } - intentHandlers[intent][routableSlot][slotValues[i]].handler = handler; + console.log('Storing handler for intent \'' + intent + '\' for metric \'' + metrics[i] + '\''); + intentHandlers[intent][metrics[i]].handler = handler; } } else { + console.log('Storing handler for intent \'' + intent + '\''); intentHandlers[intent].handler = handler; } }; - // This function retrieves a handler based on the intent name and slots requested. - alexa.getIntentHandler = function getIntentHandler(intentName, slots) { - if (intentName && intentHandlers[intentName]) { - if (slots) { - var slotKeys = Object.keys(slots); - for (var i = 0, len = slotKeys.length; i < len; i++) { - if (intentHandlers[intentName][slotKeys[i]] && slots[slotKeys[i]].value && - intentHandlers[intentName][slotKeys[i]][slots[slotKeys[i]].value] && - intentHandlers[intentName][slotKeys[i]][slots[slotKeys[i]].value].handler) { - - return intentHandlers[intentName][slotKeys[i]][slots[slotKeys[i]].value].handler; - } - } - } - if (intentHandlers[intentName].handler) { + // This function retrieves a handler based on the intent name and metric requested. + alexa.getIntentHandler = function getIntentHandler(intentName, metric) { + if (metric === undefined) { + console.log('Looking for handler for intent \'' + intentName + '\''); + if (intentName + && intentHandlers[intentName] + && intentHandlers[intentName].handler + ) { + console.log('Found!'); return intentHandlers[intentName].handler; } - return null; } else { - return null; + console.log('Looking for handler for intent \'' + intentName + '\' for metric \'' + metric + '\''); + if (intentName + && intentHandlers[intentName] + && intentHandlers[intentName][metric] + && intentHandlers[intentName][metric].handler + ) { + console.log('Found!'); + return intentHandlers[intentName][metric].handler + } } + console.log('Not found!'); + return null; }; alexa.addToRollup = function(rollupGroup, handler, rollupName) { @@ -63,7 +61,6 @@ function init(env, ctx) { rollup[rollupGroup] = []; } rollup[rollupGroup].push({handler: handler, name: rollupName}); - // status = _.orderBy(status, ['priority'], ['asc']) }; alexa.getRollup = function(rollupGroup, sbx, slots, locale, callback) { @@ -110,4 +107,4 @@ function init(env, ctx) { return alexa; } -module.exports = init; +module.exports = init; \ No newline at end of file diff --git a/lib/plugins/ar2.js b/lib/plugins/ar2.js index e25a2b36229..52aec073759 100644 --- a/lib/plugins/ar2.js +++ b/lib/plugins/ar2.js @@ -17,6 +17,7 @@ var AR2_COLOR = 'cyan'; // eslint-disable-next-line no-unused-vars function init (ctx) { + var translate = ctx.language.translate; var ar2 = { name: 'ar2' @@ -146,7 +147,7 @@ function init (ctx) { return result.points; }; - function alexaAr2Handler (next, slots, sbx) { + function virtAsstAr2Handler (next, slots, sbx) { if (sbx.properties.ar2.forecast.predicted) { var forecast = sbx.properties.ar2.forecast.predicted; var max = forecast[0].mgdl; @@ -163,19 +164,34 @@ function init (ctx) { maxForecastMills = forecast[i].mills; } } - var response = 'You are expected to be between ' + min + ' and ' + max + ' over the ' + moment(maxForecastMills).from(moment(sbx.time)); - next('AR2 Forecast', response); + var response = ''; + if (min === max) { + response = translate('virtAsstAR2ForecastAround', { + params: [ + max + , moment(maxForecastMills).from(moment(sbx.time)) + ] + }); + } else { + response = translate('virtAsstAR2ForecastBetween', { + params: [ + min + , max + , moment(maxForecastMills).from(moment(sbx.time)) + ] + }); + } + next(translate('virtAsstTitleAR2Forecast'), response); } else { - next('AR2 Forecast', 'AR2 plugin does not seem to be enabled'); + next(translate('virtAsstTitleAR2Forecast'), translate('virtAsstUnknown')); } } - ar2.alexa = { + ar2.virtAsst = { intentHandlers: [{ intent: 'MetricNow' - , routableSlot: 'metric' - , slots: ['ar2 forecast', 'forecast'] - , intentHandler: alexaAr2Handler + , metrics: ['ar2 forecast', 'forecast'] + , intentHandler: virtAsstAr2Handler }] }; diff --git a/lib/plugins/basalprofile.js b/lib/plugins/basalprofile.js index 05bc8c7248a..4347a7005ec 100644 --- a/lib/plugins/basalprofile.js +++ b/lib/plugins/basalprofile.js @@ -1,6 +1,7 @@ 'use strict'; var times = require('../times'); var moment = require('moment'); +var consts = require('../constants'); function init (ctx) { @@ -62,9 +63,10 @@ function init (ctx) { var tzMessage = profile.getTimezone() ? profile.getTimezone() : 'Timezone not set in profile'; var sensitivity = profile.getSensitivity(sbx.time); + var units = profile.getUnits(); - if (sbx.settings.units != profile.data[0].units) { - sensitivity *= (sbx.settings.units === 'mmol' ? 0.055 : 18); + if (sbx.settings.units != units) { + sensitivity *= (sbx.settings.units === 'mmol' ? (1 / consts.MMOL_TO_MGDL) : consts.MMOL_TO_MGDL); var decimals = (sbx.settings.units === 'mmol' ? 10 : 1); sensitivity = Math.round(sensitivity * decimals) / decimals; @@ -111,16 +113,16 @@ function init (ctx) { function basalMessage(slots, sbx) { var basalValue = sbx.data.profile.getTempBasal(sbx.time); - var response = 'Unable to determine current basal'; + var response = translate('virtAsstUnknown'); var preamble = ''; if (basalValue.treatment) { - preamble = (slots && slots.pwd && slots.pwd.value) ? translate('alexaPreamble3person', { + preamble = (slots && slots.pwd && slots.pwd.value) ? translate('virtAsstPreamble3person', { params: [ slots.pwd.value ] - }) : translate('alexaPreamble'); + }) : translate('virtAsstPreamble'); var minutesLeft = moment(basalValue.treatment.endmills).from(moment(sbx.time)); - response = translate('alexaBasalTemp', { + response = translate('virtAsstBasalTemp', { params: [ preamble, basalValue.totalbasal, @@ -128,12 +130,12 @@ function init (ctx) { ] }); } else { - preamble = (slots && slots.pwd && slots.pwd.value) ? translate('alexaPreamble3person', { + preamble = (slots && slots.pwd && slots.pwd.value) ? translate('virtAsstPreamble3person', { params: [ slots.pwd.value ] - }) : translate('alexaPreamble'); - response = translate('alexaBasal', { + }) : translate('virtAsstPreamble'); + response = translate('virtAsstBasal', { params: [ preamble, basalValue.totalbasal @@ -143,30 +145,28 @@ function init (ctx) { return response; } - function alexaRollupCurrentBasalHandler (slots, sbx, callback) { + function virtAsstRollupCurrentBasalHandler (slots, sbx, callback) { callback(null, {results: basalMessage(slots, sbx), priority: 1}); } - function alexaCurrentBasalhandler (next, slots, sbx) { - next('Current Basal', basalMessage(slots, sbx)); + function virtAsstCurrentBasalhandler (next, slots, sbx) { + next(translate('virtAsstTitleCurrentBasal'), basalMessage(slots, sbx)); } - basal.alexa = { + basal.virtAsst = { rollupHandlers: [{ rollupGroup: 'Status' , rollupName: 'current basal' - , rollupHandler: alexaRollupCurrentBasalHandler + , rollupHandler: virtAsstRollupCurrentBasalHandler }], intentHandlers: [{ intent: 'MetricNow' - , routableSlot:'metric' - , slots:['basal', 'current basal'] - , intentHandler: alexaCurrentBasalhandler + , metrics: ['basal', 'current basal'] + , intentHandler: virtAsstCurrentBasalhandler }] }; return basal; } - module.exports = init; diff --git a/lib/plugins/bridge.js b/lib/plugins/bridge.js index dc47f13aa9b..bf3c67e88da 100644 --- a/lib/plugins/bridge.js +++ b/lib/plugins/bridge.js @@ -46,9 +46,17 @@ function options (env) { , minutes: env.extendedSettings.bridge.minutes || 1440 }; + var interval = env.extendedSettings.bridge.interval || 60000 * 2.5; // Default: 2.5 minutes + + if (interval < 1000 || interval > 300000) { + // Invalid interval range. Revert to default + console.error("Invalid interval set: [" + interval + "ms]. Defaulting to 2.5 minutes.") + interval = 60000 * 2.5 // 2.5 minutes + } + return { login: config - , interval: env.extendedSettings.bridge.interval || 60000 * 2.5 + , interval: interval , fetch: fetch_config , nightscout: { } , maxFailures: env.extendedSettings.bridge.maxFailures || 3 diff --git a/lib/plugins/cob.js b/lib/plugins/cob.js index bc769197c2b..c6d4c4fdf8f 100644 --- a/lib/plugins/cob.js +++ b/lib/plugins/cob.js @@ -41,15 +41,17 @@ function init (ctx) { } var devicestatusCOB = cob.lastCOBDeviceStatus(devicestatus, time); + var result = devicestatusCOB; - var treatmentCOB = (treatments !== undefined && treatments.length) ? cob.fromTreatments(treatments, devicestatus, profile, time, spec_profile) : {}; + const TEN_MINUTES = 10 * 60 * 1000; - var result = devicestatusCOB; - if (_.isEmpty(result)) { - result = treatmentCOB; + if (_.isEmpty(result) || _.isNil(result.cob) || (Date.now() - result.mills) > TEN_MINUTES) { + + var treatmentCOB = (treatments !== undefined && treatments.length) ? cob.fromTreatments(treatments, devicestatus, profile, time, spec_profile) : {}; + + result = _.cloneDeep(treatmentCOB); result.source = 'Care Portal'; - } else if (treatmentCOB) { - result.treatmentCOB = treatmentCOB; + result.treatmentCOB = _.cloneDeep(treatmentCOB); } return addDisplay(result); @@ -289,22 +291,31 @@ function init (ctx) { }); }; - function alexaCOBHandler (next, slots, sbx) { - var preamble = (slots && slots.pwd && slots.pwd.value) ? slots.pwd.value.replace('\'s', '') + ' has' : 'You have'; - var value = 'no'; - if (sbx.properties.cob && sbx.properties.cob.cob !== 0) { - value = sbx.properties.cob.cob; + function virtAsstCOBHandler (next, slots, sbx) { + var response = ''; + var value = (sbx.properties.cob && sbx.properties.cob.cob) ? sbx.properties.cob.cob : 0; + if (slots && slots.pwd && slots.pwd.value) { + response = translate('virtAsstCob3person', { + params: [ + slots.pwd.value.replace('\'s', '') + , value + ] + }); + } else { + response = translate('virtAsstCob', { + params: [ + value + ] + }); } - var response = preamble + ' ' + value + ' carbohydrates on board'; - next('Current COB', response); + next(translate('virtAsstTitleCurrentCOB'), response); } - cob.alexa = { + cob.virtAsst = { intentHandlers: [{ intent: 'MetricNow' - , routableSlot: 'metric' - , slots: ['cob', 'carbs on board', 'carbohydrates on board'] - , intentHandler: alexaCOBHandler + , metrics: ['cob', 'carbs on board', 'carbohydrates on board'] + , intentHandler: virtAsstCOBHandler }] }; diff --git a/lib/plugins/direction.js b/lib/plugins/direction.js index 12a3511368b..6274fc9e537 100644 --- a/lib/plugins/direction.js +++ b/lib/plugins/direction.js @@ -10,7 +10,7 @@ function init() { direction.setProperties = function setProperties (sbx) { sbx.offerProperty('direction', function setDirection ( ) { - if (sbx.data.inRetroMode && !sbx.isCurrent(sbx.lastSGVEntry())) { + if (!sbx.isCurrent(sbx.lastSGVEntry())) { return undefined; } else { return direction.info(sbx.lastSGVEntry()); @@ -77,4 +77,4 @@ function init() { } -module.exports = init; \ No newline at end of file +module.exports = init; diff --git a/lib/plugins/googlehome.js b/lib/plugins/googlehome.js new file mode 100644 index 00000000000..8e8181512c8 --- /dev/null +++ b/lib/plugins/googlehome.js @@ -0,0 +1,97 @@ +var _ = require('lodash'); +var async = require('async'); + +function init (env, ctx) { + console.log('Configuring Google Home...'); + function googleHome() { + return googleHome; + } + var intentHandlers = {}; + var rollup = {}; + + // There is no protection for a previously handled metric - one plugin can overwrite the handler of another plugin. + googleHome.configureIntentHandler = function configureIntentHandler(intent, handler, metrics) { + if (!intentHandlers[intent]) { + intentHandlers[intent] = {}; + } + if (metrics) { + for (var i = 0, len = metrics.length; i < len; i++) { + if (!intentHandlers[intent][metrics[i]]) { + intentHandlers[intent][metrics[i]] = {}; + } + console.log('Storing handler for intent \'' + intent + '\' for metric \'' + metrics[i] + '\''); + intentHandlers[intent][metrics[i]].handler = handler; + } + } else { + console.log('Storing handler for intent \'' + intent + '\''); + intentHandlers[intent].handler = handler; + } + }; + + // This function retrieves a handler based on the intent name and metric requested. + googleHome.getIntentHandler = function getIntentHandler(intentName, metric) { + console.log('Looking for handler for intent \'' + intentName + '\' for metric \'' + metric + '\''); + if (intentName && intentHandlers[intentName]) { + if (intentHandlers[intentName][metric] && intentHandlers[intentName][metric].handler) { + console.log('Found!'); + return intentHandlers[intentName][metric].handler + } else if (intentHandlers[intentName].handler) { + console.log('Found!'); + return intentHandlers[intentName].handler; + } + console.log('Not found!'); + return null; + } else { + console.log('Not found!'); + return null; + } + }; + + googleHome.addToRollup = function(rollupGroup, handler, rollupName) { + if (!rollup[rollupGroup]) { + console.log('Creating the rollup group: ', rollupGroup); + rollup[rollupGroup] = []; + } + rollup[rollupGroup].push({handler: handler, name: rollupName}); + }; + + googleHome.getRollup = function(rollupGroup, sbx, slots, locale, callback) { + var handlers = _.map(rollup[rollupGroup], 'handler'); + console.log('Rollup array for ', rollupGroup); + console.log(rollup[rollupGroup]); + var nHandlers = []; + _.each(handlers, function (handler) { + nHandlers.push(handler.bind(null, slots, sbx)); + }); + async.parallelLimit(nHandlers, 10, function(err, results) { + if (err) { + console.error('Error: ', err); + } + callback(_.map(_.orderBy(results, ['priority'], ['asc']), 'results').join(' ')); + }); + }; + + // This creates the expected Google Home response + googleHome.buildSpeechletResponse = function buildSpeechletResponse(output, expectUserResponse) { + return { + payload: { + google: { + expectUserResponse: expectUserResponse, + richResponse: { + items: [ + { + simpleResponse: { + textToSpeech: output + } + } + ] + } + } + } + }; + }; + + return googleHome; +} + +module.exports = init; \ No newline at end of file diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 5970c16836c..2568b634afd 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -37,7 +37,7 @@ function init (ctx) { , require('./careportal')(ctx) , require('./pump')(ctx) , require('./openaps')(ctx) - , require('./xdrip-js')(ctx) + , require('./xdripjs')(ctx) , require('./loop')(ctx) , require('./override')(ctx) , require('./boluswizardpreview')(ctx) @@ -63,7 +63,7 @@ function init (ctx) { , require('./cob')(ctx) , require('./pump')(ctx) , require('./openaps')(ctx) - , require('./xdrip-js')(ctx) + , require('./xdripjs')(ctx) , require('./loop')(ctx) , require('./boluswizardpreview')(ctx) , require('./cannulaage')(ctx) diff --git a/lib/plugins/iob.js b/lib/plugins/iob.js index f9bf082d0f4..96bea03b3ff 100644 --- a/lib/plugins/iob.js +++ b/lib/plugins/iob.js @@ -243,21 +243,19 @@ function init(ctx) { }; - function alexaIOBIntentHandler (callback, slots, sbx) { + function virtAsstIOBIntentHandler (callback, slots, sbx) { - var message = translate('alexaIobIntent', { + var message = translate('virtAsstIobIntent', { params: [ - //preamble, getIob(sbx) ] }); - //preamble + + ' insulin on board'; - callback('Current IOB', message); + callback(translate('virtAsstTitleCurrentIOB'), message); } - function alexaIOBRollupHandler (slots, sbx, callback) { + function virtAsstIOBRollupHandler (slots, sbx, callback) { var iob = getIob(sbx); - var message = translate('alexaIob', { + var message = translate('virtAsstIob', { params: [iob] }); callback(null, {results: message, priority: 2}); @@ -265,26 +263,25 @@ function init(ctx) { function getIob(sbx) { if (sbx.properties.iob && sbx.properties.iob.iob !== 0) { - return translate('alexaIobUnits', { + return translate('virtAsstIobUnits', { params: [ utils.toFixed(sbx.properties.iob.iob) ] }); } - return translate('alexaNoInsulin'); + return translate('virtAsstNoInsulin'); } - iob.alexa = { + iob.virtAsst = { rollupHandlers: [{ rollupGroup: 'Status' , rollupName: 'current iob' - , rollupHandler: alexaIOBRollupHandler + , rollupHandler: virtAsstIOBRollupHandler }] , intentHandlers: [{ intent: 'MetricNow' - , routableSlot: 'metric' - , slots: ['iob', 'insulin on board'] - , intentHandler: alexaIOBIntentHandler + , metrics: ['iob', 'insulin on board'] + , intentHandler: virtAsstIOBIntentHandler }] }; diff --git a/lib/plugins/loop.js b/lib/plugins/loop.js index fc8dcbb3282..9b099dd188c 100644 --- a/lib/plugins/loop.js +++ b/lib/plugins/loop.js @@ -9,6 +9,7 @@ var levels = require('../levels'); function init (ctx) { var utils = require('../utils')(ctx); + var translate = ctx.language.translate; var loop = { name: 'loop' @@ -173,6 +174,81 @@ function init (ctx) { } }; + loop.getEventTypes = function getEventTypes (sbx) { + + var units = sbx.settings.units; + console.log('units', units); + + var reasonconf = []; + + if (sbx.data === undefined || sbx.data.profile === undefined || sbx.data.profile.data.length == 0) { + return []; + } + + let profile = sbx.data.profile.data[0]; + + if (profile.loopSettings === undefined || profile.loopSettings.overridePresets == undefined) { + return []; + } + + let presets = profile.loopSettings.overridePresets; + + for (var i = 0; i < presets.length; i++) { + let preset = presets[i] + reasonconf.push({ name: preset.name, displayName: preset.symbol + " " + preset.name, duration: preset.duration / 60}); + } + + var postLoopNotification = function (client, data, callback) { + + $.ajax({ + method: "POST" + , headers: client.headers() + , url: '/api/v2/notifications/loop' + , data: data + }) + .done(function () { + callback(); + }) + .fail(function (jqXHR) { + callback(jqXHR.responseText); + }); + } + + return [ + { + val: 'Temporary Override' + , name: 'Temporary Override' + , bg: false + , insulin: false + , carbs: false + , prebolus: false + , duration: true + , percent: false + , absolute: false + , profile: false + , split: false + , targets: false + , reasons: reasonconf + , submitHook: postLoopNotification + }, + { + val: 'Temporary Override Cancel' + , name: 'Temporary Override Cancel' + , bg: false + , insulin: false + , carbs: false + , prebolus: false + , duration: false + , percent: false + , absolute: false + , profile: false + , split: false + , targets: false + , submitHook: postLoopNotification + } + ]; + }; + loop.updateVisualisation = function updateVisualisation (sbx) { var prop = sbx.properties.loop; @@ -284,9 +360,9 @@ function init (ctx) { var iob = prop.lastLoop.iob; valueParts = valueParts.concat([ ', IOB: ' - + , sbx.roundInsulinForDisplayFormat(iob.iob) + 'U' - + , iob.basaliob ? ', Basal IOB ' + sbx.roundInsulinForDisplayFormat(iob.basaliob) + 'U' : '' ]); } @@ -431,7 +507,7 @@ function init (ctx) { } }; - function alexaForecastHandler (next, slots, sbx) { + function virtAsstForecastHandler (next, slots, sbx) { if (sbx.properties.loop.lastLoop.predicted) { var forecast = sbx.properties.loop.lastLoop.predicted.values; var max = forecast[0]; @@ -441,7 +517,7 @@ function init (ctx) { var startPrediction = moment(sbx.properties.loop.lastLoop.predicted.startDate); var endPrediction = startPrediction.clone().add(maxForecastIndex * 5, 'minutes'); if (endPrediction.valueOf() < sbx.time) { - next('Loop Forecast', 'Unable to forecast with the data that is available'); + next(translate('virtAsstTitleLoopForecast'), translate('virtAsstForecastUnavailable')); } else { for (var i = 1, len = forecast.slice(0, maxForecastIndex).length; i < len; i++) { if (forecast[i] > max) { @@ -451,35 +527,52 @@ function init (ctx) { min = forecast[i]; } } - var value = ''; + var response = ''; if (min === max) { - value = 'around ' + max; + response = translate('virtAsstLoopForecastAround', { + params: [ + max + , moment(endPrediction).from(moment(sbx.time)) + ] + }); } else { - value = 'between ' + min + ' and ' + max; + response = translate('virtAsstLoopForecastBetween', { + params: [ + min + , max + , moment(endPrediction).from(moment(sbx.time)) + ] + }); } - var response = 'According to the loop forecast you are expected to be ' + value + ' over the next ' + moment(endPrediction).from(moment(sbx.time)); - next('Loop Forecast', response); + next(translate('virtAsstTitleLoopForecast'), response); } } else { - next('Loop forecast', 'Loop plugin does not seem to be enabled'); + next(translate('virtAsstTitleLoopForecast'), translate('virtAsstUnknown')); } } - function alexaLastLoopHandler (next, slots, sbx) { - console.log(JSON.stringify(sbx.properties.loop.lastLoop)); - var response = 'The last successful loop was ' + moment(sbx.properties.loop.lastOkMoment).from(moment(sbx.time)); - next('Last loop', response); + function virtAsstLastLoopHandler (next, slots, sbx) { + if (sbx.properties.loop.lastLoop) { + console.log(JSON.stringify(sbx.properties.loop.lastLoop)); + var response = translate('virtAsstLastLoop', { + params: [ + moment(sbx.properties.loop.lastOkMoment).from(moment(sbx.time)) + ] + }); + next(translate('virtAsstTitleLastLoop'), response); + } else { + next(translate('virtAsstTitleLastLoop'), translate('virtAsstUnknown')); + } } - loop.alexa = { + loop.virtAsst = { intentHandlers: [{ intent: 'MetricNow' - , routableSlot: 'metric' - , slots: ['loop forecast', 'forecast'] - , intentHandler: alexaForecastHandler + , metrics: ['loop forecast', 'forecast'] + , intentHandler: virtAsstForecastHandler }, { intent: 'LastLoop' - , intentHandler: alexaLastLoopHandler + , intentHandler: virtAsstLastLoopHandler }] }; diff --git a/lib/plugins/openaps.js b/lib/plugins/openaps.js index a9c45db7e1b..59af42c0e61 100644 --- a/lib/plugins/openaps.js +++ b/lib/plugins/openaps.js @@ -4,6 +4,7 @@ var _ = require('lodash'); var moment = require('moment'); var times = require('../times'); var levels = require('../levels'); +var consts = require('../constants'); // var ALL_STATUS_FIELDS = ['status-symbol', 'status-label', 'iob', 'meal-assist', 'freq', 'rssi']; Unused variable @@ -54,8 +55,8 @@ function init (ctx) { , urgent: settings.urgent ? settings.urgent : 60 , enableAlerts: settings.enableAlerts , predIOBColor: settings.predIobColor ? settings.predIobColor : '#1e88e5' - , predCOBColor: settings.predCobColor ? settings.predCobColor : '#FB8C00FF' - , predACOBColor: settings.predAcobColor ? settings.predAcobColor : '#FB8C0080' + , predCOBColor: settings.predCobColor ? settings.predCobColor : '#FB8C00' + , predACOBColor: settings.predAcobColor ? settings.predAcobColor : '#FB8C00' , predZTColor: settings.predZtColor ? settings.predZtColor : '#00d2d2' , predUAMColor: settings.predUamColor ? settings.predUamColor : '#c9bd60' , colorPredictionLines: settings.colorPredictionLines @@ -349,8 +350,10 @@ function init (ctx) { function addSuggestion () { if (prop.lastSuggested) { var bg = prop.lastSuggested.bg; - if (sbx.data.profile.data[0].units === 'mmol') { - bg = Math.round(bg / 18 * 10) / 10; + var units = sbx.data.profile.getUnits(); + + if (units === 'mmol') { + bg = Math.round(bg / consts.MMOL_TO_MGDL * 10) / 10; } var valueParts = [ @@ -514,36 +517,41 @@ function init (ctx) { } }; - function alexaForecastHandler (next, slots, sbx) { + function virtAsstForecastHandler (next, slots, sbx) { if (sbx.properties.openaps && sbx.properties.openaps.lastEventualBG) { - var response = translate('alexaOpenAPSForecast', { + var response = translate('virtAsstOpenAPSForecast', { params: [ sbx.properties.openaps.lastEventualBG ] }); - next('Loop Forecast', response); + next(translate('virtAsstTitleOpenAPSForecast'), response); + } else { + next(translate('virtAsstTitleOpenAPSForecast'), translate('virtAsstUnknown')); } } - function alexaLastLoopHandler (next, slots, sbx) { - console.log(JSON.stringify(sbx.properties.openaps.lastLoopMoment)); - var response = translate('alexaLastLoop', { - params: [ - moment(sbx.properties.openaps.lastLoopMoment).from(moment(sbx.time)) - ] - }); - next('Last loop', response); + function virtAsstLastLoopHandler (next, slots, sbx) { + if (sbx.properties.openaps.lastLoopMoment) { + console.log(JSON.stringify(sbx.properties.openaps.lastLoopMoment)); + var response = translate('virtAsstLastLoop', { + params: [ + moment(sbx.properties.openaps.lastLoopMoment).from(moment(sbx.time)) + ] + }); + next(translate('virtAsstTitleLastLoop'), response); + } else { + next(translate('virtAsstTitleLastLoop'), translate('virtAsstUnknown')); + } } - openaps.alexa = { + openaps.virtAsst = { intentHandlers: [{ intent: 'MetricNow' - , routableSlot: 'metric' - , slots: ['openaps forecast', 'forecast'] - , intentHandler: alexaForecastHandler + , metrics: ['openaps forecast', 'forecast'] + , intentHandler: virtAsstForecastHandler }, { intent: 'LastLoop' - , intentHandler: alexaLastLoopHandler + , intentHandler: virtAsstLastLoopHandler }] }; diff --git a/lib/plugins/pluginbase.js b/lib/plugins/pluginbase.js index a6f0e77e455..0e2adad3586 100644 --- a/lib/plugins/pluginbase.js +++ b/lib/plugins/pluginbase.js @@ -10,7 +10,7 @@ function init (majorPills, minorPills, statusPills, bgStatus, tooltip) { var pluginBase = { }; pluginBase.forecastInfos = []; - pluginBase.forecastPoints = []; + pluginBase.forecastPoints = {}; function findOrCreatePill (plugin) { var container = null; @@ -84,9 +84,9 @@ function init (majorPills, minorPills, statusPills, bgStatus, tooltip) { }).join('
\n'); pill.mouseover(function pillMouseover (event) { - tooltip.transition().duration(200).style('opacity', .9); + tooltip.style('opacity', .9); - var windowWidth = $(tooltip).parent().parent().width(); + var windowWidth = $(tooltip.node()).parent().parent().width(); var left = event.pageX + TOOLTIP_WIDTH < windowWidth ? event.pageX : windowWidth - TOOLTIP_WIDTH - 10; tooltip.html(html) .style('left', left + 'px') @@ -94,9 +94,7 @@ function init (majorPills, minorPills, statusPills, bgStatus, tooltip) { }); pill.mouseout(function pillMouseout ( ) { - tooltip.transition() - .duration(200) - .style('opacity', 0); + tooltip.style('opacity', 0); }); } else { pill.off('mouseover'); @@ -113,7 +111,7 @@ function init (majorPills, minorPills, statusPills, bgStatus, tooltip) { }); pluginBase.forecastInfos.push(info); - pluginBase.forecastPoints = pluginBase.forecastPoints.concat(points); + pluginBase.forecastPoints[info.type] = points; }; return pluginBase; diff --git a/lib/plugins/pump.js b/lib/plugins/pump.js index 842d8536b6b..7e71c21e1b1 100644 --- a/lib/plugins/pump.js +++ b/lib/plugins/pump.js @@ -135,38 +135,58 @@ function init (ctx) { }); }; - function alexaReservoirHandler (next, slots, sbx) { - var response = translate('alexaReservoir', { + function virtAsstReservoirHandler (next, slots, sbx) { + var reservoir = sbx.properties.pump.pump.reservoir; + if (reservoir || reservoir === 0) { + var response = translate('virtAsstReservoir', { params: [ - sbx.properties.pump.pump.reservoir + reservoir ] - }); - next('Remaining insulin', response); + }); + next(translate('virtAsstTitlePumpReservoir'), response); + } else { + next(translate('virtAsstTitlePumpReservoir'), translate('virtAsstUnknown')); + } } - function alexaBatteryHandler (next, slots, sbx) { + function virtAsstBatteryHandler (next, slots, sbx) { var battery = _.get(sbx, 'properties.pump.data.battery'); if (battery) { - var response = translate('alexaPumpBattery', { + var response = translate('virtAsstPumpBattery', { params: [ battery.value, battery.unit ] }); - next('Pump battery', response); + next(translate('virtAsstTitlePumpBattery'), response); } else { - next(); + next(translate('virtAsstTitlePumpBattery'), translate('virtAsstUnknown')); } } - pump.alexa = { - intentHandlers:[{ - intent: 'InsulinRemaining', - intentHandler: alexaReservoirHandler - }, { - intent: 'PumpBattery', - intentHandler: alexaBatteryHandler - }] + pump.virtAsst = { + intentHandlers:[ + { + // backwards compatibility + intent: 'InsulinRemaining', + intentHandler: virtAsstReservoirHandler + } + , { + // backwards compatibility + intent: 'PumpBattery', + intentHandler: virtAsstBatteryHandler + } + , { + intent: 'MetricNow' + , metrics: ['pump reservoir'] + , intentHandler: virtAsstReservoirHandler + } + , { + intent: 'MetricNow' + , metrics: ['pump battery'] + , intentHandler: virtAsstBatteryHandler + } + ] }; function statusClass (level) { diff --git a/lib/plugins/rawbg.js b/lib/plugins/rawbg.js index f19e669f63b..3248126b046 100644 --- a/lib/plugins/rawbg.js +++ b/lib/plugins/rawbg.js @@ -106,17 +106,24 @@ function init (ctx) { return display; }; - function alexaRawBGHandler (next, slots, sbx) { - var response = 'Your raw bg is ' + sbx.properties.rawbg.mgdl; - next('Current Raw BG', response); + function virtAsstRawBGHandler (next, slots, sbx) { + if (sbx.properties.rawbg.mgdl) { + var response = translate('virtAsstRawBG', { + params: [ + sbx.properties.rawbg.mgdl + ] + }); + next(translate('virtAsstTitleRawBG'), response); + } else { + next(translate('virtAsstTitleRawBG'), translate('virtAsstUnknown')); + } } - rawbg.alexa = { + rawbg.virtAsst = { intentHandlers: [{ intent: 'MetricNow' - , routableSlot:'metric' - , slots:['raw bg', 'raw blood glucose'] - , intentHandler: alexaRawBGHandler + , metrics:['raw bg', 'raw blood glucose'] + , intentHandler: virtAsstRawBGHandler }] }; diff --git a/lib/plugins/timeago.js b/lib/plugins/timeago.js index 6f8989a4876..4b176764d45 100644 --- a/lib/plugins/timeago.js +++ b/lib/plugins/timeago.js @@ -7,6 +7,7 @@ var lastSuspendTime = new Date("1900-01-01"); function init(ctx) { var translate = ctx.language.translate; + var heartbeatMs = ctx.settings.heartbeat * 1000; var timeago = { name: 'timeago', @@ -64,20 +65,29 @@ function init(ctx) { }; timeago.checkStatus = function checkStatus(sbx) { - // Check if the app has been suspended; if yes, snooze data missing alarmn for 15 seconds var now = new Date(); var delta = now.getTime() - lastChecked.getTime(); lastChecked = now; - if (delta > 15 * 1000) { // Looks like we've been hibernating - lastSuspendTime = now; - } + function isHibernationDetected() { + if (sbx.runtimeEnvironment === 'client') { + if (delta > 15 * 1000) { // Looks like we've been hibernating + lastSuspendTime = now; + } - var timeSinceLastSuspended = now.getTime() - lastSuspendTime.getTime(); + var timeSinceLastSuspended = now.getTime() - lastSuspendTime.getTime(); - if (timeSinceLastSuspended < (10 * 1000)) { + return timeSinceLastSuspended < (10 * 1000); + } else if (sbx.runtimeEnvironment === 'server') { + return delta > 2 * heartbeatMs; + } else { + console.error('Cannot detect hibernation, because runtimeEnvironment is not detected from sbx.runtimeEnvironment:', sbx.runtimeEnvironment); + return false; + } + } + if (isHibernationDetected()) { console.log('Hibernation detected, suspending timeago alarm'); return 'current'; } diff --git a/lib/plugins/upbat.js b/lib/plugins/upbat.js index eda42a3901f..dc603054ecb 100644 --- a/lib/plugins/upbat.js +++ b/lib/plugins/upbat.js @@ -4,7 +4,8 @@ var _ = require('lodash'); var times = require('../times'); var levels = require('../levels'); -function init() { +function init(ctx) { + var translate = ctx.language.translate; var upbat = { name: 'upbat' @@ -221,16 +222,32 @@ function init() { }); }; - function alexaUploaderBatteryHandler (next, slots, sbx) { - var response = 'Your uploader battery is at ' + sbx.properties.upbat.display; - next('Uploader battery', response); + function virtAsstUploaderBatteryHandler (next, slots, sbx) { + if (sbx.properties.upbat.display) { + var response = translate('virtAsstUploaderBattery', { + params: [ + sbx.properties.upbat.display + ] + }); + next(translate('virtAsstTitleUploaderBattery'), response); + } else { + next(translate('virtAsstTitleUploaderBattery'), translate('virtAsstUnknown')); + } } - upbat.alexa = { - intentHandlers: [{ - intent: 'UploaderBattery' - , intentHandler: alexaUploaderBatteryHandler - }] + upbat.virtAsst = { + intentHandlers: [ + { + // for backwards compatibility + intent: 'UploaderBattery' + , intentHandler: virtAsstUploaderBatteryHandler + } + , { + intent: 'MetricNow' + , metrics: ['uploader battery'] + , intentHandler: virtAsstUploaderBatteryHandler + } + ] }; return upbat; diff --git a/lib/plugins/xdrip-js.js b/lib/plugins/xdripjs.js similarity index 95% rename from lib/plugins/xdrip-js.js rename to lib/plugins/xdripjs.js index d66f112cfcf..a36a42de2e0 100644 --- a/lib/plugins/xdrip-js.js +++ b/lib/plugins/xdripjs.js @@ -11,7 +11,7 @@ function init(ctx) { var lastStateNotification = null; var sensorState = { - name: 'xdrip-js' + name: 'xdripjs' , label: 'CGM Status' , pluginType: 'pill-status' }; @@ -25,7 +25,7 @@ function init(ctx) { if (firstPrefs) { firstPrefs = false; - console.info('xdrip-js Prefs:', prefs); + console.info('xdripjs Prefs:', prefs); } return prefs; @@ -154,8 +154,8 @@ function init(ctx) { }; } - message = 'CGM state: ' + sensorInfo.xdripjs.stateString; - title = 'CGM state: ' + sensorInfo.xdripjs.stateString; + message = 'CGM Transmitter state: ' + sensorInfo.xdripjs.stateString; + title = 'CGM Transmitter state: ' + sensorInfo.xdripjs.stateString; if (sensorInfo.xdripjs.state == 0x7) { // If it is a calibration request, only use INFO @@ -167,15 +167,15 @@ function init(ctx) { if (sensorInfo.xdripjs.voltagea && (sensorInfo.xdripjs.voltagea < prefs.warnBatV)) { sendNotification = true; - message = 'CGM Battery A Low Voltage: ' + sensorInfo.xdripjs.voltagea; - title = 'CGM Battery Low'; + message = 'CGM Transmitter Battery A Low Voltage: ' + sensorInfo.xdripjs.voltagea; + title = 'CGM Transmitter Battery Low'; result.level = levels.WARN; } if (sensorInfo.xdripjs.voltageb && (sensorInfo.xdripjs.voltageb < (prefs.warnBatV - 10))) { sendNotification = true; - message = 'CGM Battery B Low Voltage: ' + sensorInfo.xdripjs.voltageb; - title = 'CGM Battery Low'; + message = 'CGM Transmitter Battery B Low Voltage: ' + sensorInfo.xdripjs.voltageb; + title = 'CGM Transmitter Battery Low'; result.level = levels.WARN; } diff --git a/lib/profilefunctions.js b/lib/profilefunctions.js index c15860bab26..5826e6108b4 100644 --- a/lib/profilefunctions.js +++ b/lib/profilefunctions.js @@ -4,16 +4,20 @@ var _ = require('lodash'); var moment = require('moment-timezone'); var c = require('memory-cache'); var times = require('./times'); -var crypto = require('crypto'); - -var cacheTTL = 600; +var cacheTTL = 5000; var prevBasalTreatment = null; +var cache = new c.Cache(); function init (profileData) { var profile = {}; - var cache = new c.Cache(); + + profile.clear = function clear() { + cache.clear(); + profile.data = null; + prevBasalTreatment = null; + } profile.loadData = function loadData (profileData) { if (profileData && profileData.length) { @@ -71,6 +75,15 @@ function init (profileData) { profile.getValueByTime = function getValueByTime (time, valueType, spec_profile) { if (!time) { time = Date.now(); } + //round to the minute for better caching + var minuteTime = Math.round(time / 60000) * 60000; + var cacheKey = (minuteTime + valueType + spec_profile); + var returnValue = cache.get(cacheKey); + + if (returnValue) { + return returnValue; + } + // CircadianPercentageProfile support var timeshift = 0; var percentage = 100; @@ -83,16 +96,6 @@ function init (profileData) { var offset = timeshift % 24; time = time + offset * times.hours(offset).msecs; - //round to the minute for better caching - var minuteTime = Math.round(time / 60000) * 60000; - - var cacheKey = (minuteTime + valueType + spec_profile + profile.profiletreatments_hash); - var returnValue = cache.get(cacheKey); - - if (returnValue) { - return returnValue; - } - var valueContainer = profile.getCurrentProfile(time, spec_profile)[valueType]; // Assumes the timestamps are in UTC @@ -139,14 +142,28 @@ function init (profileData) { }; profile.getCurrentProfile = function getCurrentProfile (time, spec_profile) { - time = time || new Date().getTime(); + + time = time || Date.now(); + var minuteTime = Math.round(time / 60000) * 60000; + var cacheKey = ("profile" + minuteTime + spec_profile); + var returnValue = cache.get(cacheKey); + + if (returnValue) { + return returnValue; + } + var data = profile.hasData() ? profile.data[0] : null; var timeprofile = spec_profile || profile.activeProfileToTime(time); - return data && data.store[timeprofile] ? data.store[timeprofile] : {}; + returnValue = data && data.store[timeprofile] ? data.store[timeprofile] : {}; + + cache.put(cacheKey, returnValue, cacheTTL); + return returnValue; }; profile.getUnits = function getUnits (spec_profile) { - return profile.getCurrentProfile(null, spec_profile)['units']; + var pu = profile.getCurrentProfile(null, spec_profile)['units'] + ' '; + if (pu.toLowerCase().includes('mmol')) return 'mmol'; + return 'mgdl'; }; profile.getTimezone = function getTimezone (spec_profile) { @@ -202,9 +219,8 @@ function init (profileData) { }); profile.combobolustreatments = combobolustreatments || []; - profile.profiletreatments_hash = crypto.createHash('sha1').update(JSON.stringify(profile.profiletreatments)).digest('hex'); - profile.tempbasaltreatments_hash = crypto.createHash('sha1').update(JSON.stringify(profile.tempbasaltreatments)).digest('hex'); - profile.combobolustreatments_hash = crypto.createHash('sha1').update(JSON.stringify(profile.combobolustreatments)).digest('hex'); + + cache.clear(); }; profile.activeProfileToTime = function activeProfileToTime (time) { @@ -221,9 +237,10 @@ function init (profileData) { }; profile.activeProfileTreatmentToTime = function activeProfileTreatmentToTime (time) { - var cacheKey = 'profile' + time + profile.profiletreatments_hash; - //var returnValue = profile.timeValueCache[cacheKey]; - var returnValue; + + var minuteTime = Math.round(time / 60000) * 60000; + var cacheKey = 'profileCache' + minuteTime; + var returnValue = cache.get(cacheKey); if (returnValue) { return returnValue; @@ -310,7 +327,8 @@ function init (profileData) { profile.getTempBasal = function getTempBasal (time, spec_profile) { - var cacheKey = 'basal' + time + profile.tempbasaltreatments_hash + profile.combobolustreatments_hash + profile.profiletreatments_hash + spec_profile; + var minuteTime = Math.round(time / 60000) * 60000; + var cacheKey = 'basalCache' + minuteTime + spec_profile; var returnValue = cache.get(cacheKey); if (returnValue) { diff --git a/lib/report_plugins/calibrations.js b/lib/report_plugins/calibrations.js index 958dd06c906..baf16a47a27 100644 --- a/lib/report_plugins/calibrations.js +++ b/lib/report_plugins/calibrations.js @@ -146,20 +146,16 @@ calibrations.report = function report_calibrations (datastorage, sorteddaystosho calibration_context = charts.append('g'); // define the parts of the axis that aren't dependent on width or height - xScale2 = d3.scale.linear() + xScale2 = d3.scaleLinear() .domain([0, maxBG]); - yScale2 = d3.scale.linear() + yScale2 = d3.scaleLinear() .domain([0, 400000]); - var xAxis2 = d3.svg.axis() - .scale(xScale2) - .ticks(10) - .orient('bottom'); + var xAxis2 = d3.axisBottom(xScale2) + .ticks(10); - var yAxis2 = d3.svg.axis() - .scale(yScale2) - .orient('left'); + var yAxis2 = d3.axisLeft(yScale2); // get current data range var dataRange = [0, maxBG]; diff --git a/lib/report_plugins/daytoday.js b/lib/report_plugins/daytoday.js index 5f7983480c5..f4b5da7cf45 100644 --- a/lib/report_plugins/daytoday.js +++ b/lib/report_plugins/daytoday.js @@ -82,11 +82,12 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio var report_plugins = Nightscout.report_plugins; var scaledTreatmentBG = report_plugins.utils.scaledTreatmentBG; - var TOOLTIP_TRANS_MS = 300; - var padding = { top: 15, right: 22, bottom: 30, left: 35 }; var tddSum = 0; + var basalSum = 0; + var baseBasalSum = 0; + var bolusSum = 0; var carbsSum = 0; var proteinSum = 0; var fatSum = 0; @@ -97,22 +98,28 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio }); var tddAverage = tddSum / datastorage.alldays; + var basalAveragePercent = Math.round( (basalSum / datastorage.alldays) / tddAverage * 100); + var baseBasalAveragePercent = Math.round( (baseBasalSum / datastorage.alldays) / tddAverage * 100); + var bolusAveragePercent = Math.round( (bolusSum / datastorage.alldays) / tddAverage * 100); var carbsAverage = carbsSum / datastorage.alldays; var proteinAverage = proteinSum / datastorage.alldays; var fatAverage = fatSum / datastorage.alldays; - if (options.insulindistribution) - $('#daytodaycharts').append('

' + - '' + translate('TDD average') + ': ' + tddAverage.toFixed(1) + 'U ' + - '' + translate('Carbs average') + ': ' + carbsAverage.toFixed(0) + 'g ' + - '' + translate('Protein average') + ': ' + proteinAverage.toFixed(0) + 'g ' + - '' + translate('Fat average') + ': ' + fatAverage.toFixed(0) + 'g' - ); - - function timeTicks (n, i) { - var t12 = [ - '12am', '', '2am', '', '4am', '', '6am', '', '8am', '', '10am', '' - , '12pm', '', '2pm', '', '4pm', '', '6pm', '', '8pm', '', '10pm', '', '12am' + if (options.insulindistribution) { + var html = '

' + translate('TDD average') + ': ' + tddAverage.toFixed(1) + 'U  '; + html += '' + translate('Bolus average') + ': ' + bolusAveragePercent + '%  '; + html += '' + translate('Basal average') + ': ' + basalAveragePercent + '%  '; + html += '(' + translate('Base basal average:') + ' ' + baseBasalAveragePercent + '%)  '; + html += '' + translate('Carbs average') + ': ' + carbsAverage.toFixed(0) + 'g'; + html += '' + translate('Protein average') + ': ' + proteinAverage.toFixed(0) + 'g'; + html += '' + translate('Fat average') + ': ' + fatAverage.toFixed(0) + 'g'; + $('#daytodaycharts').append(html); + } + + function timeTicks(n,i) { + var t12 = [ + '12am', '', '2am', '', '4am', '', '6am', '', '8am', '', '10am', '', + '12pm', '', '2pm', '', '4pm', '', '6pm', '', '8pm', '', '10pm', '', '12am' ]; if (Nightscout.client.settings.timeFormat === 24) { return ('00' + i).slice(-2); @@ -170,37 +177,33 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio context = charts.append('g'); // define the parts of the axis that aren't dependent on width or height - xScale2 = d3.time.scale() + xScale2 = d3.scaleTime() .domain(d3.extent(data.sgv, dateFn)); if (options.scale === report_plugins.consts.SCALE_LOG) { - yScale2 = d3.scale.log() + yScale2 = d3.scaleLog() .domain([client.utils.scaleMgdl(options.basal ? 30 : 36), client.utils.scaleMgdl(420)]); } else { - yScale2 = d3.scale.linear() + yScale2 = d3.scaleLinear() .domain([client.utils.scaleMgdl(options.basal ? -40 : 36), client.utils.scaleMgdl(420)]); } // allow insulin to be negative (when plotting negative IOB) - yInsulinScale = d3.scale.linear() + yInsulinScale = d3.scaleLinear() .domain([-2 * options.maxInsulinValue, 2 * options.maxInsulinValue]); - yCarbsScale = d3.scale.linear() + yCarbsScale = d3.scaleLinear() .domain([0, options.maxCarbsValue * 1.25]); - yScaleBasals = d3.scale.linear(); + yScaleBasals = d3.scaleLinear(); - xAxis2 = d3.svg.axis() - .scale(xScale2) + xAxis2 = d3.axisBottom(xScale2) .tickFormat(timeTicks) - .ticks(24) - .orient('bottom'); + .ticks(24); - yAxis2 = d3.svg.axis() - .scale(yScale2) + yAxis2 = d3.axisLeft(yScale2) .tickFormat(d3.format('d')) - .tickValues(tickValues) - .orient('left'); + .tickValues(tickValues); // get current data range var dataRange = d3.extent(data.sgv, dateFn); @@ -294,7 +297,7 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio }) .on('mouseover', function(d) { if (options.openAps && d.openaps) { - client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9); + client.tooltip.style('opacity', .9); var text = 'BG: ' + d.openaps.suggested.bg + ', ' + d.openaps.suggested.reason + (d.openaps.suggested.mealAssist ? ' Meal Assist: ' + d.openaps.suggested.mealAssist : ''); @@ -602,13 +605,13 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio yScaleBasals.domain([basalMax, 0]); - var valueline = d3.svg.line() - .interpolate('step-after') + var valueline = d3.line() + .curve(d3.curveStepAfter) .x(function(d) { return xScale2(d.d) + padding.left; }) .y(function(d) { return yScaleBasals(d.b) + padding.top; }); - var area = d3.svg.area() - .interpolate('step-after') + var area = d3.area() + .curve(d3.curveStepAfter) .x(function(d) { return xScale2(d.d) + padding.left; }) .y0(yScaleBasals(0) + padding.top) .y1(function(d) { return yScaleBasals(d.b) + padding.top; }); @@ -735,7 +738,6 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio } if (treatment.carbs && options.carbs) { - var ic = profile.getCarbRatio(new Date(treatment.mills)); var label = ' ' + treatment.carbs + ' g'; if (treatment.protein) label += ' / ' + treatment.protein + ' g'; if (treatment.fat) label += ' / ' + treatment.fat + ' g'; @@ -932,9 +934,9 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio var height = 120; var radius = Math.min(width, height) / 2; - var color = d3.scale.ordinal().range([basalcolor, boluscolor]); + var color = d3.scaleOrdinal().range([basalcolor, boluscolor]); - var labelArc = d3.svg.arc() + var labelArc = d3.arc() .outerRadius(radius / 2) .innerRadius(radius / 2); @@ -946,10 +948,11 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio .attr('transform', 'translate(' + (width / 2) + ',' + (height / 2) + ')'); - var arc = d3.svg.arc() + var arc = d3.arc() + .innerRadius(0) .outerRadius(radius); - var pie = d3.layout.pie() + var pie = d3.pie() .value(function(d) { return d.count; }) @@ -981,7 +984,7 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio // Carbs pie chart - var carbscolor = d3.scale.ordinal().range(['red']); + var carbscolor = d3.scaleOrdinal().range(['red']); var carbsData = [ { label: translate('Carbs'), count: data.dailyCarbs } @@ -995,10 +998,10 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio .attr('transform', 'translate(' + (width / 2) + ',' + (height / 2) + ')'); - var carbsarc = d3.svg.arc() + var carbsarc = d3.arc() .outerRadius(radius * data.dailyCarbs / options.maxDailyCarbsValue); - var carbspie = d3.layout.pie() + var carbspie = d3.pie() .value(function(d) { return d.count; }) @@ -1030,6 +1033,9 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio } tddSum += totalDailyInsulin; + basalSum += totalBasalInsulin; + baseBasalSum += baseBasalInsulin; + bolusSum += bolusInsulin; carbsSum += data.dailyCarbs; proteinSum += data.dailyProtein; fatSum += data.dailyFat; @@ -1067,8 +1073,6 @@ daytoday.report = function report_daytoday (datastorage, sorteddaystoshow, optio } function hideTooltip () { - client.tooltip.transition() - .duration(TOOLTIP_TRANS_MS) - .style('opacity', 0); + client.tooltip.style('opacity', 0); } }; diff --git a/lib/report_plugins/glucosedistribution.js b/lib/report_plugins/glucosedistribution.js index 4a5e7dd31fb..f97bd034765 100644 --- a/lib/report_plugins/glucosedistribution.js +++ b/lib/report_plugins/glucosedistribution.js @@ -1,5 +1,7 @@ 'use strict'; +var consts = require('../constants'); + var glucosedistribution = { name: 'glucosedistribution' , label: 'Distribution' @@ -122,7 +124,7 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s $('#glucosedistribution-days').text(days + ' ' + translate('days total')); - for (var i = 0; i < 23; i++) { + for (var i = 0; i < 24; i++) { $('#glucosedistribution-' + i).unbind('click').click(onClick); enabledHours[i] = $('#glucosedistribution-' + i).is(':checked'); } @@ -146,6 +148,11 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s var glucose_data = [data[0]]; + if (data.length === 0) { + $('#glucosedistribution-days').text(translate('Result is empty')); + return; + } + // data cleaning pass 1 - add interpolated missing points for (i = 0; i <= data.length - 2; i++) { var entry = data[i]; @@ -169,7 +176,7 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s var bg = Math.floor(entry.bgValue + bgDelta * j); var t = new Date(entry.displayTime.getTime() + j * timePatch); var newEntry = { - sgv: displayUnits === 'mmol' ? bg / 18 : bg + sgv: displayUnits === 'mmol' ? bg / consts.MMOL_TO_MGDL : bg , bgValue: bg , displayTime: t }; @@ -212,7 +219,7 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s const interpolatedValue = prevEntry.bgValue + d; let newEntry = { - sgv: displayUnits === 'mmol' ? interpolatedValue / 18 : interpolatedValue + sgv: displayUnits === 'mmol' ? interpolatedValue / consts.MMOL_TO_MGDL : interpolatedValue , bgValue: interpolatedValue , displayTime: entry.displayTime }; @@ -292,7 +299,10 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s rangeExp = ' (>=' + options.targetHigh + ')'; } - $('' + translate(range) + rangeExp + ': ').appendTo(tr); + var rangeLabel = range; + if (rangeLabel == 'Normal') rangeLabel = 'In Range'; + + $('' + translate(rangeLabel) + rangeExp + ': ').appendTo(tr); $('' + r.readingspct + '%').appendTo(tr); $('' + r.rangeRecords.length + '').appendTo(tr); if (r.rangeRecords.length > 0) { @@ -425,11 +435,11 @@ glucosedistribution.report = function report_glucosedistribution (datastorage, s var unitString = ' mg/dl'; if (displayUnits == 'mmol') { - TDC = TDC / 18.0; - TDCHourly = TDCHourly / 18.0; + TDC = TDC / consts.MMOL_TO_MGDL; + TDCHourly = TDCHourly / consts.MMOL_TO_MGDL; unitString = ' mmol/L'; - RMS = Math.sqrt(RMSTotal / events) / 18; + RMS = Math.sqrt(RMSTotal / events) / consts.MMOL_TO_MGDL; } TDC = Math.round(TDC * 100) / 100; diff --git a/lib/report_plugins/utils.js b/lib/report_plugins/utils.js index 10daec32304..be6ba940003 100644 --- a/lib/report_plugins/utils.js +++ b/lib/report_plugins/utils.js @@ -1,5 +1,7 @@ 'use strict'; +var consts = require('../constants'); + var moment = window.moment; var utils = { }; @@ -69,7 +71,7 @@ utils.scaledTreatmentBG = function scaledTreatmentBG(treatment,data) { console.info('found mismatched glucose units, converting ' + treatment.units + ' into ' + client.settings.units, treatment); if (treatment.units === 'mmol') { //BG is in mmol and display in mg/dl - treatmentGlucose = Math.round(treatment.glucose * 18); + treatmentGlucose = Math.round(treatment.glucose * consts.MMOL_TO_MGDL); } else { //BG is in mg/dl and display in mmol treatmentGlucose = client.utils.scaleMgdl(treatment.glucose); diff --git a/lib/report_plugins/weektoweek.js b/lib/report_plugins/weektoweek.js index 3ff84a78639..8719af81542 100644 --- a/lib/report_plugins/weektoweek.js +++ b/lib/report_plugins/weektoweek.js @@ -79,8 +79,6 @@ weektoweek.report = function report_weektoweek(datastorage, sorteddaystoshow, op var client = Nightscout.client; var report_plugins = Nightscout.report_plugins; - var TOOLTIP_TRANS_MS = 300; - var padding = { top: 15, right: 22, bottom: 30, left: 35 }; var weekstoshow = [ ]; @@ -196,28 +194,24 @@ weektoweek.report = function report_weektoweek(datastorage, sorteddaystoshow, op context = charts.append('g'); // define the parts of the axis that aren't dependent on width or height - xScale2 = d3.time.scale() + xScale2 = d3.scaleTime() .domain(d3.extent(sgvData, dateFn)); if (options.weekscale === report_plugins.consts.SCALE_LOG) { - yScale2 = d3.scale.log() + yScale2 = d3.scaleLog() .domain([client.utils.scaleMgdl(36), client.utils.scaleMgdl(420)]); } else { - yScale2 = d3.scale.linear() + yScale2 = d3.scaleLinear() .domain([client.utils.scaleMgdl(36), client.utils.scaleMgdl(420)]); } - xAxis2 = d3.svg.axis() - .scale(xScale2) + xAxis2 = d3.axisBottom(xScale2) .tickFormat(timeTicks) - .ticks(24) - .orient('bottom'); + .ticks(24); - yAxis2 = d3.svg.axis() - .scale(yScale2) + yAxis2 = d3.axisLeft(yScale2) .tickFormat(d3.format('d')) - .tickValues(tickValues) - .orient('left'); + .tickValues(tickValues); // get current data range var dataRange = d3.extent(sgvData, dateFn); @@ -326,8 +320,6 @@ weektoweek.report = function report_weektoweek(datastorage, sorteddaystoshow, op } function hideTooltip ( ) { - client.tooltip.transition() - .duration(TOOLTIP_TRANS_MS) - .style('opacity', 0); + client.tooltip.style('opacity', 0); } }; diff --git a/lib/sandbox.js b/lib/sandbox.js index ceac9a3fe29..4bcee3b40e4 100644 --- a/lib/sandbox.js +++ b/lib/sandbox.js @@ -44,6 +44,7 @@ function init () { sbx.serverInit = function serverInit (env, ctx) { reset(); + sbx.runtimeEnvironment = 'server'; sbx.time = Date.now(); sbx.settings = env.settings; sbx.data = ctx.ddata.clone(); @@ -83,6 +84,7 @@ function init () { sbx.clientInit = function clientInit (ctx, time, data) { reset(); + sbx.runtimeEnvironment = 'client'; sbx.settings = ctx.settings; sbx.showPlugins = ctx.settings.showPlugins; sbx.time = time; @@ -96,7 +98,7 @@ function init () { if (sbx.pluginBase) { sbx.pluginBase.forecastInfos = []; - sbx.pluginBase.forecastPoints = []; + sbx.pluginBase.forecastPoints = {}; } sbx.extendedSettings = { empty: true }; diff --git a/lib/server/bootevent.js b/lib/server/bootevent.js index 9e53c4f77ac..2bb63ad78f0 100644 --- a/lib/server/bootevent.js +++ b/lib/server/bootevent.js @@ -7,8 +7,8 @@ var UPDATE_THROTTLE = 5000; function boot (env, language) { ////////////////////////////////////////////////// - // Check Node version. - // Latest Node 8 LTS and Latest Node 10 LTS are recommended and supported. + // Check Node version. + // Latest Node 8 LTS and Latest Node 10 LTS are recommended and supported. // Latest Node version on Azure is tolerated, but not recommended // Latest Node (non LTS) version works, but is not recommended // Older Node versions or Node versions with known security issues will not work. @@ -34,7 +34,7 @@ function boot (env, language) { else if ( semver.eq(nodeVersion, '10.15.2')) { //Latest Node version on Azure is tolerated, but not recommended console.log('WARNING: Node version v10.15.2 and Microsoft Azure are not recommended.'); - console.log('WARNING: Please migrate to another hosting provider. Your Node version is outdated and insecure'); + console.log('WARNING: Please migrate to another hosting provider. Your Node version is outdated and insecure'); next(); } else if ( semver.satisfies(nodeVersion, '^12.6.0')) { @@ -42,7 +42,7 @@ function boot (env, language) { console.debug('Node version ' + nodeVersion + ' is not a LTS version. Not recommended. Not supported'); next(); } else { - // Other versions will not start + // Other versions will not start console.log( 'ERROR: Node version ' + nodeVersion + ' is not supported. Please use a secure LTS version or upgrade your Node'); process.exit(1); } @@ -115,7 +115,7 @@ function boot (env, language) { } else { //TODO assume mongo for now, when there are more storage options add a lookup require('../storage/mongo-storage')(env, function ready(err, store) { - // FIXME, error is always null, if there is an error, the storage.js will throw an exception + // FIXME, error is always null, if there is an error, the index.js will throw an exception console.log('Mongo Storage system ready'); ctx.store = store; @@ -164,6 +164,7 @@ function boot (env, language) { ctx.pushover = require('../plugins/pushover')(env); ctx.maker = require('../plugins/maker')(env); ctx.pushnotify = require('./pushnotify')(env, ctx); + ctx.loop = require('./loop')(env, ctx); ctx.activity = require('./activity')(env, ctx); ctx.entries = require('./entries')(env, ctx); @@ -182,6 +183,10 @@ function boot (env, language) { ctx.alexa = require('../plugins/alexa')(env, ctx); } + if (env.settings.isEnabled('googlehome')) { + ctx.googleHome = require('../plugins/googlehome')(env, ctx); + } + next( ); } diff --git a/lib/server/loop.js b/lib/server/loop.js new file mode 100644 index 00000000000..6a04fcd73aa --- /dev/null +++ b/lib/server/loop.js @@ -0,0 +1,109 @@ +'use strict'; + +const apn = require('apn'); + +function init (env, ctx) { + + function loop () { + return loop; + } + + loop.sendNotification = function sendNotification (data, remoteAddress, completion) { + if (env.extendedSettings.loop.apnsKey === undefined || env.extendedSettings.loop.apnsKey.length == 0) { + completion("Loop notification failed: LOOP_APNS_KEY not set."); + return; + } + + if (env.extendedSettings.loop.apnsKeyId === undefined || env.extendedSettings.loop.apnsKeyId.length == 0) { + completion("Loop notification failed: LOOP_APNS_KEY_ID not set."); + return; + } + + if (env.extendedSettings.loop.developerTeamId === undefined || env.extendedSettings.loop.developerTeamId.length != 10) { + completion("Loop notification failed: LOOP_DEVELOPER_TEAM_ID not set."); + return; + } + + if (env.extendedSettings.loop.developerTeamId === undefined || env.extendedSettings.loop.developerTeamId.length != 10) { + completion("Loop notification failed: LOOP_DEVELOPER_TEAM_ID not set."); + return; + } + + if (ctx.ddata.profiles === undefined || ctx.ddata.profiles.length < 1 || ctx.ddata.profiles[0].loopSettings === undefined) { + completion("Loop notification failed: Could not find loopSettings in profile."); + return; + } + + let loopSettings = ctx.ddata.profiles[0].loopSettings; + + if (loopSettings.deviceToken === undefined) { + completion("Loop notification failed: Could not find deviceToken in loopSettings."); + return; + } + + if (loopSettings.bundleIdentifier === undefined) { + completion("Loop notification failed: Could not find bundleIdentifier in loopSettings."); + return; + } + + var options = { + token: { + key: env.extendedSettings.loop.apnsKey + , keyId: env.extendedSettings.loop.apnsKeyId + , teamId: env.extendedSettings.loop.developerTeamId + }, + production: env.extendedSettings.loop.pushServerEnvironment === "production" + }; + + var provider = new apn.Provider(options); + + var payload = { + 'remote-address': remoteAddress, + 'notes': data.notes, + 'entered-by': data.enteredBy + }; + var alert; + if (data.eventType === 'Temporary Override Cancel') { + payload["cancel-temporary-override"] = "true"; + alert = "Cancel Temporary Override"; + } else if (data.eventType === 'Temporary Override') { + payload["override-name"] = data.reason; + alert = data.reasonDisplay + " Temporary Override"; + } else { + completion("Loop notification failed: Unhandled event type:", data.eventType); + return; + } + + if (data.notes !== undefined && data.notes.length > 0) { + alert += " - " + data.notes + } + + if (data.enteredBy !== undefined && data.enteredBy.length > 0) { + alert += " - " + data.enteredBy + } + + let notification = new apn.Notification(); + notification.alert = alert; + notification.topic = loopSettings.bundleIdentifier; + notification.contentAvailable = 1; + notification.expiry = Math.round((Date.now() / 1000)) + 60 * 5; // Allow this to enact within 5 minutes. + notification.payload = payload; + + if (data.duration && parseInt(data.duration) > 0) { + notification.payload["override-duration-minutes"] = parseInt(data.duration); + } + + provider.send(notification, [loopSettings.deviceToken]).then( (response) => { + if (response.sent && response.sent.length > 0) { + completion(); + } else { + console.log("APNs delivery failed:", response.failed) + completion("APNs delivery failed: " + response.failed[0].response.reason); + } + }); + }; + + return loop(); +} + +module.exports = init; diff --git a/lib/server/pushnotify.js b/lib/server/pushnotify.js index ae30e67d7f0..48d5be11078 100644 --- a/lib/server/pushnotify.js +++ b/lib/server/pushnotify.js @@ -38,7 +38,7 @@ function init (env, ctx) { key = notifyToHash(notify); } } - + notify.key = key; if (recentlySent.get(key)) { diff --git a/lib/server/websocket.js b/lib/server/websocket.js index fb0aa38b349..afced8bfb37 100644 --- a/lib/server/websocket.js +++ b/lib/server/websocket.js @@ -83,6 +83,7 @@ function init (env, ctx, server) { read: false , write: false , write_treatment: false + , error: true }); } @@ -440,27 +441,20 @@ function init (env, ctx, server) { if (socketAuthorization.read) { socket.join('DataReceivers'); - var filterTreatments = false; var msecHistory = times.hours(history).msecs; // if `from` is received, it's a reconnection and full data is not needed if (from && from > 0) { - filterTreatments = true; msecHistory = Math.min(new Date().getTime() - from, msecHistory); } - // send all data upon new connection - if (lastData && lastData.splitRecent) { - var split = lastData.splitRecent(Date.now(), times.hours(3).msecs, msecHistory, filterTreatments); + + if (lastData && lastData.dataWithRecentStatuses) { + let data = lastData.dataWithRecentStatuses(); + if (message.status) { - split.first.status = status(split.first.profiles); + data.status = status(data.profiles); } - //send out first chunk - socket.emit('dataUpdate', split.first); - - //then send out the rest - setTimeout(function sendTheRest() { - split.rest.delta = true; - socket.emit('dataUpdate', split.rest); - }, 500); + + socket.emit('dataUpdate', data); } } console.log(LOG_WS + 'Authetication ID: ', socket.client.id, ' client: ', clientType, ' history: ' + history); @@ -520,6 +514,10 @@ function init (env, ctx, server) { start( ); listeners( ); + if (ctx.storageSocket) { + ctx.storageSocket.init(io); + } + return websocket(); } diff --git a/lib/settings.js b/lib/settings.js index 2d16abd0f2d..c2497ed66d5 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -2,11 +2,12 @@ var _ = require('lodash'); var levels = require('./levels'); +var constants = require('./constants.json'); function init () { var settings = { - units: 'mg/dL' + units: 'mg/dl' , timeFormat: 12 , nightMode: false , editMode: true @@ -47,8 +48,9 @@ function init () { , secureHstsHeaderIncludeSubdomains: false , secureHstsHeaderPreload: false , secureCsp: false - , showClockClosebutton: true , deNormalizeDates: false + , showClockDelta: false + , showClockLastTime: false }; var valueMappers = { @@ -69,8 +71,13 @@ function init () { , insecureUseHttp: mapTruthy , secureHstsHeader: mapTruthy , secureCsp: mapTruthy - , showClockClosebutton: mapTruthy , deNormalizeDates: mapTruthy + , showClockDelta: mapTruthy + , showClockLastTime: mapTruthy + , bgHigh: mapNumber + , bgLow: mapNumber + , bgTargetTop: mapNumber + , bgTargetBottom: mapNumber }; function mapNumberArray (value) { @@ -93,6 +100,11 @@ function init () { return value; } + if (typeof value === 'string' && isNaN(value)) { + const decommaed = value.replace(',','.'); + if (!isNaN(decommaed)) { value = decommaed; } + } + if (isNaN(value)) { return value; } else { @@ -208,6 +220,14 @@ function init () { thresholds.bgTargetBottom = Number(thresholds.bgTargetBottom); thresholds.bgLow = Number(thresholds.bgLow); + // Do not convert for old installs that have these set in mg/dl + if (settings.units.toLowerCase().includes('mmol') && thresholds.bgHigh < 50) { + thresholds.bgHigh = Math.round(thresholds.bgHigh * constants.MMOL_TO_MGDL); + thresholds.bgTargetTop = Math.round(thresholds.bgTargetTop * constants.MMOL_TO_MGDL); + thresholds.bgTargetBottom = Math.round(thresholds.bgTargetBottom * constants.MMOL_TO_MGDL); + thresholds.bgLow = Math.round(thresholds.bgLow * constants.MMOL_TO_MGDL); + } + verifyThresholds(); adjustShownPlugins(); } diff --git a/lib/units.js b/lib/units.js index f548d55744e..5eb36c10950 100644 --- a/lib/units.js +++ b/lib/units.js @@ -1,11 +1,13 @@ 'use strict'; +var consts = require('./constants'); + function mgdlToMMOL(mgdl) { - return (Math.round((mgdl / 18) * 10) / 10).toFixed(1); + return (Math.round((mgdl / consts.MMOL_TO_MGDL) * 10) / 10).toFixed(1); } function mmolToMgdl(mgdl) { - return Math.round(mgdl * 18); + return Math.round(mgdl * consts.MMOL_TO_MGDL); } function configure() { diff --git a/lib/utils.js b/lib/utils.js index fe1778f8120..083c4284846 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -39,7 +39,8 @@ function init(ctx) { return '0'; } var mult = Math.pow(10,digits); - var fixed = Math.sign(value) * Math.round(Math.abs(value)*mult) / mult + var fixed = Math.sign(value) * Math.round(Math.abs(value)*mult) / mult; + if (isNaN(fixed)) return '0'; return String(fixed); }; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 1a9c1dd716b..99dbaa768e3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "0.12.5", + "version": "13.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1701,6 +1701,33 @@ } } }, + "apn": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/apn/-/apn-2.2.0.tgz", + "integrity": "sha512-YIypYzPVJA9wzNBLKZ/mq2l1IZX/2FadPvwmSv4ZeR0VH7xdNITQ6Pucgh0Uw6ZZKC+XwheaJ57DFZAhJ0FvPg==", + "requires": { + "debug": "^3.1.0", + "http2": "https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz", + "jsonwebtoken": "^8.1.0", + "node-forge": "^0.7.1", + "verror": "^1.10.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "append-transform": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", @@ -2023,6 +2050,11 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -2383,9 +2415,9 @@ }, "dependencies": { "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2404,9 +2436,9 @@ } }, "yallist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" } } }, @@ -2533,6 +2565,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, "check-types": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz", @@ -2641,9 +2678,9 @@ } }, "chownr": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", - "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz", + "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==" }, "chrome-trace-event": { "version": "1.0.2", @@ -3061,6 +3098,11 @@ } } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", @@ -3138,14 +3180,273 @@ } }, "cyclist": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", - "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", + "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=" }, "d3": { - "version": "3.5.17", - "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", - "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=" + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-5.12.0.tgz", + "integrity": "sha512-flYVMoVuhPFHd9zVCe2BxIszUWqBcd5fvQGMNRmSiBrgdnh6Vlruh60RJQTouAK9xPbOB0plxMvBm4MoyODXNg==", + "requires": { + "d3-array": "1", + "d3-axis": "1", + "d3-brush": "1", + "d3-chord": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-contour": "1", + "d3-dispatch": "1", + "d3-drag": "1", + "d3-dsv": "1", + "d3-ease": "1", + "d3-fetch": "1", + "d3-force": "1", + "d3-format": "1", + "d3-geo": "1", + "d3-hierarchy": "1", + "d3-interpolate": "1", + "d3-path": "1", + "d3-polygon": "1", + "d3-quadtree": "1", + "d3-random": "1", + "d3-scale": "2", + "d3-scale-chromatic": "1", + "d3-selection": "1", + "d3-shape": "1", + "d3-time": "1", + "d3-time-format": "2", + "d3-timer": "1", + "d3-transition": "1", + "d3-voronoi": "1", + "d3-zoom": "1" + } + }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-axis": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", + "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" + }, + "d3-brush": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.3.tgz", + "integrity": "sha512-v8bbYyCFKjyCzFk/tdWqXwDykY8YWqhXYjcYxfILIit085VZOpj4XJKOMccTsvWxgzSLMJQg5SiqHjslsipEDg==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "d3-chord": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", + "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", + "requires": { + "d3-array": "1", + "d3-path": "1" + } + }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "d3-color": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.0.tgz", + "integrity": "sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg==" + }, + "d3-contour": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", + "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", + "requires": { + "d3-array": "^1.1.1" + } + }, + "d3-dispatch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.5.tgz", + "integrity": "sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g==" + }, + "d3-drag": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.4.tgz", + "integrity": "sha512-ICPurDETFAelF1CTHdIyiUM4PsyZLaM+7oIBhmyP+cuVjze5vDZ8V//LdOFjg0jGnFIZD/Sfmk0r95PSiu78rw==", + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "d3-dsv": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.1.1.tgz", + "integrity": "sha512-1EH1oRGSkeDUlDRbhsFytAXU6cAmXFzc52YUe6MRlPClmWb85MP1J5x+YJRzya4ynZWnbELdSAvATFW/MbxaXw==", + "requires": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + } + }, + "d3-ease": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.5.tgz", + "integrity": "sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ==" + }, + "d3-fetch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.1.2.tgz", + "integrity": "sha512-S2loaQCV/ZeyTyIF2oP8D1K9Z4QizUzW7cWeAOAS4U88qOt3Ucf6GsmgthuYSdyB2HyEm4CeGvkQxWsmInsIVA==", + "requires": { + "d3-dsv": "1" + } + }, + "d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "d3-format": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.1.tgz", + "integrity": "sha512-TUswGe6hfguUX1CtKxyG2nymO+1lyThbkS1ifLX0Sr+dOQtAD5gkrffpHnx+yHNKUZ0Bmg5T4AjUQwugPDrm0g==" + }, + "d3-geo": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.6.tgz", + "integrity": "sha512-z0J8InXR9e9wcgNtmVnPTj0TU8nhYT6lD/ak9may2PdKqXIeHUr8UbFLoCtrPYNsjv6YaLvSDQVl578k6nm7GA==", + "requires": { + "d3-array": "1" + } + }, + "d3-hierarchy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz", + "integrity": "sha512-L+GHMSZNwTpiq4rt9GEsNcpLa4M96lXMR8M/nMG9p5hBE0jy6C+3hWtyZMenPQdwla249iJy7Nx0uKt3n+u9+w==" + }, + "d3-interpolate": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.3.2.tgz", + "integrity": "sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.8.tgz", + "integrity": "sha512-J6EfUNwcMQ+aM5YPOB8ZbgAZu6wc82f/0WFxrxwV6Ll8wBwLaHLKCqQ5Imub02JriCVVdPjgI+6P3a4EWJCxAg==" + }, + "d3-polygon": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.5.tgz", + "integrity": "sha512-RHhh1ZUJZfhgoqzWWuRhzQJvO7LavchhitSTHGu9oj6uuLFzYZVeBzaWTQ2qSO6bz2w55RMoOCf0MsLCDB6e0w==" + }, + "d3-quadtree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.6.tgz", + "integrity": "sha512-NUgeo9G+ENQCQ1LsRr2qJg3MQ4DJvxcDNCiohdJGHt5gRhBW6orIB5m5FJ9kK3HNL8g9F4ERVoBzcEwQBfXWVA==" + }, + "d3-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", + "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" + }, + "d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "requires": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "d3-selection": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.0.tgz", + "integrity": "sha512-EYVwBxQGEjLCKF2pJ4+yrErskDnz5v403qvAid96cNdCMr8rmCYfY5RGzWz24mdIbxmDf6/4EAH+K9xperD5jg==" + }, + "d3-shape": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.5.tgz", + "integrity": "sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "d3-time-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.1.3.tgz", + "integrity": "sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==", + "requires": { + "d3-time": "1" + } + }, + "d3-timer": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.9.tgz", + "integrity": "sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg==" + }, + "d3-transition": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.2.0.tgz", + "integrity": "sha512-VJ7cmX/FPIPJYuaL2r1o1EMHLttvoIuZhhuAlRoOxDzogV8iQS6jYulDm3xEU3TqL80IZIhI551/ebmCMrkvhw==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } }, "dashdash": { "version": "1.14.1", @@ -3603,6 +3904,11 @@ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "dev": true }, + "es6-promisify": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-6.0.2.tgz", + "integrity": "sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -4998,9 +5304,9 @@ } }, "handlebars": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.2.tgz", - "integrity": "sha512-cIv17+GhL8pHHnRJzGu2wwcthL5sb8uDKBHvZ2Dtu5s1YNt0ljbzKbamnc+gr69y7bzwQiBdr5+hOpRd5pnOdg==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -5278,6 +5584,10 @@ "sshpk": "^1.7.0" } }, + "http2": { + "version": "https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz", + "integrity": "sha512-ad4u4I88X9AcUgxCRW3RLnbh7xHWQ1f5HbrXa7gEy2x4Xgq+rq+auGx5I+nUDE2YYuqteGIlbxrwQXkIaYTfnQ==" + }, "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -6332,6 +6642,16 @@ "object-visit": "^1.0.0" } }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "requires": { + "charenc": "~0.0.1", + "crypt": "~0.0.1", + "is-buffer": "~1.1.1" + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -8045,6 +8365,11 @@ "lodash": "^4.17.15" } }, + "node-forge": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", + "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" + }, "node-libs-browser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", @@ -8489,8 +8814,7 @@ "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "p-defer": { "version": "1.0.0", @@ -8566,11 +8890,11 @@ "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==" }, "parallel-transform": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", - "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "requires": { - "cyclist": "~0.2.2", + "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" } @@ -8722,6 +9046,17 @@ "sha.js": "^2.4.8" } }, + "pem": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.3.tgz", + "integrity": "sha512-Q+AMVMD3fzeVvZs5PHeI+pVt0hgZY2fjhkliBW43qyONLgCXPVk1ryim43F9eupHlNGLJNT5T/NNrzhUdiC5Zg==", + "requires": { + "es6-promisify": "^6.0.0", + "md5": "^2.2.1", + "os-tmpdir": "^1.0.1", + "which": "^1.3.1" + } + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -8962,9 +9297,9 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "pushover-notifications": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pushover-notifications/-/pushover-notifications-1.2.0.tgz", - "integrity": "sha512-Da2XgHDDq9ZU4idbIx5Y9N4kCsHVgeeHViHK2wxdtdkdP58OvrsKCqpLZnr5nS+I4/PphjTORGSVzwMV2UaPLg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pushover-notifications/-/pushover-notifications-1.2.1.tgz", + "integrity": "sha512-FEPbbEhKPDw4PP/e4irEEv1gRmHvt2rulpsvj9OaWTBLWuTf0qBEuaydOsYnQdXS7zq0fAX/ptsj5/BqbKrcUw==" }, "qs": { "version": "6.5.2", @@ -9433,6 +9768,11 @@ "aproba": "^1.1.1" } }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, "rxjs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", @@ -9530,9 +9870,9 @@ } }, "serialize-javascript": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.8.0.tgz", - "integrity": "sha512-3tHgtF4OzDmeKYj6V9nSyceRS0UJ3C7VqyD2Yj28vC/z2j6jG5FmFGahOKMD9CrglxTm3tETr87jEypaYV8DUg==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", + "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==" }, "serve-static": { "version": "1.14.1", @@ -10425,9 +10765,9 @@ } }, "stream-shift": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", - "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, "string-width": { "version": "4.1.0", @@ -10662,15 +11002,15 @@ } }, "terser-webpack-plugin": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz", - "integrity": "sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", + "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", "requires": { "cacache": "^12.0.2", "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", "schema-utils": "^1.0.0", - "serialize-javascript": "^1.7.0", + "serialize-javascript": "^2.1.2", "source-map": "^0.6.1", "terser": "^4.1.2", "webpack-sources": "^1.4.0", @@ -10723,9 +11063,9 @@ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "terser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.2.0.tgz", - "integrity": "sha512-6lPt7lZdZ/13icQJp8XasFOwZjFJkxFFIb/N1fhYEQNoNI3Ilo3KABZ9OocZvZoB39r6SiIk/0+v/bt8nZoSeA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.2.tgz", + "integrity": "sha512-Uufrsvhj9O1ikwgITGsZ5EZS6qPokUOkCegS7fYOdGTv+OA90vndUbU6PEjr5ePqHfNUbGyMO7xyIZv2MhsALQ==", "requires": { "commander": "^2.20.0", "source-map": "~0.6.1", diff --git a/package.json b/package.json index 39f4d2e2bfa..88e92a6f4ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "0.12.5", + "version": "13.0.0", "description": "Nightscout acts as a web-based CGM (Continuous Glucose Montinor) to allow multiple caregivers to remotely view a patients glucose data in realtime.", "license": "AGPL-3.0", "author": "Nightscout Team", @@ -27,7 +27,8 @@ }, "scripts": { "start": "node server.js", - "test": "env-cmd ./test.env mocha --exit tests/*.test.js", + "test": "env-cmd ./my.test.env mocha --exit tests/*.test.js", + "test-ci": "env-cmd ./ci.test.env mocha --exit tests/*.test.js", "env": "env", "postinstall": "webpack --mode production --config webpack.config.js && npm run-script update-buster", "bundle": "webpack --mode production --config webpack.config.js && npm run-script update-buster", @@ -38,6 +39,7 @@ "dev": "env-cmd ./my.env nodemon server.js 0.0.0.0", "prod": "env-cmd ./my.prod.env node server.js 0.0.0.0" }, + "main": "server.js", "config": { "blanket": { "pattern": [ @@ -59,15 +61,17 @@ "dependencies": { "@babel/core": "^7.5.5", "@babel/preset-env": "^7.5.5", + "apn": "^2.2.0", "async": "^0.9.2", "babel-loader": "^8.0.6", + "base64url": "^3.0.1", "body-parser": "^1.19.0", "bootevent": "0.0.1", "braces": "^3.0.2", "compression": "^1.7.4", "css-loader": "^1.0.1", "cssmin": "^0.4.3", - "d3": "^3.5.17", + "d3": "^5.12.0", "ejs": "^2.6.2", "errorhandler": "^1.5.1", "event-stream": "3.3.4", @@ -96,7 +100,8 @@ "mongomock": "^0.1.2", "node-cache": "^4.2.1", "parse-duration": "^0.1.1", - "pushover-notifications": "^1.2.0", + "pem": "^1.14.3", + "pushover-notifications": "^1.2.1", "random-token": "0.0.8", "request": "^2.88.0", "semver": "^6.3.0", @@ -109,6 +114,7 @@ "swagger-ui-express": "^4.1.2", "terser": "^3.17.0", "traverse": "^0.6.6", + "uuid": "^3.3.2", "webpack": "^4.39.2", "webpack-cli": "^3.3.7" }, diff --git a/static/css/main.css b/static/css/main.css index 78cf7ae4c97..7c2e25837cb 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -436,9 +436,9 @@ a, a:visited, a:link { font-size: 12px; } -@media (max-width: 800px) { +@media (max-width: 1000px) { .bgStatus { - width: 300px; + width: 450px; } .bgButton { diff --git a/static/images/browserstack-logo.png b/static/images/browserstack-logo.png new file mode 100644 index 00000000000..765fea8fe1f Binary files /dev/null and b/static/images/browserstack-logo.png differ diff --git a/static/robots.txt b/static/robots.txt index 1f53798bb4f..68ad19bb9f2 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,2 +1,2 @@ -User-agent: * -Disallow: / +User-agent: Browsershots +Disallow: diff --git a/swagger.json b/swagger.json index 1a9c24c985b..385f73c068f 100755 --- a/swagger.json +++ b/swagger.json @@ -8,7 +8,7 @@ "info": { "title": "Nightscout API", "description": "Own your DData with the Nightscout API", - "version": "0.12.5", + "version": "13.0.0", "license": { "name": "AGPL 3", "url": "https://www.gnu.org/licenses/agpl.txt" diff --git a/swagger.yaml b/swagger.yaml index 3469ed0c7a8..bdb74b652b9 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -4,7 +4,7 @@ servers: info: title: Nightscout API description: Own your DData with the Nightscout API - version: 0.12.5 + version: 13.0.0 license: name: AGPL 3 url: 'https://www.gnu.org/licenses/agpl.txt' diff --git a/tests/admintools.test.js b/tests/admintools.test.js index bbede54156a..0b95acdf6c1 100644 --- a/tests/admintools.test.js +++ b/tests/admintools.test.js @@ -138,7 +138,7 @@ describe('admintools', function ( ) { if (url.indexOf('status.json') > -1) { fn(serverSettings); } else { - fn({message: 'OK'}); + fn({message: {message: 'OK'}}); } return self.$.ajax(); }, @@ -153,7 +153,9 @@ describe('admintools', function ( ) { var d3 = require('d3'); //disable all d3 transitions so most of the other code can run with jsdom - d3.timer = function mockTimer() { }; + //d3.timer = function mockTimer() { }; + let timer = d3.timer(function mockTimer() { }); + timer.stop(); var cookieStorageType = self.localStorage._type diff --git a/tests/api.devicestatus.test.js b/tests/api.devicestatus.test.js index a618db49056..34a0908610e 100644 --- a/tests/api.devicestatus.test.js +++ b/tests/api.devicestatus.test.js @@ -56,6 +56,7 @@ describe('Devicestatus API', function ( ) { request(self.app) .get('/api/devicestatus/') .query('find[created_at][$gte]=2018-12-16') + .query('find[created_at][$lte]=2018-12-17') .set('api-secret', self.env.api_secret || '') .expect(200) .expect(function (response) { diff --git a/tests/api.root.test.js b/tests/api.root.test.js new file mode 100644 index 00000000000..d2c3609a117 --- /dev/null +++ b/tests/api.root.test.js @@ -0,0 +1,42 @@ +'use strict'; + +const request = require('supertest'); +require('should'); + +describe('Root REST API', function() { + const self = this + , instance = require('./fixtures/api/instance') + , semver = require('semver') + ; + + this.timeout(15000); + + before(async () => { + self.instance = await instance.create({}); + self.app = self.instance.app; + self.env = self.instance.env; + }); + + + after(function after () { + self.instance.server.close(); + }); + + + it('GET /api/versions', async () => { + let res = await request(self.app) + .get('/api/versions') + .expect(200); + + res.body.length.should.be.aboveOrEqual(3); + res.body.forEach(obj => { + const fields = Object.getOwnPropertyNames(obj); + fields.sort().should.be.eql(['url', 'version']); + + semver.valid(obj.version).should.be.ok(); + obj.url.should.startWith('/api'); + }); + }); + +}); + diff --git a/tests/api.verifyauth.test.js b/tests/api.verifyauth.test.js index a9fd681da7b..48a05f2d9c4 100644 --- a/tests/api.verifyauth.test.js +++ b/tests/api.verifyauth.test.js @@ -26,7 +26,7 @@ describe('Verifyauth REST api', function ( ) { .get('/api/verifyauth') .expect(200) .end(function(err, res) { - res.body.message.should.equal('UNAUTHORIZED'); + res.body.message.message.should.equal('UNAUTHORIZED'); done(); }); }); @@ -37,7 +37,7 @@ describe('Verifyauth REST api', function ( ) { .set('api-secret', self.env.api_secret || '') .expect(200) .end(function(err, res) { - res.body.message.should.equal('OK'); + res.body.message.message.should.equal('OK'); done(); }); }); diff --git a/tests/api3.basic.test.js b/tests/api3.basic.test.js new file mode 100644 index 00000000000..8e51b343585 --- /dev/null +++ b/tests/api3.basic.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const request = require('supertest'); +require('should'); + +describe('Basic REST API3', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + ; + + this.timeout(15000); + + before(async () => { + self.instance = await instance.create({}); + self.app = self.instance.app; + self.env = self.instance.env; + }); + + + after(function after () { + self.instance.server.close(); + }); + + + it('GET /swagger', async () => { + let res = await request(self.app) + .get('/api/v3/swagger.yaml') + .expect(200); + + res.header['content-length'].should.be.above(0); + }); + + + it('GET /version', async () => { + let res = await request(self.app) + .get('/api/v3/version') + .expect(200); + + const apiConst = require('../lib/api3/const.json') + , software = require('../package.json'); + + res.body.version.should.equal(software.version); + res.body.apiVersion.should.equal(apiConst.API3_VERSION); + res.body.srvDate.should.be.within(testConst.YEAR_2019, testConst.YEAR_2050); + }); + +}); + diff --git a/tests/api3.create.test.js b/tests/api3.create.test.js new file mode 100644 index 00000000000..cbd17a3e826 --- /dev/null +++ b/tests/api3.create.test.js @@ -0,0 +1,487 @@ +/* eslint require-atomic-updates: 0 */ +/* global should */ +'use strict'; + +require('should'); + +describe('API3 CREATE', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + , utils = require('./fixtures/api3/utils') + ; + + self.validDoc = { + date: (new Date()).getTime(), + app: testConst.TEST_APP, + device: testConst.TEST_DEVICE, + eventType: 'Correction Bolus', + insulin: 0.3 + }; + self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc); + + self.timeout(20000); + + + /** + * Cleanup after successful creation + */ + self.delete = async function deletePermanent (identifier) { + await self.instance.delete(`${self.url}/${identifier}?permanent=true&token=${self.token.delete}`) + .expect(204); + }; + + + /** + * Get document detail for futher processing + */ + self.get = async function get (identifier) { + let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`) + .expect(200); + + return res.body; + }; + + + /** + * Get document detail for futher processing + */ + self.search = async function search (date) { + let res = await self.instance.get(`${self.url}?date$eq=${date}&token=${self.token.read}`) + .expect(200); + + return res.body; + }; + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/treatments'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}?token=${self.token.create}`; + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.post(`${self.url}`) + .send(self.validDoc) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.post(`/api/v3/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should require create permission', async () => { + let res = await self.instance.post(`${self.url}?token=${self.token.read}`) + .send(self.validDoc) + .expect(403); + + res.body.status.should.equal(403); + res.body.message.should.equal('Missing permission api:treatments:create'); + }); + + + it('should reject empty body', async () => { + await self.instance.post(self.urlToken) + .send({ }) + .expect(400); + }); + + + it('should accept valid document', async () => { + let res = await self.instance.post(self.urlToken) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + res.headers.location.should.equal(`${self.url}/${self.validDoc.identifier}`); + const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds + + let body = await self.get(self.validDoc.identifier); + body.should.containEql(self.validDoc); + + const ms = body.srvModified % 1000; + (body.srvModified - ms).should.equal(lastModified); + (body.srvCreated - ms).should.equal(lastModified); + body.subject.should.equal(self.subject.apiCreate.name); + + await self.delete(self.validDoc.identifier); + }); + + + it('should reject missing date', async () => { + let doc = Object.assign({}, self.validDoc); + delete doc.date; + + let res = await self.instance.post(self.urlToken) + .send(doc) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + it('should reject invalid date null', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: null })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + it('should reject invalid date ABC', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: 'ABC' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + it('should reject invalid date -1', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: -1 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + + it('should reject invalid date 1 (too old)', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: 1 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + it('should reject invalid date - illegal format', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: '2019-20-60T50:90:90' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing date field'); + }); + + + it('should reject invalid utcOffset -5000', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: -5000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing utcOffset field'); + }); + + + it('should reject invalid utcOffset ABC', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: 'ABC' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing utcOffset field'); + }); + + + it('should accept valid utcOffset', async () => { + await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: 120 })) + .expect(201); + + let body = await self.get(self.validDoc.identifier); + body.utcOffset.should.equal(120); + await self.delete(self.validDoc.identifier); + }); + + + it('should reject invalid utcOffset null', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: null })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing utcOffset field'); + }); + + + it('should reject missing app', async () => { + let doc = Object.assign({}, self.validDoc); + delete doc.app; + + let res = await self.instance.post(self.urlToken) + .send(doc) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing app field'); + }); + + + it('should reject invalid app null', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { app: null })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing app field'); + }); + + + it('should reject empty app', async () => { + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { app: '' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Bad or missing app field'); + }); + + + it('should normalize date and store utcOffset', async () => { + await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: '2019-06-10T08:07:08,576+02:00' })) + .expect(201); + + let body = await self.get(self.validDoc.identifier); + body.date.should.equal(1560146828576); + body.utcOffset.should.equal(120); + await self.delete(self.validDoc.identifier); + }); + + + it('should require update permission for deduplication', async () => { + self.validDoc.date = (new Date()).getTime(); + self.validDoc.identifier = utils.randomString('32', 'aA#'); + + const doc = Object.assign({}, self.validDoc); + + await self.instance.post(self.urlToken) + .send(doc) + .expect(201); + + let createdBody = await self.get(doc.identifier); + createdBody.should.containEql(doc); + + const doc2 = Object.assign({}, doc); + let res = await self.instance.post(self.urlToken) + .send(doc2) + .expect(403); + + res.body.status.should.equal(403); + res.body.message.should.equal('Missing permission api:treatments:update'); + await self.delete(doc.identifier); + }); + + + it('should deduplicate document by identifier', async () => { + self.validDoc.date = (new Date()).getTime(); + self.validDoc.identifier = utils.randomString('32', 'aA#'); + + const doc = Object.assign({}, self.validDoc); + + await self.instance.post(self.urlToken) + .send(doc) + .expect(201); + + let createdBody = await self.get(doc.identifier); + createdBody.should.containEql(doc); + + const doc2 = Object.assign({}, doc, { + insulin: 0.5 + }); + + await self.instance.post(`${self.url}?token=${self.token.all}`) + .send(doc2) + .expect(204); + + let updatedBody = await self.get(doc2.identifier); + updatedBody.should.containEql(doc2); + + await self.delete(doc2.identifier); + }); + + + it('should deduplicate document by created_at+eventType', async () => { + self.validDoc.date = (new Date()).getTime(); + self.validDoc.identifier = utils.randomString('32', 'aA#'); + + const doc = Object.assign({}, self.validDoc, { + created_at: new Date(self.validDoc.date).toISOString() + }); + delete doc.identifier; + + self.instance.ctx.treatments.create([doc], async (err) => { // let's insert the document in APIv1's way + should.not.exist(err); + + const doc2 = Object.assign({}, doc, { + insulin: 0.4, + identifier: utils.randomString('32', 'aA#') + }); + delete doc2._id; // APIv1 updates input document, we must get rid of _id for the next round + + await self.instance.post(`${self.url}?token=${self.token.all}`) + .send(doc2) + .expect(204); + + let updatedBody = await self.get(doc2.identifier); + updatedBody.should.containEql(doc2); + + await self.delete(doc2.identifier); + }); + }); + + + it('should not deduplicate treatment only by created_at', async () => { + self.validDoc.date = (new Date()).getTime(); + self.validDoc.identifier = utils.randomString('32', 'aA#'); + + const doc = Object.assign({}, self.validDoc, { + created_at: new Date(self.validDoc.date).toISOString() + }); + delete doc.identifier; + + self.instance.ctx.treatments.create([doc], async (err) => { // let's insert the document in APIv1's way + should.not.exist(err); + + let oldBody = await self.get(doc._id); + delete doc._id; // APIv1 updates input document, we must get rid of _id for the next round + oldBody.should.containEql(doc); + + const doc2 = Object.assign({}, doc, { + eventType: 'Meal Bolus', + insulin: 0.4, + identifier: utils.randomString('32', 'aA#') + }); + + await self.instance.post(`${self.url}?token=${self.token.all}`) + .send(doc2) + .expect(201); + + let updatedBody = await self.get(doc2.identifier); + updatedBody.should.containEql(doc2); + updatedBody.identifier.should.not.equal(oldBody.identifier); + + await self.delete(doc2.identifier); + await self.delete(oldBody.identifier); + }); + }); + + + it('should overwrite deleted document', async () => { + const date1 = new Date() + , identifier = utils.randomString('32', 'aA#'); + + await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { identifier, date: date1.toISOString() })) + .expect(201); + + await self.instance.delete(`${self.url}/${identifier}?token=${self.token.delete}`) + .expect(204); + + const date2 = new Date(); + let res = await self.instance.post(self.urlToken) + .send(Object.assign({}, self.validDoc, { identifier, date: date2.toISOString() })) + .expect(403); + + res.body.status.should.be.equal(403); + res.body.message.should.be.equal('Missing permission api:treatments:update'); + + res = await self.instance.post(`${self.url}?token=${self.token.all}`) + .send(Object.assign({}, self.validDoc, { identifier, date: date2.toISOString() })) + .expect(204); + + res.body.should.be.empty(); + + let body = await self.get(identifier); + body.date.should.equal(date2.getTime()); + body.identifier.should.equal(identifier); + await self.delete(identifier); + }); + + + it('should calculate the identifier', async () => { + self.validDoc.date = (new Date()).getTime(); + delete self.validDoc.identifier; + const validIdentifier = opTools.calculateIdentifier(self.validDoc); + + let res = await self.instance.post(self.urlToken) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + res.headers.location.should.equal(`${self.url}/${validIdentifier}`); + self.validDoc.identifier = validIdentifier; + + let body = await self.get(validIdentifier); + body.should.containEql(self.validDoc); + await self.delete(validIdentifier); + }); + + + it('should deduplicate by identifier calculation', async () => { + self.validDoc.date = (new Date()).getTime(); + delete self.validDoc.identifier; + const validIdentifier = opTools.calculateIdentifier(self.validDoc); + + let res = await self.instance.post(self.urlToken) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + res.headers.location.should.equal(`${self.url}/${validIdentifier}`); + self.validDoc.identifier = validIdentifier; + + let body = await self.get(validIdentifier); + body.should.containEql(self.validDoc); + + delete self.validDoc.identifier; + res = await self.instance.post(`${self.url}?token=${self.token.update}`) + .send(self.validDoc) + .expect(204); + + res.body.should.be.empty(); + res.headers.location.should.equal(`${self.url}/${validIdentifier}`); + self.validDoc.identifier = validIdentifier; + + body = await self.search(self.validDoc.date); + body.length.should.equal(1); + + await self.delete(validIdentifier); + }); + +}); + diff --git a/tests/api3.delete.test.js b/tests/api3.delete.test.js new file mode 100644 index 00000000000..203d32edce8 --- /dev/null +++ b/tests/api3.delete.test.js @@ -0,0 +1,53 @@ +/* eslint require-atomic-updates: 0 */ +'use strict'; + +require('should'); + +describe('API3 UPDATE', function() { + const self = this + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + ; + + self.timeout(15000); + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/treatments'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}?token=${self.token.delete}`; + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.delete(`${self.url}/FAKE_IDENTIFIER`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.delete(`/api/v3/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + +}); + diff --git a/tests/api3.generic.workflow.test.js b/tests/api3.generic.workflow.test.js new file mode 100644 index 00000000000..acebe39555a --- /dev/null +++ b/tests/api3.generic.workflow.test.js @@ -0,0 +1,295 @@ +/* eslint require-atomic-updates: 0 */ +'use strict'; + +require('should'); + +describe('Generic REST API3', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + , utils = require('./fixtures/api3/utils') + ; + + utils.randomString('32', 'aA#'); // let's have a brand new identifier for your testing document + self.urlLastModified = '/api/v3/lastModified'; + self.historyTimestamp = 0; + + self.docOriginal = { + eventType: 'Correction Bolus', + insulin: 1, + date: (new Date()).getTime(), + app: testConst.TEST_APP, + device: testConst.TEST_DEVICE + }; + self.identifier = opTools.calculateIdentifier(self.docOriginal); + self.docOriginal.identifier = self.identifier; + + this.timeout(30000); + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.urlCol = '/api/v3/treatments'; + self.urlResource = self.urlCol + '/' + self.identifier; + self.urlHistory = self.urlCol + '/history'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}?token=${self.token.create}`; + }); + + + after(() => { + self.instance.server.close(); + }); + + + self.checkHistoryExistence = async function checkHistoryExistence (assertions) { + + let res = await self.instance.get(`${self.urlHistory}/${self.historyTimestamp}?token=${self.token.read}`) + .expect(200); + + res.body.length.should.be.above(0); + res.body.should.matchAny(value => { + value.identifier.should.be.eql(self.identifier); + value.srvModified.should.be.above(self.historyTimestamp); + + if (typeof(assertions) === 'function') { + assertions(value); + } + + self.historyTimestamp = value.srvModified; + }); + }; + + + it('LAST MODIFIED to get actual server timestamp', async () => { + let res = await self.instance.get(`${self.urlLastModified}?token=${self.token.read}`) + .expect(200); + + self.historyTimestamp = res.body.collections.treatments; + if (!self.historyTimestamp) { + self.historyTimestamp = res.body.srvDate - (10 * 60 * 1000); + } + self.historyTimestamp.should.be.aboveOrEqual(testConst.YEAR_2019); + }); + + + it('STATUS to get actual server timestamp', async () => { + let res = await self.instance.get(`/api/v3/status?token=${self.token.read}`) + .expect(200); + + self.historyTimestamp = res.body.srvDate; + self.historyTimestamp.should.be.aboveOrEqual(testConst.YEAR_2019); + }); + + + it('READ of not existing document is not found', async () => { + await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(404); + }); + + + it('SEARCH of not existing document (not found)', async () => { + let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`) + .query({ 'identifier_eq': self.identifier }) + .expect(200); + + res.body.should.have.length(0); + }); + + + it('DELETE of not existing document is not found', async () => { + await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`) + .expect(404); + }); + + + it('CREATE new document', async () => { + await self.instance.post(`${self.urlCol}?token=${self.token.create}`) + .send(self.docOriginal) + .expect(201); + }); + + + it('READ existing document', async () => { + let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(200); + + res.body.should.containEql(self.docOriginal); + self.docActual = res.body; + + if (self.historyTimestamp >= self.docActual.srvModified) { + self.historyTimestamp = self.docActual.srvModified - 1; + } + }); + + + it('SEARCH existing document (found)', async () => { + let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`) + .query({ 'identifier$eq': self.identifier }) + .expect(200); + + res.body.length.should.be.above(0); + res.body.should.matchAny(value => { + value.identifier.should.be.eql(self.identifier); + }); + }); + + + it('new document in HISTORY', async () => { + await self.checkHistoryExistence(); + }); + + + it('UPDATE document', async () => { + self.docActual.insulin = 0.5; + + await self.instance.put(`${self.urlResource}?token=${self.token.update}`) + .send(self.docActual) + .expect(204); + + self.docActual.subject = self.subject.apiUpdate.name; + }); + + + it('document changed in HISTORY', async () => { + await self.checkHistoryExistence(); + }); + + + it('document changed in READ', async () => { + let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(200); + + delete self.docActual.srvModified; + res.body.should.containEql(self.docActual); + self.docActual = res.body; + }); + + + it('PATCH document', async () => { + self.docActual.carbs = 5; + self.docActual.insulin = 0.4; + + await self.instance.patch(`${self.urlResource}?token=${self.token.update}`) + .send({ 'carbs': self.docActual.carbs, 'insulin': self.docActual.insulin }) + .expect(204); + }); + + + it('document changed in HISTORY', async () => { + await self.checkHistoryExistence(); + }); + + + it('document changed in READ', async () => { + let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(200); + + delete self.docActual.srvModified; + res.body.should.containEql(self.docActual); + self.docActual = res.body; + }); + + + it('soft DELETE', async () => { + await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`) + .expect(204); + }); + + + it('READ of deleted is gone', async () => { + await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(410); + }); + + + + it('SEARCH of deleted document missing it', async () => { + let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`) + .query({ 'identifier_eq': self.identifier }) + .expect(200); + + res.body.should.have.length(0); + }); + + + it('document deleted in HISTORY', async () => { + await self.checkHistoryExistence(value => { + value.isValid.should.be.eql(false); + }); + }); + + + it('permanent DELETE', async () => { + await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`) + .query({ 'permanent': 'true' }) + .expect(204); + }); + + + it('READ of permanently deleted is not found', async () => { + await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(404); + }); + + + it('document permanently deleted not in HISTORY', async () => { + let res = await self.instance.get(`${self.urlHistory}/${self.historyTimestamp}?token=${self.token.read}`); + + if (res.status === 200) { + res.body.should.matchEach(value => { + value.identifier.should.not.be.eql(self.identifier); + }); + } else { + res.status.should.equal(204); + } + }); + + + it('should not modify read-only document', async () => { + await self.instance.post(`${self.urlCol}?token=${self.token.create}`) + .send(Object.assign({}, self.docOriginal, { isReadOnly: true })) + .expect(201); + + let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(200); + + self.docActual = res.body; + delete self.docActual.srvModified; + const readOnlyMessage = 'Trying to modify read-only document'; + + res = await self.instance.post(`${self.urlCol}?token=${self.token.update}`) + .send(Object.assign({}, self.docActual, { insulin: 0.41 })) + .expect(422); + res.body.message.should.equal(readOnlyMessage); + + res = await self.instance.put(`${self.urlResource}?token=${self.token.update}`) + .send(Object.assign({}, self.docActual, { insulin: 0.42 })) + .expect(422); + res.body.message.should.equal(readOnlyMessage); + + res = await self.instance.patch(`${self.urlResource}?token=${self.token.update}`) + .send({ insulin: 0.43 }) + .expect(422); + res.body.message.should.equal(readOnlyMessage); + + res = await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`) + .query({ 'permanent': 'true' }) + .expect(422); + res.body.message.should.equal(readOnlyMessage); + + res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`) + .expect(200); + res.body.should.containEql(self.docOriginal); + }); + +}); + diff --git a/tests/api3.patch.test.js b/tests/api3.patch.test.js new file mode 100644 index 00000000000..38850b46ad5 --- /dev/null +++ b/tests/api3.patch.test.js @@ -0,0 +1,219 @@ +/* eslint require-atomic-updates: 0 */ +'use strict'; + +require('should'); + +describe('API3 PATCH', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + ; + + self.validDoc = { + date: (new Date()).getTime(), + utcOffset: -180, + app: testConst.TEST_APP, + device: testConst.TEST_DEVICE, + eventType: 'Correction Bolus', + insulin: 0.3 + }; + self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc); + + self.timeout(15000); + + + /** + * Get document detail for futher processing + */ + self.get = async function get (identifier) { + let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`) + .expect(200); + + return res.body; + }; + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/treatments'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}/${self.validDoc.identifier}?token=${self.token.update}`; + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.patch(`${self.url}/FAKE_IDENTIFIER`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.patch(`/api/v3/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should not found not existing document', async () => { + let res = await self.instance.patch(self.urlToken) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + + // now let's insert the document for further patching + res = await self.instance.post(`${self.url}?token=${self.token.create}`) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + }); + + + it('should reject identifier alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { identifier: 'MODIFIED'})) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field identifier cannot be modified by the client'); + }); + + + it('should reject date alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: self.validDoc.date + 10000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field date cannot be modified by the client'); + }); + + + it('should reject utcOffset alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: self.utcOffset - 120 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field utcOffset cannot be modified by the client'); + }); + + + it('should reject eventType alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { eventType: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field eventType cannot be modified by the client'); + }); + + + it('should reject device alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { device: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field device cannot be modified by the client'); + }); + + + it('should reject app alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { app: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field app cannot be modified by the client'); + }); + + + it('should reject srvCreated alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { srvCreated: self.validDoc.date - 10000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field srvCreated cannot be modified by the client'); + }); + + + it('should reject subject alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { subject: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field subject cannot be modified by the client'); + }); + + + it('should reject srvModified alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { srvModified: self.validDoc.date - 100000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field srvModified cannot be modified by the client'); + }); + + + it('should reject modifiedBy alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { modifiedBy: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field modifiedBy cannot be modified by the client'); + }); + + + it('should reject isValid alteration', async () => { + let res = await self.instance.patch(self.urlToken) + .send(Object.assign({}, self.validDoc, { isValid: false })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field isValid cannot be modified by the client'); + }); + + + it('should patch document', async () => { + self.validDoc.carbs = 10; + + let res = await self.instance.patch(self.urlToken) + .send(self.validDoc) + .expect(204); + + res.body.should.be.empty(); + + let body = await self.get(self.validDoc.identifier); + body.carbs.should.equal(10); + body.insulin.should.equal(0.3); + body.subject.should.equal(self.subject.apiCreate.name); + body.modifiedBy.should.equal(self.subject.apiUpdate.name); + }); + +}); + diff --git a/tests/api3.read.test.js b/tests/api3.read.test.js new file mode 100644 index 00000000000..b18b0225bb9 --- /dev/null +++ b/tests/api3.read.test.js @@ -0,0 +1,180 @@ +/* eslint require-atomic-updates: 0 */ +/* global should */ +'use strict'; + +require('should'); + +describe('API3 READ', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + ; + + self.validDoc = { + date: (new Date()).getTime(), + app: testConst.TEST_APP, + device: testConst.TEST_DEVICE, + uploaderBattery: 58 + }; + self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc); + + self.timeout(15000); + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/devicestatus'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.get(`${self.url}/FAKE_IDENTIFIER`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.get(`/api/v3/NOT_EXIST/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should not found not existing document', async () => { + await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .expect(404); + }); + + + it('should read just created document', async () => { + let res = await self.instance.post(`${self.url}?token=${self.token.create}`) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + + res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .expect(200); + + res.body.should.containEql(self.validDoc); + res.body.should.have.property('srvCreated').which.is.a.Number(); + res.body.should.have.property('srvModified').which.is.a.Number(); + res.body.should.have.property('subject'); + self.validDoc.subject = res.body.subject; // let's store subject for later tests + }); + + + it('should contain only selected fields', async () => { + let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?fields=date,device,subject&token=${self.token.read}`) + .expect(200); + + const correct = { + date: self.validDoc.date, + device: self.validDoc.device, + subject: self.validDoc.subject + }; + res.body.should.eql(correct); + }); + + + it('should contain all fields', async () => { + let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?fields=_all&token=${self.token.read}`) + .expect(200); + + for (let fieldName of ['app', 'date', 'device', 'identifier', 'srvModified', 'uploaderBattery', 'subject']) { + res.body.should.have.property(fieldName); + } + }); + + + it('should not send unmodified document since', async () => { + let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .set('If-Modified-Since', new Date(new Date().getTime() + 1000).toUTCString()) + .expect(304); + + res.body.should.be.empty(); + }); + + + it('should send modified document since', async () => { + let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .set('If-Modified-Since', new Date(new Date(self.validDoc.date).getTime() - 1000).toUTCString()) + .expect(200); + + res.body.should.containEql(self.validDoc); + }); + + + it('should recognize softly deleted document', async () => { + let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?token=${self.token.delete}`) + .expect(204); + + res.body.should.be.empty(); + + res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .expect(410); + + res.body.should.be.empty(); + }); + + + it('should not found permanently deleted document', async () => { + let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?permanent=true&token=${self.token.delete}`) + .expect(204); + + res.body.should.be.empty(); + + res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should found document created by APIv1', async () => { + + const doc = Object.assign({}, self.validDoc, { + created_at: new Date(self.validDoc.date).toISOString() + }); + delete doc.identifier; + + self.instance.ctx.devicestatus.create([doc], async (err) => { // let's insert the document in APIv1's way + should.not.exist(err); + const identifier = doc._id.toString(); + delete doc._id; + + let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`) + .expect(200); + + res.body.should.containEql(doc); + + res = await self.instance.delete(`${self.url}/${identifier}?permanent=true&token=${self.token.delete}`) + .expect(204); + + res.body.should.be.empty(); + }); + }); + + +}); + diff --git a/tests/api3.search.test.js b/tests/api3.search.test.js new file mode 100644 index 00000000000..dae0ebaaf34 --- /dev/null +++ b/tests/api3.search.test.js @@ -0,0 +1,261 @@ +/* eslint require-atomic-updates: 0 */ +/* global should */ +'use strict'; + +require('should'); + +describe('API3 SEARCH', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + ; + + self.docs = testConst.SAMPLE_ENTRIES; + + self.timeout(15000); + + + /** + * Get document detail for futher processing + */ + self.get = function get (identifier, done) { + self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`) + .expect(200) + .end((err, res) => { + should.not.exist(err); + done(res.body); + }); + }; + + + /** + * Create given document in a promise + */ + self.create = (doc) => new Promise((resolve) => { + doc.identifier = opTools.calculateIdentifier(doc); + self.instance.post(`${self.url}?token=${self.token.all}`) + .send(doc) + .end((err) => { + should.not.exist(err); + self.get(doc.identifier, resolve); + }); + }); + + + before(async () => { + self.testStarted = new Date(); + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/entries'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}?token=${self.token.read}`; + self.urlTest = `${self.urlToken}&srvModified$gte=${self.testStarted.getTime()}`; + + const promises = testConst.SAMPLE_ENTRIES.map(doc => self.create(doc)); + self.docs = await Promise.all(promises); + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.get(self.url) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.get(`/api/v3/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should found at least 10 documents', async () => { + let res = await self.instance.get(self.urlToken) + .expect(200); + + res.body.length.should.be.aboveOrEqual(self.docs.length); + }); + + + it('should found at least 10 documents from test start', async () => { + let res = await self.instance.get(self.urlTest) + .expect(200); + + res.body.length.should.be.aboveOrEqual(self.docs.length); + }); + + + it('should reject invalid limit - not a number', async () => { + let res = await self.instance.get(`${self.urlToken}&limit=INVALID`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter limit out of tolerance'); + }); + + + it('should reject invalid limit - negative number', async () => { + let res = await self.instance.get(`${self.urlToken}&limit=-1`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter limit out of tolerance'); + }); + + + it('should reject invalid limit - zero', async () => { + let res = await self.instance.get(`${self.urlToken}&limit=0`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter limit out of tolerance'); + }); + + + it('should accept valid limit', async () => { + let res = await self.instance.get(`${self.urlToken}&limit=3`) + .expect(200); + + res.body.length.should.be.equal(3); + }); + + + it('should reject invalid skip - not a number', async () => { + let res = await self.instance.get(`${self.urlToken}&skip=INVALID`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter skip out of tolerance'); + }); + + + it('should reject invalid skip - negative number', async () => { + let res = await self.instance.get(`${self.urlToken}&skip=-5`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter skip out of tolerance'); + }); + + + it('should reject both sort and sort$desc', async () => { + let res = await self.instance.get(`${self.urlToken}&sort=date&sort$desc=created_at`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameters sort and sort_desc cannot be combined'); + }); + + + it('should sort well by date field', async () => { + let res = await self.instance.get(`${self.urlTest}&sort=date`) + .expect(200); + + const ascending = res.body; + const length = ascending.length; + length.should.be.aboveOrEqual(self.docs.length); + + res = await self.instance.get(`${self.urlTest}&sort$desc=date`) + .expect(200); + + const descending = res.body; + descending.length.should.equal(length); + + for (let i in ascending) { + ascending[i].should.eql(descending[length - i - 1]); + + if (i > 0) { + ascending[i - 1].date.should.be.lessThanOrEqual(ascending[i].date); + } + } + }); + + + it('should skip documents', async () => { + let res = await self.instance.get(`${self.urlToken}&sort=date&limit=8`) + .expect(200); + + const fullDocs = res.body; + fullDocs.length.should.be.equal(8); + + res = await self.instance.get(`${self.urlToken}&sort=date&skip=3&limit=5`) + .expect(200); + + const skipDocs = res.body; + skipDocs.length.should.be.equal(5); + + for (let i = 0; i < 3; i++) { + skipDocs[i].should.be.eql(fullDocs[i + 3]); + } + }); + + + it('should project selected fields', async () => { + let res = await self.instance.get(`${self.urlToken}&fields=date,app,subject`) + .expect(200); + + res.body.forEach(doc => { + const docFields = Object.getOwnPropertyNames(doc); + docFields.sort().should.be.eql(['app', 'date', 'subject']); + }); + }); + + + it('should project all fields', async () => { + let res = await self.instance.get(`${self.urlToken}&fields=_all`) + .expect(200); + + res.body.forEach(doc => { + Object.getOwnPropertyNames(doc).length.should.be.aboveOrEqual(10); + Object.prototype.hasOwnProperty.call(doc, '_id').should.not.be.true(); + Object.prototype.hasOwnProperty.call(doc, 'identifier').should.be.true(); + Object.prototype.hasOwnProperty.call(doc, 'srvModified').should.be.true(); + Object.prototype.hasOwnProperty.call(doc, 'srvCreated').should.be.true(); + }); + }); + + + it('should not exceed the limit of docs count', async () => { + const apiApp = self.instance.ctx.apiApp + , limitBackup = apiApp.get('API3_MAX_LIMIT'); + apiApp.set('API3_MAX_LIMIT', 5); + let res = await self.instance.get(`${self.urlToken}&limit=10`) + .expect(400); + + res.body.status.should.be.equal(400); + res.body.message.should.be.equal('Parameter limit out of tolerance'); + apiApp.set('API3_MAX_LIMIT', limitBackup); + }); + + + it('should respect the ceiling (hard) limit of docs', async () => { + const apiApp = self.instance.ctx.apiApp + , limitBackup = apiApp.get('API3_MAX_LIMIT'); + apiApp.set('API3_MAX_LIMIT', 5); + let res = await self.instance.get(`${self.urlToken}`) + .expect(200); + + res.body.length.should.be.equal(5); + apiApp.set('API3_MAX_LIMIT', limitBackup); + }); + +}); + diff --git a/tests/api3.security.test.js b/tests/api3.security.test.js new file mode 100644 index 00000000000..4cdc8e22b21 --- /dev/null +++ b/tests/api3.security.test.js @@ -0,0 +1,189 @@ +/* eslint require-atomic-updates: 0 */ +'use strict'; + +const request = require('supertest') + , apiConst = require('../lib/api3/const.json') + , semver = require('semver') + , moment = require('moment') + ; +require('should'); + +describe('Security of REST API3', function() { + const self = this + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + ; + + this.timeout(30000); + + + before(async () => { + self.http = await instance.create({ useHttps: false }); + self.https = await instance.create({ }); + + let authResult = await authSubject(self.https.ctx.authorization.storage); + self.subject = authResult.subject; + self.token = authResult.token; + }); + + + after(() => { + self.http.server.close(); + self.https.server.close(); + }); + + + it('should require HTTPS', async () => { + if (semver.gte(process.version, '10.0.0')) { + let res = await request(self.http.baseUrl) // hangs on 8.x.x (no reason why) + .get('/api/v3/test') + .expect(403); + + res.body.status.should.equal(403); + res.body.message.should.equal(apiConst.MSG.HTTP_403_NOT_USING_HTTPS); + } + }); + + + it('should require Date header', async () => { + let res = await request(self.https.baseUrl) + .get('/api/v3/test') + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_DATE); + }); + + + it('should validate Date header syntax', async () => { + let res = await request(self.https.baseUrl) + .get('/api/v3/test') + .set('Date', 'invalid date header') + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_BAD_DATE); + }); + + + it('should reject Date header out of tolerance', async () => { + const oldDate = new Date((new Date() * 1) - 2 * 3600 * 1000) + , futureDate = new Date((new Date() * 1) + 2 * 3600 * 1000); + + let res = await request(self.https.baseUrl) + .get('/api/v3/test') + .set('Date', oldDate.toUTCString()) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE); + + res = await request(self.https.baseUrl) + .get('/api/v3/test') + .set('Date',futureDate.toUTCString()) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE); + }); + + + it('should reject invalid now ABC', async () => { + let res = await request(self.https.baseUrl) + .get(`/api/v3/test?now=ABC`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Bad Date header'); + }); + + + it('should reject invalid now -1', async () => { + let res = await request(self.https.baseUrl) + .get(`/api/v3/test?now=-1`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Bad Date header'); + }); + + + it('should reject invalid now - illegal format', async () => { + let res = await request(self.https.baseUrl) + .get(`/api/v3/test?now=2019-20-60T50:90:90`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Bad Date header'); + }); + + + it('should require token', async () => { + let res = await request(self.https.baseUrl) + .get('/api/v3/test') + .set('Date', new Date().toUTCString()) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN); + }); + + + it('should require valid token', async () => { + let res = await request(self.https.baseUrl) + .get('/api/v3/test?token=invalid_token') + .set('Date', new Date().toUTCString()) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN); + }); + + + it('should deny subject denied', async () => { + let res = await request(self.https.baseUrl) + .get('/api/v3/test?token=' + self.subject.denied.accessToken) + .set('Date', new Date().toUTCString()) + .expect(403); + + res.body.status.should.equal(403); + res.body.message.should.equal(apiConst.MSG.HTTP_403_MISSING_PERMISSION.replace('{0}', 'api:entries:read')); + }); + + + it('should allow subject with read permission', async () => { + await request(self.https.baseUrl) + .get('/api/v3/test?token=' + self.token.read) + .set('Date', new Date().toUTCString()) + .expect(200); + }); + + + it('should accept valid now - epoch in ms', async () => { + await request(self.https.baseUrl) + .get(`/api/v3/test?token=${self.token.read}&now=${moment().valueOf()}`) + .expect(200); + }); + + + it('should accept valid now - epoch in seconds', async () => { + await request(self.https.baseUrl) + .get(`/api/v3/test?token=${self.token.read}&now=${moment().unix()}`) + .expect(200); + }); + + + it('should accept valid now - ISO 8601', async () => { + await request(self.https.baseUrl) + .get(`/api/v3/test?token=${self.token.read}&now=${moment().toISOString()}`) + .expect(200); + }); + + + it('should accept valid now - RFC 2822', async () => { + await request(self.https.baseUrl) + .get(`/api/v3/test?token=${self.token.read}&now=${moment().utc().format('ddd, DD MMM YYYY HH:mm:ss [GMT]')}`) + .expect(200); + }); + +}); \ No newline at end of file diff --git a/tests/api3.socket.test.js b/tests/api3.socket.test.js new file mode 100644 index 00000000000..5c2a5cf6461 --- /dev/null +++ b/tests/api3.socket.test.js @@ -0,0 +1,178 @@ +/* eslint require-atomic-updates: 0 */ +/* global should */ +'use strict'; + +require('should'); + +describe('Socket.IO in REST API3', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , apiConst = require('../lib/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , utils = require('./fixtures/api3/utils') + ; + + self.identifier = utils.randomString('32', 'aA#'); // let's have a brand new identifier for your testing document + + self.docOriginal = { + identifier: self.identifier, + eventType: 'Correction Bolus', + insulin: 1, + date: (new Date()).getTime(), + app: testConst.TEST_APP + }; + + this.timeout(30000); + + before(async () => { + self.instance = await instance.create({ + storageSocket: true + }); + + self.app = self.instance.app; + self.env = self.instance.env; + self.colName = 'treatments'; + self.urlCol = `/api/v3/${self.colName}`; + self.urlResource = self.urlCol + '/' + self.identifier; + self.urlHistory = self.urlCol + '/history'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.socket = self.instance.clientSocket; + }); + + + after(() => { + if(self.instance && self.instance.clientSocket && self.instance.clientSocket.connected) { + self.instance.clientSocket.disconnect(); + } + self.instance.server.close(); + }); + + + it('should not subscribe without accessToken', done => { + self.socket.emit('subscribe', { }, function (data) { + data.success.should.not.equal(true); + data.message.should.equal(apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN); + done(); + }); + }); + + + it('should not subscribe by invalid accessToken', done => { + self.socket.emit('subscribe', { accessToken: 'INVALID' }, function (data) { + data.success.should.not.equal(true); + data.message.should.equal(apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN); + done(); + }); + }); + + + it('should not subscribe by subject with no rights', done => { + self.socket.emit('subscribe', { accessToken: self.token.denied }, function (data) { + data.success.should.not.equal(true); + data.message.should.equal(apiConst.MSG.SOCKET_UNAUTHORIZED_TO_ANY); + done(); + }); + }); + + + it('should subscribe by valid accessToken', done => { + const cols = ['entries', 'treatments']; + + self.socket.emit('subscribe', { + accessToken: self.token.all, + collections: cols + }, function (data) { + data.success.should.equal(true); + should(data.collections.sort()).be.eql(cols); + done(); + }); + }); + + + it('should emit create event on CREATE', done => { + + self.socket.once('create', (event) => { + event.colName.should.equal(self.colName); + event.doc.should.containEql(self.docOriginal); + delete event.doc.subject; + self.docActual = event.doc; + done(); + }); + + self.instance.post(`${self.urlCol}?token=${self.token.create}`) + .send(self.docOriginal) + .expect(201) + .end((err) => { + should.not.exist(err); + }); + }); + + + it('should emit update event on UPDATE', done => { + + self.docActual.insulin = 0.5; + + self.socket.once('update', (event) => { + delete self.docActual.srvModified; + event.colName.should.equal(self.colName); + event.doc.should.containEql(self.docActual); + delete event.doc.subject; + self.docActual = event.doc; + done(); + }); + + self.instance.put(`${self.urlResource}?token=${self.token.update}`) + .send(self.docActual) + .expect(204) + .end((err) => { + should.not.exist(err); + self.docActual.subject = self.subject.apiUpdate.name; + }); + }); + + + it('should emit update event on PATCH', done => { + + self.docActual.carbs = 5; + self.docActual.insulin = 0.4; + + self.socket.once('update', (event) => { + delete self.docActual.srvModified; + event.colName.should.equal(self.colName); + event.doc.should.containEql(self.docActual); + delete event.doc.subject; + self.docActual = event.doc; + done(); + }); + + self.instance.patch(`${self.urlResource}?token=${self.token.update}`) + .send({ 'carbs': self.docActual.carbs, 'insulin': self.docActual.insulin }) + .expect(204) + .end((err) => { + should.not.exist(err); + }); + }); + + + it('should emit delete event on DELETE', done => { + + self.socket.once('delete', (event) => { + event.colName.should.equal(self.colName); + event.identifier.should.equal(self.identifier); + done(); + }); + + self.instance.delete(`${self.urlResource}?token=${self.token.delete}`) + .expect(204) + .end((err) => { + should.not.exist(err); + }); + }); + +}); + diff --git a/tests/api3.update.test.js b/tests/api3.update.test.js new file mode 100644 index 00000000000..403aadb022e --- /dev/null +++ b/tests/api3.update.test.js @@ -0,0 +1,289 @@ +/* eslint require-atomic-updates: 0 */ +/* global should */ +'use strict'; + +require('should'); + +describe('API3 UPDATE', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , utils = require('./fixtures/api3/utils') + ; + + self.validDoc = { + identifier: utils.randomString('32', 'aA#'), + date: (new Date()).getTime(), + utcOffset: -180, + app: testConst.TEST_APP, + eventType: 'Correction Bolus', + insulin: 0.3 + }; + + self.timeout(15000); + + + /** + * Get document detail for futher processing + */ + self.get = async function get (identifier) { + let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`) + .expect(200); + + return res.body; + }; + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/treatments'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + self.urlToken = `${self.url}/${self.validDoc.identifier}?token=${self.token.update}` + }); + + + after(() => { + self.instance.server.close(); + }); + + + it('should require authentication', async () => { + let res = await self.instance.put(`${self.url}/FAKE_IDENTIFIER`) + .expect(401); + + res.body.status.should.equal(401); + res.body.message.should.equal('Missing or bad access token or JWT'); + }); + + + it('should not found not existing collection', async () => { + let res = await self.instance.put(`/api/v3/NOT_EXIST?token=${self.url}`) + .send(self.validDoc) + .expect(404); + + res.body.should.be.empty(); + }); + + + it('should require update permission for upsert', async () => { + let res = await self.instance.put(`${self.url}/${self.validDoc.identifier}?token=${self.token.update}`) + .send(self.validDoc) + .expect(403); + + res.body.status.should.equal(403); + res.body.message.should.equal('Missing permission api:treatments:create'); + }); + + + it('should upsert not existing document', async () => { + let res = await self.instance.put(`${self.url}/${self.validDoc.identifier}?token=${self.token.all}`) + .send(self.validDoc) + .expect(201); + + res.body.should.be.empty(); + + const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds + + let body = await self.get(self.validDoc.identifier); + body.should.containEql(self.validDoc); + should.not.exist(body.modifiedBy); + + const ms = body.srvModified % 1000; + (body.srvModified - ms).should.equal(lastModified); + (body.srvCreated - ms).should.equal(lastModified); + body.subject.should.equal(self.subject.apiAll.name); + }); + + + it('should update the document', async () => { + self.validDoc.carbs = 10; + delete self.validDoc.insulin; + + let res = await self.instance.put(self.urlToken) + .send(self.validDoc) + .expect(204); + + res.body.should.be.empty(); + + const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds + + let body = await self.get(self.validDoc.identifier); + body.should.containEql(self.validDoc); + should.not.exist(body.insulin); + should.not.exist(body.modifiedBy); + + const ms = body.srvModified % 1000; + (body.srvModified - ms).should.equal(lastModified); + body.subject.should.equal(self.subject.apiUpdate.name); + }); + + + it('should update unmodified document since', async () => { + const doc = Object.assign({}, self.validDoc, { + carbs: 11 + }); + let res = await self.instance.put(self.urlToken) + .set('If-Unmodified-Since', new Date(new Date().getTime() + 1000).toUTCString()) + .send(doc) + .expect(204); + + res.body.should.be.empty(); + + let body = await self.get(self.validDoc.identifier); + body.should.containEql(doc); + }); + + + it('should not update document modified since', async () => { + const doc = Object.assign({}, self.validDoc, { + carbs: 12 + }); + let body = await self.get(doc.identifier); + self.validDoc = body; + + let res = await self.instance.put(self.urlToken) + .set('If-Unmodified-Since', new Date(new Date(body.srvModified).getTime() - 1000).toUTCString()) + .send(doc) + .expect(412); + + res.body.should.be.empty(); + + body = await self.get(doc.identifier); + body.should.eql(self.validDoc); + }); + + + it('should reject date alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { date: self.validDoc.date + 10000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field date cannot be modified by the client'); + }); + + + it('should reject utcOffset alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { utcOffset: self.utcOffset - 120 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field utcOffset cannot be modified by the client'); + }); + + + it('should reject eventType alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { eventType: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field eventType cannot be modified by the client'); + }); + + + it('should reject device alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { device: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field device cannot be modified by the client'); + }); + + + it('should reject app alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { app: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field app cannot be modified by the client'); + }); + + + it('should reject srvCreated alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { srvCreated: self.validDoc.date - 10000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field srvCreated cannot be modified by the client'); + }); + + + it('should reject subject alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { subject: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field subject cannot be modified by the client'); + }); + + + it('should reject srvModified alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { srvModified: self.validDoc.date - 100000 })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field srvModified cannot be modified by the client'); + }); + + + it('should reject modifiedBy alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { modifiedBy: 'MODIFIED' })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field modifiedBy cannot be modified by the client'); + }); + + + it('should reject isValid alteration', async () => { + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { isValid: false })) + .expect(400); + + res.body.status.should.equal(400); + res.body.message.should.equal('Field isValid cannot be modified by the client'); + }); + + + it('should ignore identifier alteration in body', async () => { + self.validDoc = await self.get(self.validDoc.identifier); + + let res = await self.instance.put(self.urlToken) + .send(Object.assign({}, self.validDoc, { identifier: 'MODIFIED' })) + .expect(204); + + res.body.should.be.empty(); + }); + + + it('should not update deleted document', async () => { + let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?token=${self.token.delete}`) + .expect(204); + + res.body.should.be.empty(); + + res = await self.instance.put(self.urlToken) + .send(self.validDoc) + .expect(410); + + res.body.should.be.empty(); + }); + +}); + diff --git a/tests/ar2.test.js b/tests/ar2.test.js index 9dbf6de14cd..01f4f3d41a1 100644 --- a/tests/ar2.test.js +++ b/tests/ar2.test.js @@ -147,18 +147,18 @@ describe('ar2', function ( ) { done(); }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var now = Date.now(); var before = now - FIVE_MINS; ctx.ddata.sgvs = [{mgdl: 100, mills: before}, {mgdl: 105, mills: now}]; var sbx = prepareSandbox(); - ar2.alexa.intentHandlers.length.should.equal(1); + ar2.virtAsst.intentHandlers.length.should.equal(1); - ar2.alexa.intentHandlers[0].intentHandler(function next(title, response) { + ar2.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('AR2 Forecast'); - response.should.equal('You are expected to be between 109 and 120 over the in 30 minutes'); + response.should.equal('According to the AR2 forecast you are expected to be between 109 and 120 over the next in 30 minutes'); done(); }, [], sbx); }); diff --git a/tests/basalprofileplugin.test.js b/tests/basalprofileplugin.test.js index 0bcfd3bc268..fa97f84274e 100644 --- a/tests/basalprofileplugin.test.js +++ b/tests/basalprofileplugin.test.js @@ -77,7 +77,7 @@ describe('basalprofile', function ( ) { }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var data = {}; var ctx = { @@ -92,14 +92,14 @@ describe('basalprofile', function ( ) { var sbx = sandbox.clientInit(ctx, time, data); sbx.data.profile = profile; - basal.alexa.intentHandlers.length.should.equal(1); - basal.alexa.rollupHandlers.length.should.equal(1); + basal.virtAsst.intentHandlers.length.should.equal(1); + basal.virtAsst.rollupHandlers.length.should.equal(1); - basal.alexa.intentHandlers[0].intentHandler(function next(title, response) { + basal.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('Current Basal'); response.should.equal('Your current basal is 0.175 units per hour'); - basal.alexa.rollupHandlers[0].rollupHandler([], sbx, function callback (err, response) { + basal.virtAsst.rollupHandlers[0].rollupHandler([], sbx, function callback (err, response) { should.not.exist(err); response.results.should.equal('Your current basal is 0.175 units per hour'); response.priority.should.equal(1); diff --git a/tests/bgnow.test.js b/tests/bgnow.test.js index 819f3dafbfc..c87e513c48d 100644 --- a/tests/bgnow.test.js +++ b/tests/bgnow.test.js @@ -9,6 +9,7 @@ var SIX_MINS = 360000; describe('BG Now', function ( ) { var ctx = { language: require('../lib/language')() + , settings: require('../lib/settings')() }; ctx.levels = require('../lib/levels'); diff --git a/tests/bridge.test.js b/tests/bridge.test.js index 99c1587fab4..66b69f64c3a 100644 --- a/tests/bridge.test.js +++ b/tests/bridge.test.js @@ -10,6 +10,7 @@ describe('bridge', function ( ) { bridge: { userName: 'nightscout' , password: 'wearenotwaiting' + , interval: 60000 } } }; @@ -27,6 +28,7 @@ describe('bridge', function ( ) { opts.login.accountName.should.equal('nightscout'); opts.login.password.should.equal('wearenotwaiting'); + opts.interval.should.equal(60000); }); it('store entries from share', function (done) { @@ -39,4 +41,43 @@ describe('bridge', function ( ) { bridge.bridged(mockEntries)(null); }); + it('set too low bridge interval option from env', function () { + var tooLowInterval = { + extendedSettings: { + bridge: { interval: 900 } + } + }; + + var opts = bridge.options(tooLowInterval); + should.exist(opts); + + opts.interval.should.equal(150000); + }); + + it('set too high bridge interval option from env', function () { + var tooHighInterval = { + extendedSettings: { + bridge: { interval: 500000 } + } + }; + + var opts = bridge.options(tooHighInterval); + should.exist(opts); + + opts.interval.should.equal(150000); + }); + + it('set no bridge interval option from env', function () { + var noInterval = { + extendedSettings: { + bridge: { } + } + }; + + var opts = bridge.options(noInterval); + should.exist(opts); + + opts.interval.should.equal(150000); + }); + }); diff --git a/tests/careportal.test.js b/tests/careportal.test.js index 782bc4fa566..36f48d3a5a4 100644 --- a/tests/careportal.test.js +++ b/tests/careportal.test.js @@ -49,7 +49,7 @@ describe('client', function ( ) { client.init(); - client.dataUpdate(nowData); + client.dataUpdate(nowData, true); client.careportal.prepareEvents(); diff --git a/tests/client.renderer.test.js b/tests/client.renderer.test.js index 569691cd717..ca81e7d99e8 100644 --- a/tests/client.renderer.test.js +++ b/tests/client.renderer.test.js @@ -54,6 +54,10 @@ describe('renderer', () => { } } , futureOpacity: (millsDifference) => { return 1; } + , createAdjustedRange: () => { return [ + { getTime: () => { return extent.times[0]}}, + { getTime: () => { return extent.times[1]}} + ] } } , latestSGV: { mills: 120 } }; diff --git a/tests/cob.test.js b/tests/cob.test.js index dbbecda0b67..54fbcb6c50d 100644 --- a/tests/cob.test.js +++ b/tests/cob.test.js @@ -97,7 +97,7 @@ describe('COB', function ( ) { }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var data = { treatments: [{ carbs: '8' @@ -110,9 +110,9 @@ describe('COB', function ( ) { var sbx = sandbox.clientInit(ctx, Date.now(), data); cob.setProperties(sbx); - cob.alexa.intentHandlers.length.should.equal(1); + cob.virtAsst.intentHandlers.length.should.equal(1); - cob.alexa.intentHandlers[0].intentHandler(function next(title, response) { + cob.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('Current COB'); response.should.equal('You have 8 carbohydrates on board'); done(); diff --git a/tests/ddata.test.js b/tests/ddata.test.js index f3757348c53..ceb163b7c4f 100644 --- a/tests/ddata.test.js +++ b/tests/ddata.test.js @@ -41,19 +41,6 @@ describe('ddata', function ( ) { done( ); }); - it('has #split( )', function (done) { - var date = new Date( ); - var time = date.getTime( ); - var cutoff = 1000 * 60 * 5; - var max = 1000 * 60 * 60 * 24 * 2; - var pieces = ctx.ddata.splitRecent(time, cutoff, max); - should.exist(pieces); - should.exist(pieces.first); - should.exist(pieces.rest); - - done( ); - }); - // TODO: ensure partition function gets called via: // Properties // * ddata.devicestatus diff --git a/tests/fail.test.js b/tests/fail.test.js new file mode 100644 index 00000000000..eefda445b3d --- /dev/null +++ b/tests/fail.test.js @@ -0,0 +1,14 @@ +'use strict'; + +require('should'); + +// This test is included just so we have an easy to template to intentionally cause +// builds to fail + +describe('fail', function ( ) { + + it('should not fail', function () { + true.should.equal(true); + }); + +}); diff --git a/tests/fixtures/api/instance.js b/tests/fixtures/api/instance.js new file mode 100644 index 00000000000..ed5b28474c9 --- /dev/null +++ b/tests/fixtures/api/instance.js @@ -0,0 +1,98 @@ +'use strict'; + +const fs = require('fs') + , path = require('path') + , language = require('../../../lib/language')() + , apiRoot = require('../../../lib/api/root') + , http = require('http') + , https = require('https') + ; + +function configure () { + const self = { }; + + self.prepareEnv = function prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable }) { + + if (useHttps) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + else { + process.env.INSECURE_USE_HTTP = true; + } + process.env.API_SECRET = apiSecret; + + process.env.HOSTNAME = 'localhost'; + const env = require('../../../env')(); + + if (useHttps) { + env.ssl = { + key: fs.readFileSync(path.join(__dirname, '../api3/localhost.key')), + cert: fs.readFileSync(path.join(__dirname, '../api3/localhost.crt')) + }; + } + + env.settings.authDefaultRoles = authDefaultRoles; + env.settings.enable = enable; + + return env; + }; + + + /* + * Create new web server instance for testing purposes + */ + self.create = function createHttpServer ({ + apiSecret = 'this is my long pass phrase', + useHttps = true, + authDefaultRoles = '', + enable = ['careportal', 'api'] + }) { + + return new Promise(function (resolve, reject) { + + try { + let instance = { }, + hasBooted = false + ; + + instance.env = self.prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable }); + + self.wares = require('../../../lib/middleware/')(instance.env); + instance.app = require('express')(); + instance.app.enable('api'); + + require('../../../lib/server/bootevent')(instance.env, language).boot(function booted (ctx) { + instance.ctx = ctx; + instance.ctx.ddata = require('../../../lib/data/ddata')(); + instance.ctx.apiRootApp = apiRoot(instance.env, ctx); + + instance.app.use('/api', instance.ctx.apiRootApp); + + const transport = useHttps ? https : http; + + instance.server = transport.createServer(instance.env.ssl || { }, instance.app).listen(0); + instance.env.PORT = instance.server.address().port; + + instance.baseUrl = `${useHttps ? 'https' : 'http'}://${instance.env.HOSTNAME}:${instance.env.PORT}`; + + console.log(`Started ${useHttps ? 'SSL' : 'HTTP'} instance on ${instance.baseUrl}`); + hasBooted = true; + resolve(instance); + }); + + setTimeout(function watchDog() { + if (!hasBooted) + reject('timeout'); + }, 30000); + + } catch (err) { + reject(err); + } + }); + }; + + + return self; +} + +module.exports = configure(); \ No newline at end of file diff --git a/tests/fixtures/api3/authSubject.js b/tests/fixtures/api3/authSubject.js new file mode 100644 index 00000000000..6036103b0e5 --- /dev/null +++ b/tests/fixtures/api3/authSubject.js @@ -0,0 +1,94 @@ +'use strict'; + +const _ = require('lodash'); + +function createRole (authStorage, name, permissions) { + + return new Promise((resolve, reject) => { + + let role = _.find(authStorage.roles, { name }); + + if (role) { + resolve(role); + } + else { + authStorage.createRole({ + "name": name, + "permissions": permissions, + "notes": "" + }, function afterCreate (err) { + + if (err) + reject(err); + + role = _.find(authStorage.roles, { name }); + resolve(role); + }); + } + }); +} + + +function createTestSubject (authStorage, subjectName, roles) { + + return new Promise((resolve, reject) => { + + const subjectDbName = 'test-' + subjectName; + let subject = _.find(authStorage.subjects, { name: subjectDbName }); + + if (subject) { + resolve(subject); + } + else { + authStorage.createSubject({ + "name": subjectDbName, + "roles": roles, + "notes": "" + }, function afterCreate (err) { + + if (err) + reject(err); + + subject = _.find(authStorage.subjects, { name: subjectDbName }); + resolve(subject); + }); + } + }); +} + + +async function authSubject (authStorage) { + + await createRole(authStorage, 'apiAll', 'api:*:*'); + await createRole(authStorage, 'apiAdmin', 'api:*:admin'); + await createRole(authStorage, 'apiCreate', 'api:*:create'); + await createRole(authStorage, 'apiRead', 'api:*:read'); + await createRole(authStorage, 'apiUpdate', 'api:*:update'); + await createRole(authStorage, 'apiDelete', 'api:*:delete'); + + const subject = { + apiAll: await createTestSubject(authStorage, 'apiAll', ['apiAll']), + apiAdmin: await createTestSubject(authStorage, 'apiAdmin', ['apiAdmin']), + apiCreate: await createTestSubject(authStorage, 'apiCreate', ['apiCreate']), + apiRead: await createTestSubject(authStorage, 'apiRead', ['apiRead']), + apiUpdate: await createTestSubject(authStorage, 'apiUpdate', ['apiUpdate']), + apiDelete: await createTestSubject(authStorage, 'apiDelete', ['apiDelete']), + admin: await createTestSubject(authStorage, 'admin', ['admin']), + readable: await createTestSubject(authStorage, 'readable', ['readable']), + denied: await createTestSubject(authStorage, 'denied', ['denied']) + }; + + const token = { + all: subject.apiAll.accessToken, + admin: subject.apiAdmin.accessToken, + create: subject.apiCreate.accessToken, + read: subject.apiRead.accessToken, + update: subject.apiUpdate.accessToken, + delete: subject.apiDelete.accessToken, + denied: subject.denied.accessToken + }; + + return {subject, token}; +} + +module.exports = authSubject; \ No newline at end of file diff --git a/tests/fixtures/api3/const.json b/tests/fixtures/api3/const.json new file mode 100644 index 00000000000..a0acf37cfee --- /dev/null +++ b/tests/fixtures/api3/const.json @@ -0,0 +1,138 @@ +{ + "YEAR_2019": 1546304400000, + "YEAR_2050": 2524611600000, + "TEST_APP": "cgm-remote-monitor.test", + "TEST_DEVICE": "Samsung XCover 4-123456735643809", + + "SAMPLE_ENTRIES": [ + { + "date": 1491717830000.0, + "device": "dexcom", + "direction": "FortyFiveUp", + "filtered": 167584, + "noise": 2, + "rssi": 183, + "sgv": 149, + "type": "sgv", + "unfiltered": 171584, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491718130000.0, + "device": "dexcom", + "direction": "FortyFiveUp", + "filtered": 170656, + "noise": 2, + "rssi": 181, + "sgv": 152, + "type": "sgv", + "unfiltered": 175776, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491718430000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 173536, + "noise": 2, + "rssi": 185, + "sgv": 155, + "type": "sgv", + "unfiltered": 180864, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491718730000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 177120, + "noise": 2, + "rssi": 186, + "sgv": 159, + "type": "sgv", + "unfiltered": 182080, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491719030000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 181088, + "noise": 2, + "rssi": 165, + "sgv": 163, + "type": "sgv", + "unfiltered": 186912, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491719330000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 184736, + "noise": 1, + "rssi": 162, + "sgv": 170, + "type": "sgv", + "unfiltered": 188512, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491719630000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 187776, + "noise": 1, + "rssi": 175, + "sgv": 175, + "type": "sgv", + "unfiltered": 192608, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491719930000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 190816, + "noise": 1, + "rssi": 181, + "sgv": 179, + "type": "sgv", + "unfiltered": 196640, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491720230000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 194016, + "noise": 1, + "rssi": 203, + "sgv": 181, + "type": "sgv", + "unfiltered": 199008, + "app": "cgm-remote-monitor.test" + }, + + { + "date": 1491720530000.0, + "device": "dexcom", + "direction": "Flat", + "filtered": 197536, + "noise": 1, + "rssi": 184, + "sgv": 186, + "type": "sgv", + "unfiltered": 203296, + "app": "cgm-remote-monitor.test" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/api3/instance.js b/tests/fixtures/api3/instance.js new file mode 100644 index 00000000000..a7693ab3c40 --- /dev/null +++ b/tests/fixtures/api3/instance.js @@ -0,0 +1,163 @@ +'use strict'; + +var fs = require('fs') + , language = require('../../../lib/language')() + , api = require('../../../lib/api3/') + , http = require('http') + , https = require('https') + , request = require('supertest') + , websocket = require('../../../lib/server/websocket') + , io = require('socket.io-client') + ; + +function configure () { + const self = { }; + + self.prepareEnv = function prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable }) { + + if (useHttps) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + else { + process.env.INSECURE_USE_HTTP = true; + } + process.env.API_SECRET = apiSecret; + + process.env.HOSTNAME = 'localhost'; + const env = require('../../../env')(); + + if (useHttps) { + env.ssl = { + key: fs.readFileSync(__dirname + '/localhost.key'), + cert: fs.readFileSync(__dirname + '/localhost.crt') + }; + } + + env.settings.authDefaultRoles = authDefaultRoles; + env.settings.enable = enable; + + return env; + }; + + + self.addSecuredOperations = function addSecuredOperations (instance) { + + instance.get = (url) => request(instance.baseUrl).get(url).set('Date', new Date().toUTCString()); + + instance.post = (url) => request(instance.baseUrl).post(url).set('Date', new Date().toUTCString()); + + instance.put = (url) => request(instance.baseUrl).put(url).set('Date', new Date().toUTCString()); + + instance.patch = (url) => request(instance.baseUrl).patch(url).set('Date', new Date().toUTCString()); + + instance.delete = (url) => request(instance.baseUrl).delete(url).set('Date', new Date().toUTCString()); + }; + + + self.bindSocket = function bindSocket (storageSocket, instance) { + + return new Promise(function (resolve, reject) { + if (!storageSocket) { + resolve(); + } + else { + let socket = io(`${instance.baseUrl}/storage`, { + origins:"*", + transports: ['websocket', 'flashsocket', 'polling'], + rejectUnauthorized: false + }); + + socket.on('connect', function () { + resolve(socket); + }); + socket.on('connect_error', function (error) { + console.error(error); + reject(error); + }); + } + }); + }; + + + self.unbindSocket = function unbindSocket (instance) { + if (instance.clientSocket.connected) { + instance.clientSocket.disconnect(); + } + }; + + /* + * Create new web server instance for testing purposes + */ + self.create = function createHttpServer ({ + apiSecret = 'this is my long pass phrase', + disableSecurity = false, + useHttps = true, + authDefaultRoles = '', + enable = ['careportal', 'api'], + storageSocket = null + }) { + + return new Promise(function (resolve, reject) { + + try { + let instance = { }, + hasBooted = false + ; + + instance.env = self.prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable }); + + self.wares = require('../../../lib/middleware/')(instance.env); + instance.app = require('express')(); + instance.app.enable('api'); + + require('../../../lib/server/bootevent')(instance.env, language).boot(function booted (ctx) { + instance.ctx = ctx; + instance.ctx.ddata = require('../../../lib/data/ddata')(); + instance.ctx.apiApp = api(instance.env, ctx); + + if (disableSecurity) { + instance.ctx.apiApp.set('API3_SECURITY_ENABLE', false); + } + + instance.app.use('/api/v3', instance.ctx.apiApp); + + const transport = useHttps ? https : http; + + instance.server = transport.createServer(instance.env.ssl || { }, instance.app).listen(0); + instance.env.PORT = instance.server.address().port; + + instance.baseUrl = `${useHttps ? 'https' : 'http'}://${instance.env.HOSTNAME}:${instance.env.PORT}`; + + self.addSecuredOperations(instance); + + websocket(instance.env, instance.ctx, instance.server); + + self.bindSocket(storageSocket, instance) + .then((socket) => { + instance.clientSocket = socket; + + console.log(`Started ${useHttps ? 'SSL' : 'HTTP'} instance on ${instance.baseUrl}`); + hasBooted = true; + resolve(instance); + }) + .catch((reason) => { + console.error(reason); + reject(reason); + }); + }); + + setTimeout(function watchDog() { + if (!hasBooted) + reject('timeout'); + }, 30000); + + } catch (err) { + reject(err); + } + }); + }; + + return self; +} + +module.exports = configure(); \ No newline at end of file diff --git a/tests/fixtures/api3/localhost.crt b/tests/fixtures/api3/localhost.crt new file mode 100644 index 00000000000..21a2a39b0a4 --- /dev/null +++ b/tests/fixtures/api3/localhost.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+zCCAeOgAwIBAgIJAIx0y57dTqDpMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0xOTAyMDQxOTM1MDhaFw0yOTAyMDExOTM1MDhaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKCeBaqAJU+nrzNUZMsD1jYQpmcw8+6tG69KQY2XmqMsaPupo2ArwUlYD3pm +F1HTf9Lkq8u07rlUyMaSSRYrY56vPrMWGSK5Elm4kF8DNS4b/55KwZC+YQM0ZuJK +wSM6WX4G7JwV936HKJAT+Ec+8Ofq3GQzA9+Z4x2zMwNGC8AghtPjsCk68ORCmr+5 +fdCdC1Rz9hE92Nmofi8e1hUTeZmFROx8hcYRhxYXLIWVxALc/t8yY3MZfsRuZXcP +/3PageAn0ecxhqlWBY23GDQx7OSEZxSEPgqxnAHQfQXIrPRjMkFNHeMM7HTvITAG +VCc99zEG3Jy5hatm+RAajdWBH4sCAwEAAaNQME4wHQYDVR0OBBYEFJJVZn5Y91O7 +JUKeHW4La8eseKKwMB8GA1UdIwQYMBaAFJJVZn5Y91O7JUKeHW4La8eseKKwMAwG +A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAFOU19t9h6C1Hakkik/93kun +pwG7v8VvDPjKECR5KlNPKNZUOQaiMAVHgNwPWV8q+qvfydzIpDrTd/O5eOaOduLx +gDVDj078Q05j17RUC+ct5yQ6lPgEHlnkI0Zr/hgFyNC+mtK7oIm6BT8wSSRbv7AG +3wQzCA5UvW/BQ8rtNZSC42Jyr0BR0ZS9Fo3Gc4v/nZJlgkiBvU2gKVQ7VRKxybCn +0hDghVwTfBPq7PKmupLX82ktwhYpDJZXCsOVfq9mF6nbQ6b0MieXFD+7cBlEXb1e +3VgtVzKYyqh/Oex4HfMThzAJZSWa0E4FShr5XdTdIc3nB4Vgbsis5l9Yrcp3Xo4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/api3/localhost.key b/tests/fixtures/api3/localhost.key new file mode 100644 index 00000000000..2486c15fefe --- /dev/null +++ b/tests/fixtures/api3/localhost.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAoJ4FqoAlT6evM1RkywPWNhCmZzDz7q0br0pBjZeaoyxo+6mj +YCvBSVgPemYXUdN/0uSry7TuuVTIxpJJFitjnq8+sxYZIrkSWbiQXwM1Lhv/nkrB +kL5hAzRm4krBIzpZfgbsnBX3focokBP4Rz7w5+rcZDMD35njHbMzA0YLwCCG0+Ow +KTrw5EKav7l90J0LVHP2ET3Y2ah+Lx7WFRN5mYVE7HyFxhGHFhcshZXEAtz+3zJj +cxl+xG5ldw//c9qB4CfR5zGGqVYFjbcYNDHs5IRnFIQ+CrGcAdB9Bcis9GMyQU0d +4wzsdO8hMAZUJz33MQbcnLmFq2b5EBqN1YEfiwIDAQABAoIBADoh95sGVnrGDjtd +yD1SXi2jSRcAOMmiDesbzS4aOPXmFPlBJMiiDYsmPDPoz3fmPNVvvl40VlLtxN1a +BOnpOl0swFzBGsfehC3FBzvcRVsy9wmrtPNWdHZceQBeXhkJ/WoHx4uWx8Ub1iqP +j8T5mufVsX7yl+xOHk2ZllUQ/R/EEz9x00pkiH8Vsn8DhFI5KNqgi4n4c36T3vrn +MjTp+1o7bJ/cEnvXLi+IG2CO5y5hVbu3iKb+71YOGc6f/AJVzZ3MegC3KMFho9lh +DbDzumMuW8fZNyBfslXXoOr6oDqNq92n/jC/2hR8Xlth/aafisJiIVGydeVdDXhM +gDjdroECgYEAy3hXuo/Q1acncInGhIJvHjS/sVShP2epHz9zp8XuWl4NCuGP5V2c +jLT0hDW+ZKTUFweK9sQJNta81gs4pYc+2HGI8RP65XW4vgesNoKbBcE9xhEq0HMX +KN3/MJiwkNkM95T3nWqulhzNszhgNbZDMAU3Ule+o4n8udwOlFCTeXMCgYEAyhV4 +PoL3wp05BY0ssyKEqld3EqHNlPdQeJe1Dg9LSBy+3Z9sNngRD1/FuTo7RX6UY0FH +MaSI1JwhHSQ+2GNkqdMvVAilTXIDRw8vU9B77bYiHjny8+vMU06I9V3cJ57bNfmR +NUJtPmGO9xQ5UYxhP9rFOcI4MIecSzu1tvqiG4kCgYB01NoS7sdsFrnnvcS2i6rA +PmufqEeaf6w1nBqNyHJPg1eb2t7kRfdBOBp6291CLv71Zkhd3zynN3BguzrAmUL1 +x2Npgh57qTf2LbOt7RqUmFwfIfZikONIfQgt4E7qLSdr9iakRgCPg2R9ty5PSSOV +LDmS131IrE/obLoWYZn8jwKBgQDIaAxMahONA+CFueCHcgcA6yah6qZ3QeCjB0g9 +vjsZM7CxFqX5So8YoRDzxWT8YTCFUjppZ9NujbtlLAnLDJ7KsC2yd7R/Hj9T3CJC +S3JrZoFlWnCvJ7wFLdAzDTcEb8zTNUGlANBX2eYu7/Z8Aex7p9iJlCunLQV5sqhd +4yaaiQKBgQCERrz1XcJpM8S93nXdAv3Nn1bwA1V/ylx42DRxNEBl2JZQ1sQeqN36 +JvXPXhVZ3vTQDhVUqcVgqJIAb2xMviIVBnssOq3+pi/hOs13rakJf4AOulZ/3Si7 +HSLdymfQAMEKczU2261kw4pjPwiurkjAFWbQG2C8RGE/rR2y38PkDg== +-----END RSA PRIVATE KEY----- diff --git a/tests/fixtures/api3/utils.js b/tests/fixtures/api3/utils.js new file mode 100644 index 00000000000..942f948c10e --- /dev/null +++ b/tests/fixtures/api3/utils.js @@ -0,0 +1,21 @@ +'use strict'; + +function randomString (length, chars) { + let mask = ''; + if (chars.indexOf('a') > -1) mask += 'abcdefghijklmnopqrstuvwxyz'; + if (chars.indexOf('A') > -1) mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (chars.indexOf('#') > -1) mask += '0123456789'; + if (chars.indexOf('!') > -1) mask += '~`!@#$%^&*()_+-={}[]:";\'<>?,./|\\'; + + let result = ''; + + for (let i = length; i > 0; --i) + result += mask[Math.floor(Math.random() * mask.length)]; + + return result; +} + + +module.exports = { + randomString +}; \ No newline at end of file diff --git a/tests/iob.test.js b/tests/iob.test.js index 30872e4fb4d..3b92fb8d05a 100644 --- a/tests/iob.test.js +++ b/tests/iob.test.js @@ -7,10 +7,11 @@ describe('IOB', function() { var ctx = {}; ctx.language = require('../lib/language')(); ctx.language.set('en'); + ctx.settings = require('../lib/settings')(); var iob = require('../lib/plugins/iob')(ctx); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var sbx = { properties: { @@ -20,14 +21,14 @@ describe('IOB', function() { } }; - iob.alexa.intentHandlers.length.should.equal(1); - iob.alexa.rollupHandlers.length.should.equal(1); + iob.virtAsst.intentHandlers.length.should.equal(1); + iob.virtAsst.rollupHandlers.length.should.equal(1); - iob.alexa.intentHandlers[0].intentHandler(function next(title, response) { + iob.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('Current IOB'); response.should.equal('You have 1.50 units of insulin on board'); - iob.alexa.rollupHandlers[0].rollupHandler([], sbx, function callback (err, response) { + iob.virtAsst.rollupHandlers[0].rollupHandler([], sbx, function callback (err, response) { should.not.exist(err); response.results.should.equal('and you have 1.50 units of insulin on board.'); response.priority.should.equal(2); diff --git a/tests/loop.test.js b/tests/loop.test.js index 9c65ff9bdd1..bfe11d5075c 100644 --- a/tests/loop.test.js +++ b/tests/loop.test.js @@ -6,6 +6,7 @@ var moment = require('moment'); var ctx = { language: require('../lib/language')() + , settings: require('../lib/settings')() }; ctx.language.set('en'); var env = require('../env')(); @@ -243,7 +244,7 @@ describe('loop', function ( ) { done(); }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var ctx = { settings: { units: 'mg/dl' @@ -255,14 +256,14 @@ describe('loop', function ( ) { var sbx = sandbox.clientInit(ctx, now.valueOf(), {devicestatus: statuses}); loop.setProperties(sbx); - loop.alexa.intentHandlers.length.should.equal(2); + loop.virtAsst.intentHandlers.length.should.equal(2); - loop.alexa.intentHandlers[0].intentHandler(function next(title, response) { + loop.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('Loop Forecast'); response.should.equal('According to the loop forecast you are expected to be between 147 and 149 over the next in 25 minutes'); - loop.alexa.intentHandlers[1].intentHandler(function next(title, response) { - title.should.equal('Last loop'); + loop.virtAsst.intentHandlers[1].intentHandler(function next(title, response) { + title.should.equal('Last Loop'); response.should.equal('The last successful loop was a few seconds ago'); done(); }, [], sbx); diff --git a/tests/openaps.test.js b/tests/openaps.test.js index ed3dd6d3b9f..b2e767c8fe2 100644 --- a/tests/openaps.test.js +++ b/tests/openaps.test.js @@ -6,6 +6,7 @@ var moment = require('moment'); var ctx = { language: require('../lib/language')() + , settings: require('../lib/settings')() }; ctx.language.set('en'); var env = require('../env')(); @@ -370,7 +371,7 @@ describe('openaps', function ( ) { done(); }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var ctx = { settings: { units: 'mg/dl' @@ -382,14 +383,14 @@ describe('openaps', function ( ) { var sbx = sandbox.clientInit(ctx, now.valueOf(), {devicestatus: statuses}); openaps.setProperties(sbx); - openaps.alexa.intentHandlers.length.should.equal(2); + openaps.virtAsst.intentHandlers.length.should.equal(2); - openaps.alexa.intentHandlers[0].intentHandler(function next(title, response) { - title.should.equal('Loop Forecast'); + openaps.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { + title.should.equal('OpenAPS Forecast'); response.should.equal('The OpenAPS Eventual BG is 125'); - openaps.alexa.intentHandlers[1].intentHandler(function next(title, response) { - title.should.equal('Last loop'); + openaps.virtAsst.intentHandlers[1].intentHandler(function next(title, response) { + title.should.equal('Last Loop'); response.should.equal('The last successful loop was 2 minutes ago'); done(); }, [], sbx); diff --git a/tests/profile.test.js b/tests/profile.test.js index 8171f459e3d..373f0479d9d 100644 --- a/tests/profile.test.js +++ b/tests/profile.test.js @@ -5,6 +5,10 @@ describe('Profile', function ( ) { var profile_empty = require('../lib/profilefunctions')(); + beforeEach(function() { + profile_empty.clear(); + }); + it('should say it does not have data before it has data', function() { var hasData = profile_empty.hasData(); hasData.should.equal(false); @@ -30,8 +34,6 @@ describe('Profile', function ( ) { }; var profile = require('../lib/profilefunctions')([profileData]); -// console.log(profile); - var now = Date.now(); it('should know what the DIA is with old style profiles', function() { diff --git a/tests/pump.test.js b/tests/pump.test.js index c6def822058..d051cbe7163 100644 --- a/tests/pump.test.js +++ b/tests/pump.test.js @@ -6,6 +6,7 @@ var moment = require('moment'); var ctx = { language: require('../lib/language')() + , settings: require('../lib/settings')() }; ctx.language.set('en'); var env = require('../env')(); @@ -254,7 +255,7 @@ describe('pump', function ( ) { done(); }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var ctx = { settings: { units: 'mg/dl' @@ -266,16 +267,28 @@ describe('pump', function ( ) { var sbx = sandbox.clientInit(ctx, now.valueOf(), {devicestatus: statuses}); pump.setProperties(sbx); - pump.alexa.intentHandlers.length.should.equal(2); + pump.virtAsst.intentHandlers.length.should.equal(4); - pump.alexa.intentHandlers[0].intentHandler(function next(title, response) { - title.should.equal('Remaining insulin'); + pump.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { + title.should.equal('Insulin Remaining'); response.should.equal('You have 86.4 units remaining'); - pump.alexa.intentHandlers[1].intentHandler(function next(title, response) { - title.should.equal('Pump battery'); + pump.virtAsst.intentHandlers[1].intentHandler(function next(title, response) { + title.should.equal('Pump Battery'); response.should.equal('Your pump battery is at 1.52 volts'); - done(); + + pump.virtAsst.intentHandlers[2].intentHandler(function next(title, response) { + title.should.equal('Insulin Remaining'); + response.should.equal('You have 86.4 units remaining'); + + pump.virtAsst.intentHandlers[3].intentHandler(function next(title, response) { + title.should.equal('Pump Battery'); + response.should.equal('Your pump battery is at 1.52 volts'); + done(); + }, [], sbx); + + }, [], sbx); + }, [], sbx); }, [], sbx); diff --git a/tests/rawbg.test.js b/tests/rawbg.test.js index ab91d2bf722..48c21186cc5 100644 --- a/tests/rawbg.test.js +++ b/tests/rawbg.test.js @@ -35,16 +35,16 @@ describe('Raw BG', function ( ) { }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var sandbox = require('../lib/sandbox')(); var sbx = sandbox.clientInit(ctx, Date.now(), data); rawbg.setProperties(sbx); - rawbg.alexa.intentHandlers.length.should.equal(1); + rawbg.virtAsst.intentHandlers.length.should.equal(1); - rawbg.alexa.intentHandlers[0].intentHandler(function next(title, response) { + rawbg.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { title.should.equal('Current Raw BG'); response.should.equal('Your raw bg is 113'); diff --git a/tests/reports.test.js b/tests/reports.test.js index 3c79e3b096a..7d5a0eb7009 100644 --- a/tests/reports.test.js +++ b/tests/reports.test.js @@ -261,10 +261,12 @@ describe('reports', function ( ) { var result = $('body').html(); //var filesys = require('fs'); //var logfile = filesys.createWriteStream('out.txt', { flags: 'a'} ) - //logfile.write($('body').html()); - + //logfile.write(result); + //console.log('RESULT', result); + result.indexOf('Milk now').should.be.greaterThan(-1); // daytoday - result.indexOf('50 g (1.67U)').should.be.greaterThan(-1); // daytoday + result.indexOf('50 g').should.be.greaterThan(-1); // daytoday + result.indexOf('TDD average: 2.9U').should.be.greaterThan(-1); // daytoday result.indexOf('0%100%0%2').should.be.greaterThan(-1); //dailystats //TODO FIXME result.indexOf('td class="tdborder" style="background-color:#8f8">Normal: 64.7%6').should.be.greaterThan(-1); // distribution result.indexOf('16 (100%)').should.be.greaterThan(-1); // hourlystats diff --git a/tests/timeago.test.js b/tests/timeago.test.js index 7b4a718ccd0..c0c88b8a1b1 100644 --- a/tests/timeago.test.js +++ b/tests/timeago.test.js @@ -7,6 +7,8 @@ describe('timeago', function() { ctx.ddata = require('../lib/data/ddata')(); ctx.notifications = require('../lib/notifications')(env, ctx); ctx.language = require('../lib/language')(); + ctx.settings = require('../lib/settings')(); + ctx.settings.heartbeat = 0.5; // short heartbeat to speedup tests var timeago = require('../lib/plugins/timeago')(ctx); @@ -41,6 +43,34 @@ describe('timeago', function() { done(); }); + it('should suspend alarms due to hibernation when 2 heartbeats are skipped on server', function() { + ctx.ddata.sgvs = [{ mills: Date.now() - times.mins(16).msecs, mgdl: 100, type: 'sgv' }]; + + var sbx = freshSBX() + var status = timeago.checkStatus(sbx); + // By default (no hibernation detected) a warning should be given + // we force no hibernation by checking status twice + status = timeago.checkStatus(sbx); + should.equal(status, 'warn'); + + // 10ms more than suspend-threshold to prevent flapping tests + var timeoutMs = 2 * ctx.settings.heartbeat * 1000 + 100; + return new Promise(function(resolve, reject) { + setTimeout(function() { + status = timeago.checkStatus(sbx); + // Because hibernation should now be detected, no warning should be given + should.equal(status, 'current'); + + // We immediately ask status again, so hibernation should not be detected anymore, + // and we should receive a warning again + status = timeago.checkStatus(sbx); + should.equal(status, 'warn'); + + resolve() + }, timeoutMs) + }) + }); + it('should trigger a warning when data older than 15m', function(done) { ctx.notifications.initRequests(); ctx.ddata.sgvs = [{ mills: Date.now() - times.mins(16).msecs, mgdl: 100, type: 'sgv' }]; diff --git a/tests/upbat.test.js b/tests/upbat.test.js index 9b48c3b845e..42d18bb0854 100644 --- a/tests/upbat.test.js +++ b/tests/upbat.test.js @@ -93,7 +93,7 @@ describe('Uploader Battery', function ( ) { upbat.updateVisualisation(sbx); }); - it('should handle alexa requests', function (done) { + it('should handle virtAsst requests', function (done) { var ctx = { settings: {} @@ -106,13 +106,19 @@ describe('Uploader Battery', function ( ) { var upbat = require('../lib/plugins/upbat')(ctx); upbat.setProperties(sbx); - upbat.alexa.intentHandlers.length.should.equal(1); + upbat.virtAsst.intentHandlers.length.should.equal(2); - upbat.alexa.intentHandlers[0].intentHandler(function next(title, response) { - title.should.equal('Uploader battery'); + upbat.virtAsst.intentHandlers[0].intentHandler(function next(title, response) { + title.should.equal('Uploader Battery'); response.should.equal('Your uploader battery is at 20%'); - - done(); + + upbat.virtAsst.intentHandlers[1].intentHandler(function next(title, response) { + title.should.equal('Uploader Battery'); + response.should.equal('Your uploader battery is at 20%'); + + done(); + }, [], sbx); + }, [], sbx); }); diff --git a/views/clockviews/bgclock.css b/views/clockviews/bgclock.css index 0a80dae7db0..3e2cbef246f 100644 --- a/views/clockviews/bgclock.css +++ b/views/clockviews/bgclock.css @@ -1,42 +1,7 @@ -body { - text-align: center; - margin: 0 0; - padding: 0; - overflow: hidden; - font-family: 'Open Sans'; - color: grey; - background-color: black; -} - -main { - display: -webkit-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - align-items: center; - height: 100vh; -} - .inner { - width: 100%; -webkit-transform: translateY(-2%); } -#trend { - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -ms-flex-align: center; - -webkit-align-items: center; - align-items: center; - justify-content: center; - -webkit-flex-direction: row; - flex-direction: row; -} - #bgnow, #arrowDiv { display: flex; flex-grow: 0; @@ -55,25 +20,9 @@ img#arrow { #clock { font-weight: 700; font-size: 25vmin; + display: inline; } .stale { text-decoration: line-through; -} - -.close { - color: white; - font: 4em 'Open Sans'; - position: absolute; - right: 20px; - text-decoration: none; -} - -.close:after { - content: '\00D7'; -} - -.hidden { - opacity: 0; - transition: opacity 0.5s linear; } \ No newline at end of file diff --git a/views/clockviews/clock-color.css b/views/clockviews/clock-color.css index 36002c6b9ac..6a6796ef823 100644 --- a/views/clockviews/clock-color.css +++ b/views/clockviews/clock-color.css @@ -1,46 +1,25 @@ body { - text-align: center; - margin: 0 0; - padding: 0; - overflow: hidden; - font-family: 'Open Sans'; color: white; - background-color: white; } -main { - display: -webkit-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - align-items: center; - height: 100vh; +#trend { + -webkit-transform: translateX(1%); + -webkit-flex-direction: column; + flex-direction: column; } -.inner { - width: 100%; - -webkit-transform: translateY(-5%); +#bgnow { + display: inline-block; + vertical-align: middle; } -#bgnow { - font-weight: 700; - font-size: 40vmin; +#delta { + font-size: 16vmin; + vertical-align: middle; } -#trend { - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -ms-flex-align: center; - -webkit-align-items: center; - -webkit-transform: translateX(1%); - align-items: center; - justify-content: center; - -webkit-flex-direction: column; - flex-direction: column; +#innerTrend { + word-spacing: 2em; } #arrowDiv { @@ -50,31 +29,4 @@ main { img#arrow { height: 30vmin; -} - -#staleTime { - flex-grow: 1; - font-size: 6vmin; - display: none; -} - -#clock { - display: none; -} - -.close { - color: white; - font: 4em 'Open Sans'; - position: absolute; - right: 20px; - text-decoration: none; -} - -.close:after { - content: '\00D7'; -} - -.hidden { - opacity: 0; - transition: opacity 0.5s linear; } \ No newline at end of file diff --git a/views/clockviews/clock-shared.css b/views/clockviews/clock-shared.css new file mode 100644 index 00000000000..83328fe4114 --- /dev/null +++ b/views/clockviews/clock-shared.css @@ -0,0 +1,76 @@ +body { + text-align: center; + margin: 0 0; + padding: 0; + overflow: hidden; + font-family: 'Open Sans'; + color: grey; + background-color: black; +} + +main { + display: -webkit-box; + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + -webkit-align-items: center; + align-items: center; + height: 100vh; +} + +.inner { + width: 100%; + -webkit-transform: translateY(-5%); +} + +#bgnow { + font-weight: 700; + font-size: 40vmin; +} + +#trend { + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + -ms-flex-align: center; + -webkit-align-items: center; + align-items: center; + justify-content: center; + -webkit-flex-direction: row; + flex-direction: row; +} + +#staleTime { + flex-grow: 1; + font-size: 6vmin; + display: none; +} + +#clock { + display: none; +} + +#delta { + display: none; +} + +.close { + color: white; + font: 4em 'Open Sans'; + position: absolute; + top: 0; + right: 20px; + text-decoration: none; + z-index: 10; +} + +.close:after { + content: '\00D7'; +} + +.hidden { + opacity: 0; + transition: opacity 0.5s linear; +} \ No newline at end of file diff --git a/views/clockviews/clock.css b/views/clockviews/clock.css index e73f715061f..96ffe68b84a 100644 --- a/views/clockviews/clock.css +++ b/views/clockviews/clock.css @@ -1,71 +1,5 @@ -body { - text-align: center; - margin: 0 0; - padding: 0; - overflow: hidden; - font-family: 'Open Sans'; - color: grey; - background-color: black; -} - -main { - display: -webkit-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - align-items: center; - height: 100vh; -} - -.inner { - width: 100%; - -webkit-transform: translateY(-5%); -} - -#bgnow { - font-weight: 700; - font-size: 40vmin; -} - #trend { - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -ms-flex-align: center; - -webkit-align-items: center; -webkit-transform: translateX(1%); - align-items: center; - justify-content: center; -webkit-flex-direction: column; flex-direction: column; -} - -#staleTime { - flex-grow: 1; - font-size: 6vmin; - display: none; -} - -#clock { - display: none; -} - -.close { - color: white; - font: 4em 'Open Sans'; - position: absolute; - right: 20px; - text-decoration: none; -} - -.close:after { - content: '\00D7'; -} - -.hidden { - opacity: 0; - transition: opacity 0.5s linear; } \ No newline at end of file diff --git a/views/clockviews/shared.html b/views/clockviews/shared.html index 401b5bb3c25..cb27b137dfb 100644 --- a/views/clockviews/shared.html +++ b/views/clockviews/shared.html @@ -20,17 +20,20 @@ -
+
-
+
+ + +
arrow
diff --git a/views/index.html b/views/index.html index 3a156008903..b80e11ddd42 100644 --- a/views/index.html +++ b/views/index.html @@ -1,5 +1,7 @@ - + + manifest="appcache/nightscout-<%= locals.cachebuster %>.appcache" + <% } %>> @@ -150,14 +152,19 @@
+
+
    -
  • 2HR
  • -
  • 3HR
  • -
  • 6HR
  • -
  • 12HR
  • -
  • 24HR
  • +
  • Hours:
  • +
  • 2
  • +
  • 3
  • +
  • 4
  • +
  • 6
  • +
  • 12
  • +
  • 24
  • ...
+