├── .editorconfig ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── README.md ├── app ├── assets │ └── images │ │ ├── icons │ │ ├── button │ │ │ ├── pressed.png │ │ │ └── released.png │ │ ├── buzzer │ │ │ ├── off.png │ │ │ └── on.png │ │ ├── common │ │ │ └── unknown.png │ │ ├── condition │ │ │ ├── clear-day.png │ │ │ ├── clear-night.png │ │ │ ├── cloudy.png │ │ │ ├── fog.png │ │ │ ├── partly-cloudy-day.png │ │ │ ├── partly-cloudy-night.png │ │ │ ├── rain.png │ │ │ ├── sleet.png │ │ │ ├── snow.png │ │ │ └── wind.png │ │ ├── door │ │ │ ├── door-close.png │ │ │ └── door-open.png │ │ ├── heater │ │ │ ├── anti-freeze.png │ │ │ ├── comfort.png │ │ │ ├── heater_off.png │ │ │ └── night.png │ │ ├── humidity │ │ │ └── drop.png │ │ ├── light │ │ │ ├── light-off.png │ │ │ └── light-on.png │ │ ├── lock │ │ │ ├── closed.png │ │ │ └── open.png │ │ ├── luminosity │ │ │ └── sensor.png │ │ ├── motion │ │ │ ├── movement.png │ │ │ └── none.png │ │ ├── psd │ │ │ ├── heater.psd │ │ │ ├── homie.psd │ │ │ ├── light.psd │ │ │ └── shutters.psd │ │ ├── shutters │ │ │ ├── 0.png │ │ │ ├── 10.png │ │ │ ├── 100.png │ │ │ ├── 20.png │ │ │ ├── 30.png │ │ │ ├── 40.png │ │ │ ├── 50.png │ │ │ ├── 60.png │ │ │ ├── 70.png │ │ │ ├── 80.png │ │ │ └── 90.png │ │ ├── sound │ │ │ └── wave.png │ │ ├── switch │ │ │ ├── switch-off.png │ │ │ └── switch-on.png │ │ ├── temperature │ │ │ └── thermometer.png │ │ └── window │ │ │ ├── window-close.png │ │ │ └── window-open.png │ │ └── logo_white.png ├── components │ ├── App.vue │ ├── devices │ │ ├── Button.vue │ │ ├── Buzzer.vue │ │ ├── Door.vue │ │ ├── Heater.vue │ │ ├── Humidity.vue │ │ ├── Light.vue │ │ ├── Lock.vue │ │ ├── Luminosity.vue │ │ ├── Motion.vue │ │ ├── Node.js │ │ ├── NodeComponent.vue │ │ ├── Shutters.vue │ │ ├── Sound.vue │ │ ├── Statistics.vue │ │ ├── Switch.vue │ │ ├── Temperature.vue │ │ └── Window.vue │ ├── help │ │ ├── Automation.vue │ │ ├── Devices.vue │ │ ├── Help.vue │ │ ├── Overview.vue │ │ └── Settings.vue │ ├── pages │ │ ├── Automation.vue │ │ ├── Devices.vue │ │ ├── Overview.vue │ │ └── Settings.vue │ ├── partials │ │ ├── Footer.vue │ │ └── Header.vue │ └── standalones │ │ ├── AddDevice.vue │ │ └── Authentication.vue ├── constants.js ├── helpers │ ├── conversions.js │ └── ws-request.js ├── index.html ├── index.js ├── lib │ └── websocket.js ├── services │ └── api.js ├── static │ ├── 3rd │ │ └── blockly │ │ │ ├── blockly_compressed.js │ │ │ ├── blocks_compressed.js │ │ │ ├── fr.js │ │ │ └── javascript_compressed.js │ └── favicon.ico └── store │ └── app.js ├── banner.png ├── common ├── events.js ├── node-types.js ├── statistics.js └── ws-messages.js ├── dev.bat ├── emulator └── index.js ├── package.json ├── server ├── bin │ └── cli.js ├── bindings │ ├── aqara.js │ └── yeelight.js ├── index.js ├── lib │ ├── automator.js │ ├── bridges │ │ ├── infrastructure-database.js │ │ ├── infrastructure-websocket.js │ │ └── mqtt-infrastructure.js │ ├── client.js │ ├── database.js │ ├── hash.js │ ├── homie-topic-parser.js │ ├── infrastructure │ │ ├── device.js │ │ ├── floor.js │ │ ├── infrastructure.js │ │ ├── node.js │ │ ├── property.js │ │ ├── room.js │ │ └── tag.js │ ├── log.js │ ├── mqtt-client.js │ ├── settings.js │ ├── statistics.js │ ├── validators │ │ ├── schemas │ │ │ └── settings.js │ │ └── settings.js │ └── websocket-server.js ├── migrations │ ├── 20161219145339_initial.js │ ├── 20161219151706_tags.js │ ├── 20161219152015_house-modelization.js │ ├── 20170116152739_automation.js │ └── 20170119120420_settings.js ├── models │ ├── Device.js │ ├── Floor.js │ ├── Node.js │ ├── Property.js │ ├── Room.js │ ├── Tag.js │ ├── auth-token.js │ ├── automation-script.js │ ├── device.js │ ├── floor.js │ ├── node.js │ ├── property-history.js │ ├── property.js │ ├── room.js │ ├── setting.js │ └── tag.js ├── services │ └── database.js └── start.js ├── settings.toml ├── test ├── homie-topic-parser.js └── ws-messages.js ├── vue.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | npm-debug.log* 3 | yarn-error.log* 4 | 5 | # Databases 6 | *.db 7 | *.db-journal 8 | 9 | # Build 10 | dist-server 11 | dist-app 12 | 13 | # Dependency directories 14 | node_modules 15 | 16 | # Coverage 17 | coverage 18 | .nyc_output 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 7 4 | 5 | cache: yarn 6 | 7 | env: 8 | - SIDE=server 9 | - SIDE=app 10 | 11 | # Yarn automatically installed and launched at install step 12 | 13 | script: 14 | - | 15 | if [[ "$SIDE" == "server" ]]; then 16 | npm run server-lint 17 | npm run server-test 18 | npm run server-build 19 | fi 20 | - | 21 | if [[ "$SIDE" == "app" ]]; then 22 | npm run app-build 23 | fi 24 | 25 | after_success: 26 | - | 27 | if [[ "$SIDE" == "server" ]]; then 28 | ./node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls 29 | fi 30 | 31 | before_deploy: 32 | - cd dist-server 33 | 34 | deploy: 35 | skip_cleanup: true 36 | on: 37 | branch: master 38 | tags: true 39 | node: 7 40 | condition: "$SIDE == server" 41 | provider: npm 42 | email: "$NPM_EMAIL" 43 | api_key: "$NPM_TOKEN" 44 | 45 | after_deploy: 46 | - | 47 | body='{ 48 | "request": { 49 | "branch": "master", 50 | "config": { 51 | "env": { 52 | "global": ["HOMIE_DASHBOARD_VERSION=\"'"$TRAVIS_TAG"'\""] 53 | } 54 | } 55 | } 56 | }' 57 | 58 | curl -s -X POST \ 59 | -H "Content-Type: application/json" \ 60 | -H "Accept: application/json" \ 61 | -H "Travis-API-Version: 3" \ 62 | -H "Authorization: token $TRAVIS_TOKEN" \ 63 | -d "$body" \ 64 | https://api.travis-ci.org/repo/INTECH-RGB%2Fhomie-dashboard-docker/requests 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Homie Dashboard code 2 | 3 | ## Styleguides 4 | 5 | ### Git Commit Messages 6 | 7 | * Use the present tense ("Add feature" not "Added feature") 8 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 9 | * Limit the first line to 72 characters or less 10 | * Reference issues and pull requests liberally 11 | * When only changing documentation, include `[ci skip]` in the commit description 12 | * Consider starting the commit message with an applicable emoji: 13 | * :bookmark: `:bookmark:` when bumping version 14 | * :sparkles: `:sparkles:` when adding a new feature 15 | * :art: `:art:` when improving the format/structure of the code 16 | * :racehorse: `:racehorse:` when improving performance 17 | * :non-potable_water: `:non-potable_water:` when plugging memory leaks 18 | * :memo: `:memo:` when writing docs 19 | * :penguin: `:penguin:` when fixing something on Linux 20 | * :apple: `:apple:` when fixing something on macOS 21 | * :checkered_flag: `:checkered_flag:` when fixing something on Windows 22 | * :bug: `:bug:` when fixing a bug 23 | * :fire: `:fire:` when removing code or files 24 | * :green_heart: `:green_heart:` when fixing the CI build 25 | * :white_check_mark: `:white_check_mark:` when adding tests 26 | * :lock: `:lock:` when dealing with security 27 | * :arrow_up: `:arrow_up:` when upgrading dependencies 28 | * :arrow_down: `:arrow_down:` when downgrading dependencies 29 | * :shirt: `:shirt:` when removing linter warnings 30 | 31 | ### JavaScript Styleguide 32 | 33 | All JavaScript must adhere to [JavaScript Standard Style](http://standardjs.com/). 34 | 35 | * Prefer the object spread operator (`{...anotherObj}`) to `Object.assign()` 36 | * Inline `export`s with expressions whenever possible 37 | ```js 38 | // Use this: 39 | export default class ClassName { 40 | 41 | } 42 | 43 | // Instead of: 44 | class ClassName { 45 | 46 | } 47 | export default ClassName 48 | ``` 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Homie Dashboard banner](banner.png) 2 | 3 |

Homie Dashboard

4 | 5 | [![Build Status](https://travis-ci.org/INTECH-RGB/homie-dashboard.svg?branch=master)](https://travis-ci.org/INTECH-RGB/homie-dashboard) [![Coverage Status](https://coveralls.io/repos/github/INTECH-RGB/homie-dashboard/badge.svg?branch=master)](https://coveralls.io/github/INTECH-RGB/homie-dashboard?branch=master) [![Known Vulnerabilities](https://snyk.io/test/github/intech-rgb/homie-dashboard/badge.svg)](https://snyk.io/test/github/intech-rgb/homie-dashboard) [![dependencies Status](https://david-dm.org/INTECH-RGB/homie-dashboard/status.svg)](https://david-dm.org/INTECH-RGB/homie-dashboard) [![devDependencies Status](https://david-dm.org/INTECH-RGB/homie-dashboard/dev-status.svg)](https://david-dm.org/INTECH-RGB/homie-dashboard?type=dev) 6 | 7 | ## Contribute 8 | 9 | [![Greenkeeper badge](https://badges.greenkeeper.io/INTECH-RGB/homie-dashboard.svg)](https://greenkeeper.io/) 10 | 11 | * Install [Node.js](https://nodejs.org/en/) >= v7.0.0 12 | * Install [Yarn](https://yarnpkg.com/) (optional, recommended): `npm install -g yarn` 13 | * Clone this repository 14 | * `cd` into it 15 | * Install the project dependencies: 16 | * With Yarn (recommended): `yarn install` 17 | * Or with npm: `npm install` 18 | * Run one of the following scripts: 19 | * `npm run app-build`: build the Web Application into the `dist-app` directory 20 | * `npm run app-dev`: start a webpack development server, with hot-reload 21 | * `npm run server-build`: build the server from an ES2015 syntax to Node.js runnable code in the `dist-server` directory 22 | * `npm run server-dev`: start the server for development with automatic restart on code changes 23 | * `npm run server-lint`: lint the server 24 | * `npm run server-serve`: start the compiled version of the server (run from the `dist-server` directory) 25 | * `npm run server-test`: test the server 26 | 27 | ## Team stuff 28 | 29 | We all code on Windows. Install [Mosquitto](https://mosquitto.org/download/) and run in the context of the repo: `dev.bat`. This will start the MQTT broker, the development server and the development Web Application. 30 | -------------------------------------------------------------------------------- /app/assets/images/icons/button/pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/button/pressed.png -------------------------------------------------------------------------------- /app/assets/images/icons/button/released.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/button/released.png -------------------------------------------------------------------------------- /app/assets/images/icons/buzzer/off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/buzzer/off.png -------------------------------------------------------------------------------- /app/assets/images/icons/buzzer/on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/buzzer/on.png -------------------------------------------------------------------------------- /app/assets/images/icons/common/unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/common/unknown.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/clear-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/clear-day.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/clear-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/clear-night.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/cloudy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/cloudy.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/fog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/fog.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/partly-cloudy-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/partly-cloudy-day.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/partly-cloudy-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/partly-cloudy-night.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/rain.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/sleet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/sleet.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/snow.png -------------------------------------------------------------------------------- /app/assets/images/icons/condition/wind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/condition/wind.png -------------------------------------------------------------------------------- /app/assets/images/icons/door/door-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/door/door-close.png -------------------------------------------------------------------------------- /app/assets/images/icons/door/door-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/door/door-open.png -------------------------------------------------------------------------------- /app/assets/images/icons/heater/anti-freeze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/heater/anti-freeze.png -------------------------------------------------------------------------------- /app/assets/images/icons/heater/comfort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/heater/comfort.png -------------------------------------------------------------------------------- /app/assets/images/icons/heater/heater_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/heater/heater_off.png -------------------------------------------------------------------------------- /app/assets/images/icons/heater/night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/heater/night.png -------------------------------------------------------------------------------- /app/assets/images/icons/humidity/drop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/humidity/drop.png -------------------------------------------------------------------------------- /app/assets/images/icons/light/light-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/light/light-off.png -------------------------------------------------------------------------------- /app/assets/images/icons/light/light-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/light/light-on.png -------------------------------------------------------------------------------- /app/assets/images/icons/lock/closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/lock/closed.png -------------------------------------------------------------------------------- /app/assets/images/icons/lock/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/lock/open.png -------------------------------------------------------------------------------- /app/assets/images/icons/luminosity/sensor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/luminosity/sensor.png -------------------------------------------------------------------------------- /app/assets/images/icons/motion/movement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/motion/movement.png -------------------------------------------------------------------------------- /app/assets/images/icons/motion/none.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/motion/none.png -------------------------------------------------------------------------------- /app/assets/images/icons/psd/heater.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/psd/heater.psd -------------------------------------------------------------------------------- /app/assets/images/icons/psd/homie.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/psd/homie.psd -------------------------------------------------------------------------------- /app/assets/images/icons/psd/light.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/psd/light.psd -------------------------------------------------------------------------------- /app/assets/images/icons/psd/shutters.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/psd/shutters.psd -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/0.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/10.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/100.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/20.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/30.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/40.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/50.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/60.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/70.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/80.png -------------------------------------------------------------------------------- /app/assets/images/icons/shutters/90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/shutters/90.png -------------------------------------------------------------------------------- /app/assets/images/icons/sound/wave.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/sound/wave.png -------------------------------------------------------------------------------- /app/assets/images/icons/switch/switch-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/switch/switch-off.png -------------------------------------------------------------------------------- /app/assets/images/icons/switch/switch-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/switch/switch-on.png -------------------------------------------------------------------------------- /app/assets/images/icons/temperature/thermometer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/temperature/thermometer.png -------------------------------------------------------------------------------- /app/assets/images/icons/window/window-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/window/window-close.png -------------------------------------------------------------------------------- /app/assets/images/icons/window/window-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/icons/window/window-open.png -------------------------------------------------------------------------------- /app/assets/images/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/assets/images/logo_white.png -------------------------------------------------------------------------------- /app/components/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 43 | 44 | 55 | -------------------------------------------------------------------------------- /app/components/devices/Button.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | 33 | 36 | -------------------------------------------------------------------------------- /app/components/devices/Buzzer.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | 48 | 51 | -------------------------------------------------------------------------------- /app/components/devices/Door.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 31 | 32 | 35 | -------------------------------------------------------------------------------- /app/components/devices/Heater.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 61 | 62 | 65 | -------------------------------------------------------------------------------- /app/components/devices/Humidity.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /app/components/devices/Light.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 103 | 104 | 111 | -------------------------------------------------------------------------------- /app/components/devices/Lock.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 51 | 52 | 55 | -------------------------------------------------------------------------------- /app/components/devices/Luminosity.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | 31 | 34 | -------------------------------------------------------------------------------- /app/components/devices/Motion.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | 33 | 36 | -------------------------------------------------------------------------------- /app/components/devices/Node.js: -------------------------------------------------------------------------------- 1 | import Component from './NodeComponent' 2 | 3 | const mixin = { 4 | props: ['nodeData'] 5 | } 6 | 7 | export { mixin, Component } 8 | -------------------------------------------------------------------------------- /app/components/devices/Shutters.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 62 | -------------------------------------------------------------------------------- /app/components/devices/Sound.vue: -------------------------------------------------------------------------------- 1 | 20 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /app/components/devices/Statistics.vue: -------------------------------------------------------------------------------- 1 | 25 | 228 | 229 | 232 | -------------------------------------------------------------------------------- /app/components/devices/Switch.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | 48 | 51 | -------------------------------------------------------------------------------- /app/components/devices/Temperature.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 35 | 36 | 39 | -------------------------------------------------------------------------------- /app/components/devices/Window.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 33 | 34 | 37 | -------------------------------------------------------------------------------- /app/components/help/Automation.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | 27 | 29 | -------------------------------------------------------------------------------- /app/components/help/Devices.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 27 | 28 | 30 | -------------------------------------------------------------------------------- /app/components/help/Help.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 26 | 27 | 29 | -------------------------------------------------------------------------------- /app/components/help/Overview.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | 36 | 38 | -------------------------------------------------------------------------------- /app/components/help/Settings.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 26 | -------------------------------------------------------------------------------- /app/components/pages/Devices.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 190 | 191 | 212 | -------------------------------------------------------------------------------- /app/components/pages/Settings.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | 61 | 63 | -------------------------------------------------------------------------------- /app/components/partials/Footer.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 22 | 23 | 25 | -------------------------------------------------------------------------------- /app/components/partials/Header.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 114 | 115 | 119 | -------------------------------------------------------------------------------- /app/components/standalones/AddDevice.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 168 | 169 | 171 | -------------------------------------------------------------------------------- /app/components/standalones/Authentication.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 56 | 57 | 62 | -------------------------------------------------------------------------------- /app/constants.js: -------------------------------------------------------------------------------- 1 | export const HOMIE_ESP8266_AP_SERVER_URL = 'http://192.168.123.1' 2 | -------------------------------------------------------------------------------- /app/helpers/conversions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts an RGB color value to HSL. Conversion formula 3 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 4 | * Assumes r, g, and b are contained in the set [0, 255] and 5 | * returns h, s, and l in the set [0, 1]. 6 | * 7 | * @param Number r The red color value 8 | * @param Number g The green color value 9 | * @param Number b The blue color value 10 | * @return Array The HSL representation 11 | */ 12 | export function rgbToHsl (r, g, b) { 13 | r /= 255, g /= 255, b /= 255; 14 | 15 | var max = Math.max(r, g, b), min = Math.min(r, g, b); 16 | var h, s, l = (max + min) / 2; 17 | 18 | if (max == min) { 19 | h = s = 0; // achromatic 20 | } else { 21 | var d = max - min; 22 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 23 | 24 | switch (max) { 25 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 26 | case g: h = (b - r) / d + 2; break; 27 | case b: h = (r - g) / d + 4; break; 28 | } 29 | 30 | h /= 6; 31 | } 32 | 33 | return [ h, s, l ]; 34 | } 35 | 36 | /** 37 | * Converts an HSL color value to RGB. Conversion formula 38 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 39 | * Assumes h, s, and l are contained in the set [0, 1] and 40 | * returns r, g, and b in the set [0, 255]. 41 | * 42 | * @param Number h The hue 43 | * @param Number s The saturation 44 | * @param Number l The lightness 45 | * @return Array The RGB representation 46 | */ 47 | export function hslToRgb (h, s, l) { 48 | var r, g, b; 49 | 50 | if (s == 0) { 51 | r = g = b = l; // achromatic 52 | } else { 53 | function hue2rgb (p, q, t) { 54 | if (t < 0) t += 1; 55 | if (t > 1) t -= 1; 56 | if (t < 1/6) return p + (q - p) * 6 * t; 57 | if (t < 1/2) return q; 58 | if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; 59 | return p; 60 | } 61 | 62 | var q = l < 0.5 ? l * (1 + s) : l + s - l * s; 63 | var p = 2 * l - q; 64 | 65 | r = hue2rgb(p, q, h + 1/3); 66 | g = hue2rgb(p, q, h); 67 | b = hue2rgb(p, q, h - 1/3); 68 | } 69 | 70 | return [ r * 255, g * 255, b * 255 ]; 71 | } 72 | -------------------------------------------------------------------------------- /app/helpers/ws-request.js: -------------------------------------------------------------------------------- 1 | import {generateMessage, parseMessage, MESSAGE_TYPES} from '../../common/ws-messages' 2 | 3 | export default function wsRequest (opts) { 4 | return new Promise(function (resolve, reject) { 5 | const request = generateMessage({ type: MESSAGE_TYPES.REQUEST, method: opts.method, parameters: opts.parameters }) 6 | 7 | const removeWsListener = () => opts.ws.removeListener('message', onMessage) 8 | 9 | let timeout 10 | const onMessage = function (message) { 11 | const parsed = parseMessage(message) 12 | 13 | if (parsed.type === MESSAGE_TYPES.RESPONSE && parsed.id === request.id) { 14 | resolve(parsed.value) 15 | clearTimeout(timeout) 16 | removeWsListener() 17 | } 18 | } 19 | 20 | timeout = setTimeout(function onTimeout () { 21 | reject(new Error('Response timeout')) 22 | removeWsListener() 23 | }, 2500) 24 | 25 | opts.ws.on('message', onMessage) 26 | opts.ws.send(request.text) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | <% htmlWebpackPlugin.files.css.forEach(function (css) { %> 10 | 11 | <% }) %> 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | <% htmlWebpackPlugin.files.js.forEach(function (js) { %> 21 | 22 | <% }) %> 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import EVA from 'eva.js' 2 | import App from './components/App' 3 | 4 | import initializeStore, {SET_INTENDED_ROUTE, SET_IS_AUTHENTIFIED} from './store/app' 5 | 6 | import Overview from './components/pages/Overview' 7 | import Devices from './components/pages/Devices' 8 | import Automation from './components/pages/Automation' 9 | import Settings from './components/pages/Settings' 10 | 11 | import Authentication from './components/standalones/Authentication' 12 | import AddDevice from './components/standalones/AddDevice' 13 | 14 | const app = new EVA({ mode: 'history' }) 15 | 16 | initializeStore(app) 17 | 18 | app.router(route => [ 19 | { meta: { title: "Vue d'ensemble" }, ...route('/', Overview) }, 20 | { meta: { title: 'Périphériques' }, ...route('/peripheriques', Devices) }, 21 | { meta: { title: 'Automatisation' }, ...route('/automatisation', Automation) }, 22 | { meta: { title: 'Paramètres' }, ...route('/parametres', Settings) }, 23 | 24 | { meta: { title: 'Authentifiez-vous', standalone: true }, ...route('/authentification', Authentication) }, 25 | { meta: { title: "Ajout d'un périphérique", standalone: true }, ...route('/ajout-peripherique', AddDevice) }, 26 | 27 | { path: '*', redirect: '/' } 28 | ]) 29 | 30 | app.$router.beforeEach((to, from, next) => { 31 | if (app.$store.state.isAuthentified) { 32 | if (to.path === '/authentification') return next('/') 33 | } else { 34 | if (window.localStorage.getItem('accessTokenSet') && !app.$store.state.websocketAuthFailed) { 35 | app.$store.commit(SET_IS_AUTHENTIFIED, true) 36 | app.$store.dispatch('startWs') 37 | if (to.path === '/authentification') return next('/') 38 | else return next() 39 | } 40 | 41 | if (to.path !== '/authentification') { 42 | app.$store.commit(SET_INTENDED_ROUTE, to.path) 43 | return next('/authentification') 44 | } 45 | } 46 | 47 | next() 48 | }) 49 | 50 | app.$router.afterEach((to, from) => { 51 | document.title = `${to.meta.title} - Homie Dashboard` 52 | }) 53 | app.start(App, '#app') 54 | -------------------------------------------------------------------------------- /app/lib/websocket.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3' 2 | 3 | export default class WebSocket extends EventEmitter { 4 | constructor (url) { 5 | super() 6 | 7 | this.url = url 8 | } 9 | 10 | start () { 11 | this.stopped = false 12 | this.ws = new window.WebSocket(this.url) 13 | this.ws.onopen = (event) => { 14 | this.emit('open', event) 15 | } 16 | this.ws.onclose = (event) => { 17 | this.emit('close', event) 18 | 19 | if (!this.stopped) setTimeout(this.start.bind(this), 2000) 20 | } 21 | this.ws.onerror = (err) => { 22 | this.emit('error', err) 23 | } 24 | this.ws.onmessage = (event) => { 25 | this.emit('message', event.data) 26 | } 27 | } 28 | 29 | stop () { 30 | if (this.ws) this.ws.close() 31 | this.stopped = true 32 | } 33 | 34 | send (message) { 35 | this.ws.send(message) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | let baseUrl 4 | if (process.env.NODE_ENV === 'production') { 5 | const l = window.location 6 | baseUrl = ((l.protocol === 'https:') ? 'https://' : 'http://') + l.host 7 | } else { 8 | baseUrl = 'http://127.0.0.1:5000' 9 | } 10 | 11 | export async function login (opts) { 12 | return new Promise((resolve, reject) => { 13 | axios.post(`${baseUrl}/login`, opts, { withCredentials: true }).then((res) => { 14 | return resolve(true) 15 | }).catch((err) => { 16 | if (err.response) return resolve(false) 17 | else return reject(err) 18 | }) 19 | }) 20 | } 21 | 22 | export async function logout () { 23 | return new Promise((resolve, reject) => { 24 | axios.post(`${baseUrl}/logout`, null, { withCredentials: true }).then((res) => { 25 | return resolve(true) 26 | }).catch((err) => { 27 | if (err.response) return resolve(false) 28 | else return reject(err) 29 | }) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/app/static/favicon.ico -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/INTECH-RGB/homie-dashboard/3452dff8a787ac42f3af54877b2c90f3e2861eb6/banner.png -------------------------------------------------------------------------------- /common/events.js: -------------------------------------------------------------------------------- 1 | export const VERSION = 'VERSION' 2 | export const INFRASTRUCTURE = 'INFRASTRUCTURE' 3 | export const INFRASTRUCTURE_PATCH = 'INFRASTRUCTURE_PATCH' 4 | -------------------------------------------------------------------------------- /common/node-types.js: -------------------------------------------------------------------------------- 1 | export const CONDITIONS = { 2 | 'IS_ON': { id: 'isOn', hasField: false }, 3 | 'IS_OFF': { id: 'isOff', hasField: false }, 4 | 'IS_OPEN': { id: 'isOpen', hasField: false }, 5 | 'IS_CLOSE': { id: 'isClose', hasField: false }, 6 | 'IS_MOTION': { id: 'isMotion', hasField: false }, 7 | 'IS_NOT_MOTION': { id: 'isNotMotion', hasField: false }, 8 | 'IS_PRESSED': { id: 'isPressed', hasField: false }, 9 | 'IS_RELEASED': { id: 'isReleased', hasField: false }, 10 | 'EQUALS': { id: 'equals', hasField: true }, 11 | 'ABOVE': { id: 'above', hasField: true }, 12 | 'UNDER': { id: 'under', hasField: true } 13 | } 14 | 15 | export const MUTATIONS = { 16 | 'SET_ON': { id: 'setOn', hasField: false }, 17 | 'SET_OFF': { id: 'setOff', hasField: false }, 18 | 'SET_OPEN': { id: 'setOpen', hasField: false }, 19 | 'SET_CLOSE': { id: 'setClose', hasField: false }, 20 | 'SET_COLOR': { id: 'setColor', hasField: true }, 21 | 'SET_PERCENTAGE': { id: 'setPercentage', hasField: true }, 22 | 'SET_FLOAT': { id: 'setFloat', hasField: true } 23 | } 24 | 25 | export const NODE_TYPES = { 26 | 'switch': { 27 | 'on': { 28 | settable: true, 29 | conditions: [CONDITIONS.IS_ON, CONDITIONS.IS_OFF], 30 | mutations: [MUTATIONS.SET_ON, MUTATIONS.SET_OFF] 31 | } 32 | }, 33 | 'button': { 34 | 'pressed': { 35 | settable: false, 36 | conditions: [CONDITIONS.IS_PRESSED, CONDITIONS.IS_RELEASED] 37 | } 38 | }, 39 | 'light': { 40 | 'color': { 41 | settable: true, 42 | conditions: [CONDITIONS.EQUALS], 43 | mutations: [MUTATIONS.SET_COLOR] 44 | }, 45 | 'intensity': { 46 | settable: true, 47 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER], 48 | mutations: [MUTATIONS.SET_PERCENTAGE] 49 | } 50 | }, 51 | 'temperature': { 52 | 'degrees': { 53 | settable: false, 54 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER] 55 | } 56 | }, 57 | 'humidity': { 58 | 'percentage': { 59 | settable: false, 60 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER] 61 | } 62 | }, 63 | 'shutters': { 64 | 'percentage': { 65 | settable: true, 66 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER], 67 | mutations: [MUTATIONS.SET_PERCENTAGE] 68 | } 69 | }, 70 | 'door': { 71 | 'open': { 72 | settable: false, 73 | conditions: [CONDITIONS.IS_OPEN, CONDITIONS.IS_CLOSE] 74 | } 75 | }, 76 | 'window': { 77 | 'open': { 78 | settable: false, 79 | conditions: [CONDITIONS.IS_OPEN, CONDITIONS.IS_CLOSE] 80 | } 81 | }, 82 | 'lock': { 83 | 'open': { 84 | settable: true, 85 | conditions: [CONDITIONS.IS_OPEN, CONDITIONS.IS_CLOSE], 86 | mutations: [MUTATIONS.SET_OPEN, MUTATIONS.SET_CLOSE] 87 | } 88 | }, 89 | 'heater': { 90 | 'degrees': { 91 | settable: true, 92 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER], 93 | mutations: [MUTATIONS.SET_FLOAT] 94 | } 95 | }, 96 | 'sound': { 97 | 'intensity': { 98 | settable: false, 99 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER] 100 | } 101 | }, 102 | 'luminosity': { 103 | 'lux': { 104 | settable: false, 105 | conditions: [CONDITIONS.EQUALS, CONDITIONS.ABOVE, CONDITIONS.UNDER] 106 | } 107 | }, 108 | 'motion': { 109 | 'motion': { 110 | settable: false, 111 | conditions: [CONDITIONS.IS_MOTION, CONDITIONS.IS_NOT_MOTION] 112 | } 113 | }, 114 | 'buzzer': { 115 | 'buzzing': { 116 | settable: true, 117 | conditions: [CONDITIONS.IS_ON, CONDITIONS.IS_OFF], 118 | mutations: [MUTATIONS.SET_ON, MUTATIONS.SET_OFF] 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /common/statistics.js: -------------------------------------------------------------------------------- 1 | export const STATS_TYPES = { 2 | GRAPH: 'GRAPH', 3 | LIST: 'LIST' 4 | } 5 | 6 | export const STATS_GRANULARITY = { 7 | HOUR: 'HOUR', 8 | DAY: 'DAY', 9 | MONTH: 'MONTH' 10 | } 11 | -------------------------------------------------------------------------------- /common/ws-messages.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid' 2 | 3 | export const MESSAGE_TYPES = { 4 | EVENT: 0, 5 | REQUEST: 1, 6 | RESPONSE: 2 7 | } 8 | 9 | export function parseMessage (text) { 10 | const parsed = JSON.parse(text) 11 | switch (parsed[0]) { 12 | case MESSAGE_TYPES.EVENT: 13 | return { 14 | type: MESSAGE_TYPES.EVENT, 15 | event: parsed[1], 16 | value: parsed[2] 17 | } 18 | case MESSAGE_TYPES.REQUEST: 19 | return { 20 | type: MESSAGE_TYPES.REQUEST, 21 | id: parsed[1], 22 | method: parsed[2], 23 | parameters: parsed[3] 24 | } 25 | case MESSAGE_TYPES.RESPONSE: 26 | return { 27 | type: MESSAGE_TYPES.RESPONSE, 28 | id: parsed[1], 29 | value: parsed[2] 30 | } 31 | } 32 | } 33 | 34 | export function generateMessage (options) { 35 | switch (options.type) { 36 | case MESSAGE_TYPES.EVENT: 37 | return JSON.stringify([MESSAGE_TYPES.EVENT, options.event, options.value]) 38 | case MESSAGE_TYPES.REQUEST: 39 | const id = uuid() 40 | return { 41 | id, 42 | text: JSON.stringify([MESSAGE_TYPES.REQUEST, id, options.method, options.parameters]) 43 | } 44 | case MESSAGE_TYPES.RESPONSE: 45 | return JSON.stringify([MESSAGE_TYPES.RESPONSE, options.id, options.value]) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /dev.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | where npm >nul 2>nul 4 | if %ERRORLEVEL% neq 0 ( 5 | echo npm not found! 6 | exit /b 1 7 | ) 8 | 9 | if not defined MOSQUITTO_DIR ( 10 | echo mosquitto not found! 11 | exit /b 1 12 | ) 13 | 14 | echo Starting MQTT broker (RED) 15 | start cmd /k "color 4F && "%MOSQUITTO_DIR%\mosquitto" -v" 16 | echo Starting development server (BLUE) 17 | start cmd /k "color 1F && npm run server-dev" 18 | echo Starting emulator (GREEN) 19 | start cmd /k "color 2F && npm run emulator-start" 20 | echo Starting development web app (PURPLE) 21 | start cmd /k "color 5F && npm run app-dev" 22 | 23 | echo Success 24 | -------------------------------------------------------------------------------- /emulator/index.js: -------------------------------------------------------------------------------- 1 | import log from '../server/lib/log' 2 | import mqtt from 'mqtt' 3 | 4 | const BASE_TOPIC = 'homie' 5 | const STATS_INTERVAL_IN_SECONDS = 3 6 | const DEVICES = [ 7 | { 8 | id: 'temperaturedevice', 9 | name: 'Température', 10 | fw: { name: 'firmware', version: '1.0.0' }, 11 | nodes: [ 12 | { 13 | id: 'temperaturenode', 14 | type: 'temperature', 15 | properties: [ 16 | { id: 'degrees', settable: false } 17 | ] 18 | } 19 | ] 20 | }, 21 | { 22 | id: 'lightdevice', 23 | name: 'Lumière', 24 | fw: { name: 'firmware', version: '1.0.0' }, 25 | nodes: [ 26 | { 27 | id: 'lightnode', 28 | type: 'light', 29 | properties: [ 30 | { id: 'color', settable: true }, 31 | { id: 'intensity', settable: true } 32 | ] 33 | } 34 | ] 35 | }, 36 | { 37 | id: 'switchdevice', 38 | name: 'Interrupteur', 39 | fw: { name: 'firmware', version: '1.0.0' }, 40 | nodes: [ 41 | { 42 | id: 'switchnode', 43 | type: 'switch', 44 | properties: [ 45 | { id: 'on', settable: true } 46 | ] 47 | } 48 | ] 49 | }, 50 | { 51 | id: 'humiditydevice', 52 | name: 'Humidité', 53 | fw: { name: 'firmware', version: '1.0.0' }, 54 | nodes: [ 55 | { 56 | id: 'humiditynode', 57 | type: 'humidity', 58 | properties: [ 59 | { id: 'percentage', settable: false } 60 | ] 61 | } 62 | ] 63 | }, 64 | { 65 | id: 'shuttersdevice', 66 | name: 'Volets', 67 | fw: { name: 'firmware', version: '1.0.0' }, 68 | nodes: [ 69 | { 70 | id: 'shuttersnode', 71 | type: 'shutters', 72 | properties: [ 73 | { id: 'percentage', settable: true } 74 | ] 75 | } 76 | ] 77 | }, 78 | { 79 | id: 'doordevice', 80 | name: 'Porte', 81 | fw: { name: 'firmware', version: '1.0.0' }, 82 | nodes: [ 83 | { 84 | id: 'doornode', 85 | type: 'door', 86 | properties: [ 87 | { id: 'open', settable: false } 88 | ] 89 | } 90 | ] 91 | }, 92 | { 93 | id: 'windowdevice', 94 | name: 'Fenêtre', 95 | fw: { name: 'firmware', version: '1.0.0' }, 96 | nodes: [ 97 | { 98 | id: 'windownode', 99 | type: 'window', 100 | properties: [ 101 | { id: 'open', settable: false } 102 | ] 103 | } 104 | ] 105 | }, 106 | { 107 | id: 'lockdevice', 108 | name: 'Verrou', 109 | fw: { name: 'firmware', version: '1.0.0' }, 110 | nodes: [ 111 | { 112 | id: 'locknode', 113 | type: 'lock', 114 | properties: [ 115 | { id: 'open', settable: true } 116 | ] 117 | } 118 | ] 119 | }, 120 | { 121 | id: 'heaterdevice', 122 | name: 'Chauffage', 123 | fw: { name: 'firmware', version: '1.0.0' }, 124 | nodes: [ 125 | { 126 | id: 'heaternode', 127 | type: 'heater', 128 | properties: [ 129 | { id: 'degrees', settable: true } 130 | ] 131 | } 132 | ] 133 | }, 134 | { 135 | id: 'sounddevice', 136 | name: 'Son', 137 | fw: { name: 'firmware', version: '1.0.0' }, 138 | nodes: [ 139 | { 140 | id: 'soundnode', 141 | type: 'sound', 142 | properties: [ 143 | { id: 'intensity', settable: false } 144 | ] 145 | } 146 | ] 147 | }, 148 | { 149 | id: 'luminositydevice', 150 | name: 'Luminosité', 151 | fw: { name: 'firmware', version: '1.0.0' }, 152 | nodes: [ 153 | { 154 | id: 'luminositynode', 155 | type: 'luminosity', 156 | properties: [ 157 | { id: 'lux', settable: false } 158 | ] 159 | } 160 | ] 161 | }, 162 | { 163 | id: 'motiondevice', 164 | name: 'Mouvement', 165 | fw: { name: 'firmware', version: '1.0.0' }, 166 | nodes: [ 167 | { 168 | id: 'motionnode', 169 | type: 'motion', 170 | properties: [ 171 | { id: 'motion', settable: false } 172 | ] 173 | } 174 | ] 175 | }, 176 | { 177 | id: 'buzzerdevice', 178 | name: 'Buzzer', 179 | fw: { name: 'firmware', version: '1.0.0' }, 180 | nodes: [ 181 | { 182 | id: 'buzzernode', 183 | type: 'buzzer', 184 | properties: [ 185 | { id: 'buzzing', settable: true } 186 | ] 187 | } 188 | ] 189 | } 190 | ] 191 | 192 | const client = mqtt.connect('mqtt://127.0.0.1:1883') 193 | const qos1Retained = { qos: 1, retain: true } 194 | const delay = ms => new Promise(resolve => setTimeout(() => resolve(), ms)) 195 | let interval = null 196 | let startTime = null 197 | client.on('connect', async function onConnect () { 198 | log.info('connected to the broker') 199 | 200 | startTime = new Date() 201 | 202 | for (let device of DEVICES) { 203 | log.info(`sending data of device ${device.id}`) 204 | client.publish(`${BASE_TOPIC}/${device.id}/$homie`, '2.0.0', qos1Retained) 205 | client.publish(`${BASE_TOPIC}/${device.id}/$name`, device.name, qos1Retained) 206 | client.publish(`${BASE_TOPIC}/${device.id}/$localip`, '127.0.0.1', qos1Retained) 207 | client.publish(`${BASE_TOPIC}/${device.id}/$mac`, '00:00:00:00:00:00', qos1Retained) 208 | client.publish(`${BASE_TOPIC}/${device.id}/$stats/interval`, STATS_INTERVAL_IN_SECONDS.toString(), qos1Retained) 209 | sendStats(device) 210 | client.publish(`${BASE_TOPIC}/${device.id}/$fw/name`, device.fw.name, qos1Retained) 211 | client.publish(`${BASE_TOPIC}/${device.id}/$fw/version`, device.fw.version, qos1Retained) 212 | client.publish(`${BASE_TOPIC}/${device.id}/$fw/checksum`, '00000000000000000000000000000000', qos1Retained) 213 | client.publish(`${BASE_TOPIC}/${device.id}/$implementation`, 'esp8266', qos1Retained) 214 | 215 | for (let node of device.nodes) { 216 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/$type`, node.type, qos1Retained) 217 | let properties = '' 218 | for (let property of node.properties) properties += `${property.id}${property.settable ? ':settable' : ''},` 219 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/$properties`, properties.slice(0, -1), qos1Retained) 220 | } 221 | 222 | client.publish(`${BASE_TOPIC}/${device.id}/$online`, 'true', qos1Retained) 223 | await delay(200) 224 | } 225 | 226 | client.subscribe('homie/+/+/+/set') 227 | 228 | interval = setInterval(sendAllStats, STATS_INTERVAL_IN_SECONDS * 1000) 229 | }) 230 | 231 | client.on('message', function onMessage (topic, message) { 232 | client.publish(topic.substr(0, topic.length - 4), message) 233 | }) 234 | 235 | client.on('close', function onClose () { 236 | log.info('disconnected from the broker') 237 | if (interval) clearInterval(interval) 238 | }) 239 | 240 | const sendStats = function (device) { 241 | log.info(`sending stats of device ${device.id}`) 242 | const now = new Date() 243 | client.publish(`${BASE_TOPIC}/${device.id}/$stats/uptime`, Math.floor((now - startTime) / 1000).toString(), qos1Retained) 244 | client.publish(`${BASE_TOPIC}/${device.id}/$stats/signal`, (Math.floor(Math.random() * 100) + 0).toString(), qos1Retained) 245 | } 246 | 247 | const sendAllStats = async function () { 248 | for (let device of DEVICES) { 249 | sendStats(device) 250 | 251 | device.nodes.forEach(function (node) { 252 | if (node.type === 'temperature') { 253 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/degrees`, (Math.floor(Math.random() * 30) + 0).toString(), qos1Retained) 254 | } else if (node.type === 'humidity') { 255 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/percentage`, (Math.floor(Math.random() * 100) + 0).toString(), qos1Retained) 256 | } else if (node.type === 'door' || node.type === 'window') { 257 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/open`, Math.random() < 0.5 ? '1' : '0', qos1Retained) 258 | } else if (node.type === 'sound') { 259 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/intensity`, (Math.floor(Math.random() * 30) + 0).toString(), qos1Retained) 260 | } else if (node.type === 'luminosity') { 261 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/lux`, (Math.floor(Math.random() * 30) + 0).toString(), qos1Retained) 262 | } else if (node.type === 'motion') { 263 | client.publish(`${BASE_TOPIC}/${device.id}/${node.id}/motion`, Math.random() < 0.5 ? '1' : '0', qos1Retained) 264 | } 265 | }) 266 | await delay(200) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homie-dashboard", 3 | "description": "IoT dashboard for Homie devices", 4 | "version": "0.5.0", 5 | "author": "RGB Team", 6 | "ava": { 7 | "babel": "inherit", 8 | "require": [ 9 | "babel-register" 10 | ] 11 | }, 12 | "babel": { 13 | "plugins": [ 14 | "transform-es2015-modules-commonjs", 15 | "transform-async-to-generator" 16 | ], 17 | "ignore": "test/**/*.js", 18 | "env": { 19 | "development": { 20 | "sourceMaps": "inline" 21 | }, 22 | "production": { 23 | "plugins": [ 24 | "transform-inline-environment-variables" 25 | ] 26 | } 27 | } 28 | }, 29 | "bin": { 30 | "homie-dashboard": "./src/bin/cli.js" 31 | }, 32 | "bugs": "https://github.com/INTECH-RGB/homie-dashboard/issues", 33 | "contributors": [ 34 | "Marvin ROGER", 35 | "Valentin GAUTHEY", 36 | "Matthieu BOISSADY" 37 | ], 38 | "dependencies": { 39 | "ajv": "^4.10.4", 40 | "body-parser": "^1.15.2", 41 | "bookshelf": "^0.10.2", 42 | "clor": "^5.0.1", 43 | "cookie": "^0.3.1", 44 | "express": "^4.14.0", 45 | "fast-json-patch": "^1.1.2", 46 | "internal-ip": "^1.2.0", 47 | "knex": "^0.12.6", 48 | "mqtt": "^2.0.1", 49 | "speakeasy": "^2.0.0", 50 | "sqlite3": "^3.1.8", 51 | "toml": "^2.3.0", 52 | "uuid": "^3.0.0", 53 | "ws": "^2.0.2", 54 | "yargs": "^6.5.0", 55 | "yeelight-wifi": "^2.0.0" 56 | }, 57 | "devDependencies": { 58 | "ava": "^0.18.1", 59 | "axios": "^0.15.2", 60 | "babel-cli": "^6.18.0", 61 | "babel-core": "^6.18.2", 62 | "babel-plugin-transform-async-to-generator": "^6.16.0", 63 | "babel-plugin-transform-es2015-modules-commonjs": "^6.18.0", 64 | "babel-plugin-transform-inline-environment-variables": "^6.8.0", 65 | "babel-watch": "^2.0.3", 66 | "balloon-css": "^0.4.0", 67 | "bulma": "^0.3.0", 68 | "chart.js": "^2.4.0", 69 | "coveralls": "^2.11.15", 70 | "cross-env": "^3.1.3", 71 | "eva.js": "^1.1.1", 72 | "eventemitter3": "^2.0.2", 73 | "font-awesome": "^4.7.0", 74 | "lodash.debounce": "^4.0.8", 75 | "moment": "^2.17.1", 76 | "node-sass": "^4.0.0", 77 | "npm-check": "^5.4.0", 78 | "nyc": "^10.0.0", 79 | "sass-loader": "^5.0.0", 80 | "shx": "^0.2.0", 81 | "snazzy": "^6.0.0", 82 | "standard": "^8.5.0", 83 | "vbuild": "^5.0.0", 84 | "vue": "^2.1.10", 85 | "vue-color": "^2.0.5", 86 | "vue-datepicker": "^1.3.0", 87 | "vue-grid-layout": "^2.0.0", 88 | "vue-loader": "^10.0.2", 89 | "vue-template-compiler": "^2.1.10" 90 | }, 91 | "engines": { 92 | "node": "^7.0.0" 93 | }, 94 | "homepage": "https://github.com/INTECH-RGB/homie-dashboard", 95 | "keywords": [ 96 | "automation", 97 | "dashboard", 98 | "home", 99 | "homie", 100 | "iot", 101 | "mqtt" 102 | ], 103 | "license": "GPL-2.0", 104 | "main": "src/index.js", 105 | "preferGlobal": true, 106 | "repository": { 107 | "type": "git", 108 | "url": "https://github.com/INTECH-RGB/homie-dashboard.git" 109 | }, 110 | "scripts": { 111 | "app-build": "cross-env BABEL_ENV=production NODE_ENV=production vbuild && shx rm dist-app/*.map", 112 | "app-dev": "vbuild --dev", 113 | "common-depcheck": "npm-check --no-emoji", 114 | "emulator-start": "babel-watch emulator/index.js", 115 | "server-build": "cross-env BABEL_ENV=production NODE_ENV=production babel server/ -d dist-server/src/ && cross-env BABEL_ENV=production NODE_ENV=production babel common/ -d dist-server/common/ && npm run app-build && shx cp -r dist-app/ dist-server/ && shx cp package.json dist-server/ && shx cp README.md dist-server/", 116 | "server-dev": "babel-watch server/bin/cli.js -- start --port 5000 --ip 0.0.0.0 --logLevel debug", 117 | "server-lint": "standard server/**/*.js | snazzy", 118 | "server-test": "nyc ava" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /server/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | import ip from 'internal-ip' 6 | import c from 'clor/c' 7 | import yargs from 'yargs' 8 | 9 | import {bootstrap} from '../index' 10 | import {hash} from '../lib/hash' 11 | import pkg from '../../package' 12 | 13 | const argv = yargs 14 | .usage('Usage: $0 [options]') 15 | .version() 16 | .strict() 17 | .demandCommand(1) 18 | .command('hash ', 'Hash a password') 19 | .command('start', 'Start Homie Dashboard', (yargs) => { 20 | yargs.option('ip', { 21 | describe: 'IP you want to listen to. Defaults to 127.0.0.1' 22 | }) 23 | .option('port', { 24 | describe: 'Port you want to listen to. Defaults to 35589' 25 | }) 26 | .option('dataDir', { 27 | describe: "Top directory you want Homie's data to be stored in. Defaults to CWD" 28 | }) 29 | .option('logLevel', { 30 | describe: 'Minimum log level for console output. Defaults to info' 31 | }) 32 | }) 33 | .help() 34 | .locale('en') 35 | .argv 36 | 37 | const wrapper = async function () { 38 | switch (argv._[0]) { 39 | case 'hash': 40 | console.log(await hash(argv.password)) 41 | break 42 | case 'start': 43 | // Font: Dr Pepper 44 | const homieStyled = c`\ 45 | _____ _ ____ _ _ _ 46 | | | |___ _____|_|___ | \\ ___ ___| |_| |_ ___ ___ ___ _| | 47 | | | . | | | -_| | | | .'|_ -| | . | . | .'| _| . | 48 | |__|__|___|_|_|_|_|___| |____/|__,|___|_|_|___|___|__,|_| |___| 49 | ` 50 | 51 | console.log(homieStyled) 52 | console.log(c`Version ${pkg.version}\n`) 53 | console.log(c`See https://github.com/INTECH-RGB/homie-dashboard\n`) 54 | console.log(c`Homie Dashboard IP is ${ip.v4()}`) 55 | console.log(c`Make sure this IP won't change over time\n`) 56 | 57 | bootstrap({ 58 | ip: argv.ip || '127.0.0.1', 59 | port: parseInt(argv.port, 10) || 35589, 60 | dataDir: argv.dataDir || './', 61 | logLevel: argv.logLevel ? argv.logLevel.toUpperCase() : 'INFO' 62 | }) 63 | break 64 | } 65 | } 66 | wrapper() 67 | -------------------------------------------------------------------------------- /server/bindings/aqara.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import os from 'os' 3 | import dgram from 'dgram' 4 | 5 | import homieTopicParser, {TOPIC_TYPES} from '../lib/homie-topic-parser' 6 | 7 | const AQARA_IV = Buffer.from([0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58, 0x56, 0x2e]) 8 | const MULTICAST_ADDRESS = '224.0.0.50' 9 | const SERVER_PORT = 9898 10 | 11 | const BASE_TOPIC = 'homie' 12 | 13 | const serverSocket = dgram.createSocket('udp4') 14 | const qos1Retained = { qos: 1, retain: true } 15 | 16 | export default function start (opts) { 17 | const { settings, log, mqttClient } = opts 18 | 19 | const devicesToWatch = {} 20 | let gatewaySid = null 21 | let gatewayToken = null 22 | 23 | serverSocket.on('listening', function () { 24 | log.info('aqara binding listening') 25 | Object.values(os.networkInterfaces()).forEach(function (iface) { 26 | iface.forEach(function (connection) { 27 | if (connection.family === 'IPv4') serverSocket.addMembership(MULTICAST_ADDRESS, connection.address) 28 | }) 29 | }) 30 | log.info('aqara binding registered to multicast') 31 | 32 | const payload = '{"cmd": "get_id_list"}' 33 | serverSocket.send(payload, 0, payload.length, SERVER_PORT, settings.gateway.ip) 34 | }) 35 | 36 | serverSocket.on('message', function (message) { 37 | message = JSON.parse(message.toString()) 38 | 39 | let sid = null 40 | let type = null 41 | let state = null 42 | let mapped = null 43 | switch (message.cmd) { 44 | case 'heartbeat': 45 | gatewaySid = message.sid 46 | gatewayToken = message.token 47 | break 48 | case 'get_id_list_ack': 49 | gatewaySid = message.sid 50 | gatewayToken = message.token 51 | // read gateway 52 | const payload = `{"cmd": "read", "sid": "${gatewaySid}"}` 53 | serverSocket.send(payload, 0, payload.length, SERVER_PORT, settings.gateway.ip) 54 | // read subdevices 55 | for (const sid of JSON.parse(message.data)) { 56 | const payload = `{"cmd": "read", "sid": "${sid}"}` 57 | serverSocket.send(payload, 0, payload.length, SERVER_PORT, settings.gateway.ip) 58 | } 59 | break 60 | case 'read_ack': 61 | sid = message.sid 62 | type = message.model 63 | state = JSON.parse(message.data) 64 | 65 | mapped = mapToHomie({ sid, type, state }) 66 | 67 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$homie`, '2.0.0', qos1Retained) 68 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$name`, mapped.deviceName, qos1Retained) 69 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$localip`, settings.gateway.ip, qos1Retained) 70 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$mac`, '00:00:00:00:00:00', qos1Retained) 71 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$stats/interval`, '0', qos1Retained) 72 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$stats/uptime`, '0', qos1Retained) 73 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$stats/signal`, '100', qos1Retained) 74 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$fw/name`, 'aqara-binding', qos1Retained) 75 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$fw/version`, '1.0.0', qos1Retained) 76 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$fw/checksum`, '00000000000000000000000000000000', qos1Retained) 77 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$implementation`, 'aqara', qos1Retained) 78 | 79 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/${mapped.nodeId}/$type`, mapped.nodeType, qos1Retained) 80 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/${mapped.nodeId}/$properties`, mapped.nodeProperties, qos1Retained) 81 | devicesToWatch[`${mapped.deviceId}/${mapped.nodeId}`] = { 82 | type: mapped.nodeType, 83 | properties: {} 84 | } 85 | for (const property of mapped.properties) { 86 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/${mapped.nodeId}/${property.id}`, property.value, qos1Retained) 87 | devicesToWatch[`${mapped.deviceId}/${mapped.nodeId}`].properties[property.id] = property 88 | } 89 | 90 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/$online`, 'true', qos1Retained) 91 | 92 | break 93 | case 'report': 94 | sid = message.sid 95 | type = message.model 96 | state = JSON.parse(message.data) 97 | 98 | mapped = mapToHomie({ sid, type, state }) 99 | 100 | for (const property of mapped.properties) { 101 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/${mapped.nodeId}/${property.id}`, property.value, qos1Retained) 102 | devicesToWatch[`${mapped.deviceId}/${mapped.nodeId}`].properties[property.id] = property 103 | } 104 | 105 | break 106 | } 107 | }) 108 | 109 | mqttClient.on('message', (topic, value) => { 110 | const message = homieTopicParser.parse(topic, value.toString()) 111 | if (message.type !== TOPIC_TYPES.NODE_PROPERTY_SET) return 112 | 113 | const device = devicesToWatch[`${message.deviceId}/${message.nodeId}`] 114 | if (!device) return 115 | 116 | const cipher = crypto.createCipheriv('aes-128-cbc', settings.gateway.password, AQARA_IV) 117 | const key = cipher.update(gatewayToken, 'ascii', 'hex') 118 | cipher.final('hex') // useless 119 | 120 | switch (device.type) { 121 | case 'light': 122 | const initialColorSplitted = device.properties.color.value.split(',') 123 | const initialIntensity = parseInt(device.properties.intensity.value, 10) 124 | let r = null 125 | let g = null 126 | let b = null 127 | let a = null 128 | if (message.property === 'color') { 129 | const colorSplitted = message.value.split(',') 130 | r = parseInt(colorSplitted[0], 10) 131 | g = parseInt(colorSplitted[1], 10) 132 | b = parseInt(colorSplitted[2], 10) 133 | a = initialIntensity 134 | } else if (message.property === 'intensity') { 135 | r = parseInt(initialColorSplitted[0], 10) 136 | g = parseInt(initialColorSplitted[1], 10) 137 | b = parseInt(initialColorSplitted[2], 10) 138 | a = parseInt(message.value, 10) 139 | } 140 | 141 | const buf = Buffer.alloc(4) 142 | buf.writeUInt8(a, 0) 143 | buf.writeUInt8(r, 1) 144 | buf.writeUInt8(g, 2) 145 | buf.writeUInt8(b, 3) 146 | 147 | const value = buf.readUInt32BE() 148 | 149 | const payload = `{ "cmd": "write", "model": "gateway", "sid": "${gatewaySid}", "short_id": 0, "data": "{\\"rgb\\":${value}, \\"key\\": \\"${key}\\"}" }` 150 | serverSocket.send(payload, 0, payload.length, SERVER_PORT, settings.gateway.ip) 151 | break 152 | } 153 | }) 154 | 155 | serverSocket.bind(SERVER_PORT, '0.0.0.0') 156 | } 157 | 158 | function mapToHomie (opts) { 159 | const { sid, type, state } = opts 160 | 161 | const toReturn = { 162 | deviceId: `aqara-${sid}`, 163 | deviceName: null, 164 | nodeId: null, 165 | nodeType: null, 166 | nodeProperties: null, 167 | properties: [] 168 | } 169 | 170 | switch (type) { 171 | case 'gateway': 172 | Object.assign(toReturn, { 173 | deviceName: 'Lumière passerelle', 174 | nodeId: 'light', 175 | nodeType: 'light', 176 | nodeProperties: 'color:settable,intensity:settable' 177 | }) 178 | const buf = Buffer.alloc(4) 179 | buf.writeUInt32BE(state.rgb) 180 | const r = buf.readUInt8(1) 181 | const g = buf.readUInt8(2) 182 | const b = buf.readUInt8(3) 183 | const a = buf.readUInt8(0) // 0-100 184 | toReturn.properties.push({ id: 'color', value: `${r},${g},${b}` }) 185 | toReturn.properties.push({ id: 'intensity', value: a.toString() }) 186 | break 187 | case 'magnet': 188 | Object.assign(toReturn, { 189 | deviceName: 'Porte', 190 | nodeId: 'door', 191 | nodeType: 'door', 192 | nodeProperties: 'open' 193 | }) 194 | toReturn.properties.push({ id: 'open', value: state.status === 'open' ? '1' : '0' }) 195 | break 196 | case 'switch': 197 | Object.assign(toReturn, { 198 | deviceName: 'Bouton', 199 | nodeId: 'button', 200 | nodeType: 'button', 201 | nodeProperties: 'pressed' 202 | }) 203 | toReturn.properties.push({ id: 'pressed', value: state.status === 'long_click_press' ? '1' : '0' }) 204 | break 205 | } 206 | 207 | return toReturn 208 | } 209 | -------------------------------------------------------------------------------- /server/bindings/yeelight.js: -------------------------------------------------------------------------------- 1 | import YeelightSearch from 'yeelight-wifi' 2 | 3 | import homieTopicParser, {TOPIC_TYPES} from '../lib/homie-topic-parser' 4 | 5 | const BASE_TOPIC = 'homie' 6 | 7 | const qos1Retained = { qos: 1, retain: true } 8 | 9 | function componentToHex (c) { 10 | var hex = c.toString(16) 11 | return hex.length === 1 ? '0' + hex : hex 12 | } 13 | 14 | function rgbToHex (r, g, b) { 15 | return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b) 16 | } 17 | 18 | export default function start (opts) { 19 | const { log, mqttClient } = opts 20 | 21 | const devicesToWatch = {} 22 | 23 | const yeelightSearch = new YeelightSearch() 24 | yeelightSearch.on('found', async (light) => { 25 | log.info(`yeelight found - ID ${light.getId().toString()}`) 26 | const deviceId = `yeelight-${light.getId()}` 27 | 28 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$homie`, '2.0.0', qos1Retained) 29 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$name`, 'Lumière', qos1Retained) 30 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$localip`, '0.0.0.0', qos1Retained) 31 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$mac`, '00:00:00:00:00:00', qos1Retained) 32 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$stats/interval`, '0', qos1Retained) 33 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$stats/uptime`, '0', qos1Retained) 34 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$stats/signal`, '100', qos1Retained) 35 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$fw/name`, 'yeelight-binding', qos1Retained) 36 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$fw/version`, '1.0.0', qos1Retained) 37 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$fw/checksum`, '00000000000000000000000000000000', qos1Retained) 38 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$implementation`, 'yeelight', qos1Retained) 39 | 40 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/light/$type`, 'light', qos1Retained) 41 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/light/$properties`, 'color:settable,intensity:settable', qos1Retained) 42 | devicesToWatch[deviceId] = light 43 | /* for (const property of mapped.properties) { 44 | mqttClient.publish(`${BASE_TOPIC}/${mapped.deviceId}/${mapped.nodeId}/${property.id}`, property.value, qos1Retained) 45 | devicesToWatch[`${mapped.deviceId}/${mapped.nodeId}`].properties[property.id] = property 46 | } */ 47 | 48 | mqttClient.publish(`${BASE_TOPIC}/${deviceId}/$online`, 'true', qos1Retained) 49 | }) 50 | 51 | mqttClient.on('message', async (topic, value) => { 52 | const message = homieTopicParser.parse(topic, value.toString()) 53 | if (message.type !== TOPIC_TYPES.NODE_PROPERTY_SET) return 54 | 55 | const light = devicesToWatch[message.deviceId] 56 | if (!light) return 57 | 58 | if (message.property === 'color') { 59 | const colorSplitted = message.value.split(',') 60 | const r = parseInt(colorSplitted[0], 10) 61 | const g = parseInt(colorSplitted[1], 10) 62 | const b = parseInt(colorSplitted[2], 10) 63 | 64 | try { 65 | await light.setRGB(rgbToHex(r, g, b)) 66 | } catch (err) { 67 | } 68 | 69 | mqttClient.publish(`${BASE_TOPIC}/${message.deviceId}/${message.nodeId}/color`, message.value, qos1Retained) 70 | } else if (message.property === 'intensity') { 71 | const intensity = parseInt(message.value, 10) 72 | 73 | try { 74 | if (intensity === 0) await light.turnOff() 75 | else { 76 | await light.turnOn() 77 | await light.setBrightness(intensity) 78 | } 79 | } catch (err) { 80 | } 81 | mqttClient.publish(`${BASE_TOPIC}/${message.deviceId}/${message.nodeId}/intensity`, message.value, qos1Retained) 82 | } 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import Knex from 'knex' 3 | import {bookshelf} from './lib/database' 4 | import log, {LOG_LEVELS} from './lib/log' 5 | import createWebsocketServer from './lib/websocket-server' 6 | import start from './start' 7 | import loadSettings from './lib/settings' 8 | import {validate as validateSettings} from './lib/validators/settings' 9 | import SettingModel from './models/setting' 10 | 11 | export async function bootstrap (opts) { 12 | if (typeof LOG_LEVELS[opts.logLevel] === undefined) { 13 | log.fatal(`log level ${opts.logLevel} does not exist`) 14 | process.exit(1) 15 | } 16 | log.setLevel(LOG_LEVELS[opts.logLevel]) 17 | 18 | log.info('starting') 19 | 20 | let settings 21 | try { 22 | settings = await loadSettings(opts.dataDir) 23 | const validated = validateSettings(settings) 24 | if (!validated.valid) { 25 | log.fatal('invalid settings', validated.errors) 26 | process.exit(1) 27 | } 28 | log.info('settings loaded') 29 | } catch (err) { 30 | log.fatal('cannot load settings', err) 31 | process.exit(1) 32 | } 33 | 34 | const knex = new Knex({ 35 | client: 'sqlite3', 36 | connection: { 37 | filename: path.join(opts.dataDir, './homie-dashboard.db') 38 | }, 39 | useNullAsDefault: true 40 | }) 41 | bookshelf.knex = knex 42 | 43 | try { 44 | await knex.raw('PRAGMA foreign_keys=ON') 45 | await knex.raw('PRAGMA locking_mode=EXCLUSIVE') 46 | await knex.raw('PRAGMA synchronous=NORMAL') 47 | await knex.migrate.latest({ directory: path.join(__dirname, '/migrations') }) 48 | log.debug('database migrated') 49 | const otpSecretModel = await SettingModel.forge({ key: 'otp_secret' }).fetch() 50 | const qrCodeSecret = otpSecretModel.attributes['value'] 51 | const qrCodeData = `otpauth://totp/HomieDashboard?secret=${qrCodeSecret}` 52 | log.info(`copy this into a web browser to add the secure key: https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(qrCodeData)}`) 53 | } catch (err) { 54 | log.fatal('cannot open or migrate database', err) 55 | process.exit(1) 56 | } 57 | 58 | let wss 59 | try { 60 | wss = await createWebsocketServer({ ip: opts.ip, port: opts.port, settings }) 61 | log.info(`listening on ${opts.ip}:${opts.port}`) 62 | } catch (err) { 63 | log.fatal('cannot start server', err) 64 | process.exit(1) 65 | } 66 | 67 | start({ log, wss, settings }) 68 | } 69 | -------------------------------------------------------------------------------- /server/lib/automator.js: -------------------------------------------------------------------------------- 1 | import vm from 'vm' 2 | 3 | export function handleAutomation ({$deps, infrastructure, mqttClient}) { 4 | infrastructure.on('update', async () => { 5 | const sandbox = { 6 | infrastructure: infrastructure.toJSON(), 7 | handleAction (opts) { 8 | const {deviceId, nodeId, propertyId, value} = opts 9 | 10 | mqttClient.publish(`homie/${deviceId}/${nodeId}/${propertyId}/set`, value, { qos: 1, retain: true }) 11 | } 12 | } 13 | vm.createContext(sandbox) 14 | vm.runInContext(infrastructure.getAutomation().script, sandbox) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /server/lib/bridges/infrastructure-database.js: -------------------------------------------------------------------------------- 1 | import DeviceModel from '../../models/device' 2 | import NodeModel from '../../models/node' 3 | import PropertyModel from '../../models/property' 4 | import PropertyHistoryModel from '../../models/property-history' 5 | import AutomationScriptModel from '../../models/automation-script' 6 | import Device from '../infrastructure/device' 7 | import Node from '../infrastructure/node' 8 | import Property from '../infrastructure/property' 9 | 10 | /** 11 | * This funcion bridges the infrastructure to the database 12 | */ 13 | export function bridgeInfrastructureToDatabase ({$deps, infrastructure}) { 14 | let databaseQueue = Promise.resolve() 15 | 16 | infrastructure.on('automationUpdated', async () => { 17 | const automation = infrastructure.getAutomation() 18 | if (!automation.model) { 19 | automation.model = await AutomationScriptModel.forge({ script: automation.script, blockly_xml: automation.xml }).save() 20 | } else { 21 | await automation.model.set({ 22 | script: automation.script, 23 | xml: automation.xml 24 | }).save() 25 | } 26 | }) 27 | 28 | infrastructure.on('newDevice', function onNewDevice (device) { 29 | databaseQueue = databaseQueue.then(() => { 30 | $deps.log.debug(`inserting new device ${device.id} into DB`) 31 | return DeviceModel.forge({ 32 | id: device.id, 33 | name: device.name, 34 | online: device.online, 35 | local_ip: device.localIp, 36 | mac: device.mac, 37 | stats_signal: device.getStatProperty('signal'), 38 | stats_uptime: device.getStatProperty('uptime'), 39 | stats_interval_in_seconds: device.getStatProperty('interval'), 40 | fw_name: device.getFirmwareProperty('name'), 41 | fw_version: device.getFirmwareProperty('version'), 42 | fw_checksum: device.getFirmwareProperty('checksum'), 43 | implementation: device.implementation 44 | }).save(null, { method: 'insert' }) 45 | }).then((model) => { 46 | device.model = model 47 | }) 48 | }) 49 | 50 | infrastructure.on('newNode', function onNewNode (node) { 51 | databaseQueue = databaseQueue.then(() => { 52 | $deps.log.debug(`inserting new node ${node.id} into DB`) 53 | return NodeModel.forge({ 54 | device_id: node.device.id, 55 | device_node_id: node.id, 56 | name: node.type, 57 | type: node.type, 58 | properties: node.propertiesDefinition 59 | }).save() 60 | }).then((model) => { 61 | node.model = model 62 | }) 63 | }) 64 | 65 | infrastructure.on('newProperty', function onNewProperty (property) { 66 | databaseQueue = databaseQueue.then(() => { 67 | $deps.log.debug(`inserting new property ${property.id} into DB`) 68 | return PropertyModel.forge({ 69 | node_id: property.node.model.id, 70 | node_property_id: property.id, 71 | settable: property.settable 72 | }).save() 73 | }).then((model) => { 74 | property.model = model 75 | 76 | if (!property.value) return 77 | return PropertyHistoryModel.forge({ 78 | property_id: property.model.id, 79 | value: property.value, 80 | date: new Date() 81 | }).save() 82 | }) 83 | }) 84 | 85 | infrastructure.on('update', function onUpdate (update) { 86 | switch (update.entity.constructor) { 87 | case Device: 88 | if (!update.entity.model) return 89 | databaseQueue = databaseQueue.then(() => { 90 | $deps.log.debug(`updating device ${update.entity.id} in DB`) 91 | return update.entity.model.set({ 92 | name: update.entity.name, 93 | online: update.entity.online, 94 | local_ip: update.entity.localIp, 95 | stats_signal: update.entity.getStatProperty('signal'), 96 | stats_uptime: update.entity.getStatProperty('uptime'), 97 | stats_interval_in_seconds: update.entity.getStatProperty('interval'), 98 | fw_name: update.entity.getFirmwareProperty('name'), 99 | fw_version: update.entity.getFirmwareProperty('version'), 100 | fw_checksum: update.entity.getFirmwareProperty('checksum') 101 | }).save() 102 | }) 103 | 104 | break 105 | case Node: 106 | if (!update.entity.model) return 107 | databaseQueue = databaseQueue.then(() => { 108 | $deps.log.debug(`updating node ${update.entity.id} in DB`) 109 | return update.entity.model.set({ 110 | type: update.entity.type, 111 | properties: update.entity.propertiesDefinition 112 | }).save() 113 | }) 114 | 115 | break 116 | case Property: 117 | if (!update.entity.model) return 118 | databaseQueue = databaseQueue.then(() => { 119 | $deps.log.debug(`updating property ${update.entity.id} in DB`) 120 | if (!update.entity.value) return 121 | return PropertyHistoryModel.forge({ 122 | property_id: update.entity.model.id, 123 | value: update.entity.value, 124 | date: new Date() 125 | }).save() 126 | }) 127 | } 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /server/lib/bridges/infrastructure-websocket.js: -------------------------------------------------------------------------------- 1 | import jsonpatch from 'fast-json-patch' 2 | import {generateMessage, MESSAGE_TYPES} from '../../../common/ws-messages' 3 | import {INFRASTRUCTURE_PATCH} from '../../../common/events' 4 | 5 | /** 6 | * This funcion bridges the MQTT to the WebSocket 7 | */ 8 | export function bridgeInfrastructureToWebsocket ({$deps, infrastructure}) { 9 | let lastInfrastructure = JSON.parse(JSON.stringify(infrastructure.toJSON())) 10 | infrastructure.on('update', function onUpdate (update) { 11 | // send to ws 12 | const currentInfrastructure = JSON.parse(JSON.stringify(infrastructure.toJSON())) 13 | const patch = jsonpatch.compare(lastInfrastructure, currentInfrastructure) 14 | lastInfrastructure = currentInfrastructure 15 | const message = generateMessage({ type: MESSAGE_TYPES.EVENT, event: INFRASTRUCTURE_PATCH, value: patch }) 16 | for (const client of $deps.wss.clients) { 17 | client.send(message) 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /server/lib/bridges/mqtt-infrastructure.js: -------------------------------------------------------------------------------- 1 | import homieTopicParser, {TOPIC_TYPES} from '../homie-topic-parser' 2 | import Device from '../infrastructure/device' 3 | import Node from '../infrastructure/node' 4 | import Property from '../infrastructure/property' 5 | 6 | /** 7 | * This funcion bridges the MQTT to the infrastructure 8 | */ 9 | export function bridgeMqttToInfrastructure ({$deps, mqttClient, infrastructure}) { 10 | mqttClient.on('connect', function onConnect () { 11 | $deps.log.info('connected to broker') 12 | mqttClient.subscribe('homie/#', { qos: 1 }) 13 | }) 14 | mqttClient.on('message', (topic, value) => { 15 | const message = homieTopicParser.parse(topic, value.toString()) 16 | if (message.type === TOPIC_TYPES.INVALID) return 17 | 18 | /* Handle device properties */ 19 | 20 | if (message.type === TOPIC_TYPES.DEVICE_PROPERTY) { 21 | let device 22 | if (!infrastructure.hasDevice(message.deviceId)) { 23 | device = new Device() 24 | device.id = message.deviceId 25 | infrastructure.addDevice(device) 26 | } else device = infrastructure.getDevice(message.deviceId) 27 | 28 | switch (message.property) { 29 | case 'name': 30 | device.name = message.value 31 | return 32 | case 'localip': 33 | device.localIp = message.value 34 | return 35 | case 'mac': 36 | device.mac = message.value 37 | return 38 | case 'stats/signal': 39 | device.setStatProperty('signal', parseInt(message.value, 10)) 40 | return 41 | case 'stats/uptime': 42 | device.setStatProperty('uptime', parseInt(message.value, 10)) 43 | return 44 | case 'stats/interval': 45 | device.setStatProperty('interval', parseInt(message.value, 10)) 46 | return 47 | case 'fw/name': 48 | device.setFirmwareProperty('name', message.value) 49 | return 50 | case 'fw/version': 51 | device.setFirmwareProperty('version', message.value) 52 | return 53 | case 'fw/checksum': 54 | device.setFirmwareProperty('checksum', message.value) 55 | return 56 | case 'implementation': 57 | device.implementation = message.value 58 | return 59 | case 'online': 60 | device.online = message.value === 'true' 61 | return 62 | } 63 | } 64 | 65 | if (!infrastructure.hasDevice(message.deviceId)) return 66 | const device = infrastructure.getDevice(message.deviceId) 67 | 68 | /* Handle node special properties */ 69 | 70 | if (message.type === TOPIC_TYPES.NODE_SPECIAL_PROPERTY) { 71 | let node 72 | if (!device.hasNode(message.nodeId)) { 73 | node = new Node() 74 | node.id = message.nodeId 75 | device.addNode(node) 76 | node.device = device 77 | } else node = device.getNode(message.nodeId) 78 | 79 | switch (message.property) { 80 | case 'type': 81 | node.type = message.value 82 | return 83 | case 'properties': 84 | node.propertiesDefinition = message.value 85 | const propertiesDefinition = node.propertiesDefinition.split(',').map(function (propertyDefinition) { 86 | const splitted = propertyDefinition.split(':') 87 | return { 88 | id: splitted[0], 89 | settable: splitted[1] ? splitted[1] === 'settable' : false 90 | } 91 | }) 92 | 93 | for (let propertyDefinition of propertiesDefinition) { 94 | let property 95 | if (!node.hasProperty(propertyDefinition.id)) { 96 | property = new Property() 97 | property.node = node 98 | property.id = propertyDefinition.id 99 | node.addProperty(property) 100 | } else property = node.getProperty(propertyDefinition.id) 101 | property.settable = propertyDefinition.settable 102 | } 103 | return 104 | } 105 | } 106 | 107 | if (!device.hasNode(message.nodeId)) return 108 | const node = device.getNode(message.nodeId) 109 | 110 | /* Handle node properties */ 111 | 112 | if (message.type === TOPIC_TYPES.NODE_PROPERTY) { 113 | let property 114 | if (!node.hasProperty(message.property)) { 115 | property = new Property() 116 | property.id = message.property 117 | node.addProperty(property) 118 | property.node = node 119 | } else property = node.getProperty(message.property) 120 | 121 | property.value = message.value 122 | return 123 | } 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /server/lib/client.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | import uuid from 'uuid' 3 | 4 | import pkg from '../../package' 5 | import {generateMessage, parseMessage, MESSAGE_TYPES} from '../../common/ws-messages' 6 | import {hash} from './hash' 7 | import {getStatistics} from './statistics' 8 | import {VERSION, INFRASTRUCTURE} from '../../common/events' 9 | import Tag from './infrastructure/tag' 10 | import Floor from './infrastructure/floor' 11 | import Room from './infrastructure/room' 12 | 13 | import TagModel from '../models/tag' 14 | import FloorModel from '../models/floor' 15 | import RoomModel from '../models/room' 16 | import SettingModel from '../models/setting' 17 | 18 | /** 19 | * This class handles WebSocket clients 20 | * This is where every request gets parsed / responded 21 | @augments EventEmitter 22 | */ 23 | export default class Client extends EventEmitter { 24 | /** 25 | * Constructor 26 | @param {Object} opts 27 | @param {Object} opts.$deps 28 | @param {MqttClient} opts.mqttClient 29 | @param {Infrastructure} opts.infrastructure 30 | */ 31 | constructor (opts) { 32 | super() 33 | 34 | this.$deps = opts.$deps 35 | this.ws = opts.ws 36 | this.mqttClient = opts.mqttClient 37 | this.infrastructure = opts.infrastructure 38 | 39 | this.ws.send(generateMessage({ type: MESSAGE_TYPES.EVENT, event: INFRASTRUCTURE, value: this.infrastructure.toJSON() })) 40 | this.ws.send(generateMessage({ type: MESSAGE_TYPES.EVENT, event: VERSION, value: pkg.version })) 41 | 42 | this.ws.on('message', data => { 43 | const message = parseMessage(data) 44 | this.onMessage(message) 45 | }) 46 | 47 | this.ws.on('close', () => { 48 | this.emit('close') 49 | }) 50 | } 51 | 52 | /** 53 | * This function sends a response 54 | @param {Object} request the initial request 55 | @param {} value value to respond 56 | */ 57 | _sendResponse (request, value) { 58 | this.ws.send(generateMessage({ type: MESSAGE_TYPES.RESPONSE, id: request.id, value })) 59 | } 60 | 61 | /** 62 | * This function is called when we receive a message from the client 63 | @param {Object} message message received 64 | */ 65 | async onMessage (message) { 66 | if (message.type !== MESSAGE_TYPES.REQUEST) return 67 | 68 | /* Handle requests */ 69 | 70 | if (message.method === 'setState') { 71 | const deviceId = message.parameters.deviceId 72 | const nodeId = message.parameters.nodeId 73 | const property = message.parameters.property 74 | const value = message.parameters.value 75 | 76 | this.mqttClient.publish(`homie/${deviceId}/${nodeId}/${property}/set`, value, { qos: 1, retain: true }) 77 | 78 | this._sendResponse(message, true) 79 | } else if (message.method === 'createTag') { 80 | const tagId = message.parameters.id 81 | 82 | const tag = new Tag() 83 | tag.id = tagId 84 | this.infrastructure.addTag(tag) 85 | tag.model = await TagModel.forge({ id: tagId }).save(null, { method: 'insert' }) 86 | 87 | this._sendResponse(message, true) 88 | } else if (message.method === 'toggleTag') { 89 | const deviceId = message.parameters.deviceId 90 | const nodeId = message.parameters.nodeId 91 | const tagId = message.parameters.tagId 92 | const operationAdd = message.parameters.operationAdd 93 | 94 | const node = this.infrastructure.getDevice(deviceId).getNode(nodeId) 95 | const tag = this.infrastructure.getTag(tagId) 96 | 97 | if (operationAdd) { 98 | node.addTag(tag) 99 | await node.model.tags().attach(tag.model) 100 | } else { 101 | node.deleteTag(tag) 102 | await node.model.tags().detach(tag.model) 103 | } 104 | 105 | this._sendResponse(message, true) 106 | } else if (message.method === 'deleteTag') { 107 | const tagId = message.parameters.tagId 108 | const tag = this.infrastructure.getTag(tagId) 109 | 110 | await tag.model.destroy() 111 | this.infrastructure.deleteTag(tagId) 112 | 113 | this._sendResponse(message, true) 114 | } else if (message.method === 'addFloor') { 115 | const name = message.parameters.name 116 | 117 | const floor = new Floor() 118 | floor.model = await FloorModel.forge({ name, rooms_map: JSON.stringify([]) }).save() 119 | floor.id = floor.model.id 120 | floor.name = name 121 | this.infrastructure.addFloor(floor) 122 | 123 | this._sendResponse(message, true) 124 | } else if (message.method === 'changeNodeName') { 125 | const name = message.parameters.name 126 | const nodeData = message.parameters.node 127 | const device = this.infrastructure.getDevice(nodeData.device.id) 128 | const node = device.getNode(nodeData.id) 129 | node.name = name 130 | await node.model.save({ name: name }) 131 | 132 | this._sendResponse(message, true) 133 | } else if (message.method === 'deleteFloor') { 134 | const floorId = message.parameters.floorId 135 | const floor = this.infrastructure.getFloor(floorId) 136 | 137 | await floor.model.destroy() 138 | this.infrastructure.deleteFloor(floorId) 139 | 140 | this._sendResponse(message, true) 141 | } else if (message.method === 'deleteRoom') { 142 | const floorId = message.parameters.floorId 143 | const roomId = message.parameters.roomId 144 | const floor = this.infrastructure.getFloor(floorId) 145 | const room = floor.getRoom(roomId) 146 | const tag = this.infrastructure.getTag(room.tagId) 147 | await room.model.destroy() 148 | floor.deleteRoom(room) 149 | floor.deleteMapRoom(room) 150 | this.infrastructure.deleteTag(tag) 151 | await floor.model.save({ rooms_map: JSON.stringify(floor.roomsMap) }) 152 | await tag.model.destroy() 153 | 154 | this._sendResponse(message, true) 155 | } else if (message.method === 'addRoom') { 156 | const name = message.parameters.name 157 | 158 | const floorId = message.parameters.floor_id 159 | const tagId = `room:${uuid()}` 160 | const tag = new Tag() 161 | tag.id = tagId 162 | this.infrastructure.addTag(tag) 163 | tag.model = await TagModel.forge({ id: tagId }).save(null, { method: 'insert' }) 164 | const floor = this.infrastructure.getFloor(floorId) 165 | const room = new Room() 166 | room.model = await RoomModel.forge({ name, floor_id: floorId, tag_id: tagId }).save() 167 | room.id = room.model.id 168 | room.name = name 169 | room.floor = floor 170 | room.tagId = tagId 171 | floor.addRoom(room) 172 | floor.addMapRoom({ w: 2, h: 2, x: 0, y: 0, i: room.tagId }) 173 | await floor.model.save({ rooms_map: JSON.stringify(floor.roomsMap) }) 174 | 175 | this._sendResponse(message, true) 176 | } else if (message.method === 'updateMap') { 177 | const floorId = message.parameters.floorId 178 | const map = message.parameters.map 179 | const floor = this.infrastructure.getFloor(floorId) 180 | floor.updateMap(map) 181 | await floor.model.save({rooms_map: JSON.stringify(floor.roomsMap)}) 182 | 183 | this._sendResponse(message, true) 184 | } else if (message.method === 'getHomieEsp8266Settings') { 185 | this._sendResponse(message, this.$deps.settings['homie-esp8266']) 186 | } else if (message.method === 'getStatistics') { 187 | const {deviceId, nodeId, propertyId, type, granularity, range} = message.parameters 188 | 189 | const result = await getStatistics({ 190 | deviceId, nodeId, propertyId, type, granularity, range 191 | }) 192 | this._sendResponse(message, result) 193 | } else if (message.method === 'saveAutomationScript') { 194 | const blocklyXml = message.parameters.blocklyXml 195 | const script = message.parameters.script 196 | 197 | this.infrastructure.setAutomation({ 198 | xml: blocklyXml, 199 | script 200 | }) 201 | 202 | this._sendResponse(message, true) 203 | } else if (message.method === 'updatePassword') { 204 | const {password} = message.parameters 205 | 206 | const hashed = await hash(password) 207 | 208 | await SettingModel.forge({ key: 'password' }).save({ value: hashed }) 209 | 210 | this._sendResponse(message, true) 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /server/lib/database.js: -------------------------------------------------------------------------------- 1 | import Bookshelf from 'bookshelf' 2 | 3 | export const bookshelf = new Bookshelf() 4 | bookshelf.plugin('registry') 5 | -------------------------------------------------------------------------------- /server/lib/hash.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | function _generateSalt () { 4 | return new Promise((resolve, reject) => { 5 | crypto.randomBytes(16, function (err, randomBytes) { 6 | if (err) return reject(err) 7 | 8 | resolve(randomBytes.toString('hex')) 9 | }) 10 | }) 11 | } 12 | 13 | function _generateHash (string) { 14 | return crypto.createHash('sha256').update(string, 'utf-8').digest('hex') 15 | } 16 | 17 | export async function hash (password) { 18 | const salt = await _generateSalt() 19 | const hash = _generateHash(`${salt}${password}`) 20 | 21 | return `${salt},${hash}` 22 | } 23 | 24 | export async function verify (hash, password) { 25 | const splitted = hash.split(',') 26 | const salt = splitted[0] 27 | const concreteHash = splitted[1] 28 | 29 | return _generateHash(`${salt}${password}`) === concreteHash 30 | } 31 | -------------------------------------------------------------------------------- /server/lib/homie-topic-parser.js: -------------------------------------------------------------------------------- 1 | export const TOPIC_TYPES = { 2 | BROADCAST: 'BROADCAST', 3 | DEVICE_PROPERTY: 'DEVICE_PROPERTY', 4 | NODE_SPECIAL_PROPERTY: 'NODE_SPECIAL_PROPERTY', 5 | NODE_PROPERTY: 'NODE_PROPERTY', 6 | NODE_PROPERTY_SET: 'NODE_PROPERTY_SET', 7 | INVALID: 'INVALID' 8 | } 9 | 10 | /** 11 | * This function validates the ID format of the Homie convention 12 | * @param {string} id ID to test 13 | * @returns {bool} `true` if valid, `false` if not 14 | */ 15 | const validateIdFormat = (id) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) 16 | 17 | /** 18 | * This class parses Homie topics 19 | */ 20 | class HomieTopicParser { 21 | /** 22 | * Constructor 23 | @param {string} baseTopic Base topic of Homie 24 | */ 25 | constructor (baseTopic = 'homie/') { 26 | this.setBaseTopic(baseTopic) 27 | } 28 | 29 | /** 30 | * This function sets the base topic of Homie 31 | @param {string} baseTopic Base topic of Homie 32 | */ 33 | setBaseTopic (baseTopic) { 34 | this.baseTopic = baseTopic 35 | } 36 | 37 | /** 38 | * This function parses an Homie topic 39 | @param {string} topic Topic to parse 40 | @param {value} value to parse 41 | @returns {type: TOPIC_TYPES} all related properties 42 | */ 43 | parse (topic, value) { 44 | if (!topic.startsWith(this.baseTopic)) return { type: TOPIC_TYPES.INVALID } 45 | 46 | topic = topic.substr(this.baseTopic.length) // Remove base topic 47 | const splittedTopic = topic.split('/') 48 | const length = splittedTopic.length 49 | 50 | if (length === 2 && splittedTopic[0] === '$broadcast') { // Checking key word for global broadcast 51 | const level = splittedTopic[1] 52 | if (!validateIdFormat(level)) return { type: TOPIC_TYPES.INVALID } 53 | 54 | return { 55 | type: TOPIC_TYPES.BROADCAST, 56 | level, 57 | value 58 | } 59 | } else if (length >= 2 && splittedTopic[1].startsWith('$')) { // If [1] starts with $ then device property 60 | const deviceId = splittedTopic.shift() 61 | if (!validateIdFormat(deviceId)) return { type: TOPIC_TYPES.INVALID } 62 | 63 | return { 64 | type: TOPIC_TYPES.DEVICE_PROPERTY, 65 | deviceId, 66 | property: splittedTopic.join('/').substr(1), // Remove $ 67 | value 68 | } 69 | } else if (length === 3) { // node property 70 | const deviceId = splittedTopic[0] 71 | const nodeId = splittedTopic[1] 72 | if (!validateIdFormat(deviceId) || !validateIdFormat(nodeId)) return { type: TOPIC_TYPES.INVALID } 73 | 74 | const type = splittedTopic[2].startsWith('$') ? TOPIC_TYPES.NODE_SPECIAL_PROPERTY : TOPIC_TYPES.NODE_PROPERTY 75 | let property 76 | if (type === TOPIC_TYPES.NODE_SPECIAL_PROPERTY) { 77 | property = splittedTopic[2].substr(1) 78 | } else { 79 | property = splittedTopic[2] 80 | if (!validateIdFormat(property)) return { type: TOPIC_TYPES.INVALID } 81 | } 82 | 83 | return { 84 | type, 85 | deviceId, 86 | nodeId, 87 | property, 88 | value 89 | } 90 | } else if (length === 4 && splittedTopic[3] === 'set') { // If length is 4 and [5] is set then set 91 | const deviceId = splittedTopic[0] 92 | const nodeId = splittedTopic[1] 93 | const property = splittedTopic[2] 94 | if (!validateIdFormat(deviceId) || !validateIdFormat(nodeId) || !validateIdFormat(property)) return { type: TOPIC_TYPES.INVALID } 95 | 96 | return { 97 | type: TOPIC_TYPES.NODE_PROPERTY_SET, 98 | deviceId, 99 | nodeId, 100 | property, 101 | value 102 | } 103 | } else { // An error has occured, topic must be one of the above 104 | return { type: TOPIC_TYPES.INVALID } 105 | } 106 | } 107 | } 108 | 109 | export default new HomieTopicParser() 110 | -------------------------------------------------------------------------------- /server/lib/infrastructure/device.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a device 5 | */ 6 | export default class Device extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._id = null 11 | this._name = null 12 | this._online = null 13 | this._localIp = null 14 | this._mac = null 15 | this._stats = new Map([ 16 | ['signal', null], 17 | ['uptime', null], 18 | ['interval', null] 19 | ]) 20 | this._fw = new Map([ 21 | ['name', null], 22 | ['version', null], 23 | ['checksum', null] 24 | ]) 25 | this._implementation = null 26 | 27 | this._nodes = new Map() 28 | 29 | this.isValid = false 30 | 31 | this.model = null 32 | 33 | Object.seal(this) 34 | } 35 | 36 | hasNode (nodeId) { 37 | return this._nodes.has(nodeId) 38 | } 39 | 40 | addNode (node) { 41 | this._nodes.set(node.id, node) 42 | node.on('valid', () => { 43 | this.emit('newNode', node) 44 | }) 45 | node.on('newProperty', (property) => { 46 | this.emit('newProperty', property) 47 | }) 48 | node.on('update', (update) => { 49 | this.emit('update', update) 50 | }) 51 | this._wasUpdated() 52 | } 53 | 54 | getNode (nodeId) { 55 | return this._nodes.get(nodeId) 56 | } 57 | 58 | getNodes () { 59 | return this._nodes.values() 60 | } 61 | 62 | get id () { return this._id } 63 | set id (val) { 64 | if (!val || this._id === val) return 65 | this._id = val 66 | this._wasUpdated() 67 | } 68 | get name () { return this._name } 69 | set name (val) { 70 | if (!val || this._name === val) return 71 | this._name = val 72 | this._wasUpdated() 73 | } 74 | get online () { return this._online } 75 | set online (val) { 76 | if (this._online === val) return 77 | this._online = val 78 | this._wasUpdated() 79 | } 80 | get localIp () { return this._localIp } 81 | set localIp (val) { 82 | if (!val || this._localIp === val) return 83 | this._localIp = val 84 | this._wasUpdated() 85 | } 86 | get mac () { return this._mac } 87 | set mac (val) { 88 | if (!val || this._mac === val) return 89 | this._mac = val 90 | this._wasUpdated() 91 | } 92 | getStatProperty (property) { return this._stats.get(property) } 93 | setStatProperty (property, value) { 94 | if (this._stats.get(property) === value) return 95 | this._stats.set(property, value) 96 | this._wasUpdated() 97 | } 98 | getFirmwareProperty (property) { return this._fw.get(property) } 99 | setFirmwareProperty (property, value) { 100 | if (!property || !value || this._fw.get(property) === value) return 101 | this._fw.set(property, value) 102 | this._wasUpdated() 103 | } 104 | get implementation () { return this._implementation } 105 | set implementation (val) { 106 | if (!val || this._implementation === val) return 107 | this._implementation = val 108 | this._wasUpdated() 109 | } 110 | 111 | _wasUpdated () { 112 | const wasValid = this.isValid 113 | this.isValid = ( 114 | this._id !== null && 115 | this._name !== null && 116 | this._online !== null && 117 | this._localIp !== null && 118 | this._mac !== null && 119 | this._stats.get('signal') !== null && 120 | this._stats.get('uptime') !== null && 121 | this._stats.get('interval') !== null && 122 | this._fw.get('name') !== null && 123 | this._fw.get('version') !== null && 124 | this._fw.get('checksum') !== null && 125 | this._implementation !== null 126 | ) 127 | 128 | if (!this.isValid) return 129 | 130 | if (!wasValid) this.emit('valid') 131 | 132 | this.emit('update', { entity: this }) 133 | } 134 | 135 | toJSON () { 136 | const representation = {} 137 | representation.id = this.id 138 | representation.name = this.name 139 | representation.online = this.online 140 | representation.localIp = this.localIp 141 | representation.mac = this.mac 142 | representation.stats = { 143 | signal: this.getStatProperty('signal'), 144 | uptime: this.getStatProperty('uptime'), 145 | interval: this.getStatProperty('interval') 146 | } 147 | representation.fw = { 148 | name: this.getFirmwareProperty('name'), 149 | version: this.getFirmwareProperty('version'), 150 | checksum: this.getFirmwareProperty('checksum') 151 | } 152 | representation.implementation = this.implementation 153 | representation.nodes = {} 154 | for (const node of this.getNodes()) { 155 | if (node.isValid) representation.nodes[node.id] = node.toJSON() 156 | } 157 | 158 | return JSON.parse(JSON.stringify(representation)) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /server/lib/infrastructure/floor.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a floor 5 | */ 6 | export default class Floor extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._id = null 11 | this._name = null 12 | this.roomsMap = [] 13 | 14 | this._rooms = new Map() 15 | 16 | this.isValid = false 17 | 18 | this.model = null 19 | 20 | Object.seal(this) 21 | } 22 | 23 | hasRoom (roomId) { 24 | return this._rooms.has(roomId) 25 | } 26 | 27 | addRoom (room) { 28 | this._rooms.set(room.id, room) 29 | room.on('update', (update) => { 30 | this.emit('update', update) 31 | }) 32 | this._wasUpdated() 33 | } 34 | 35 | deleteRoom (room) { 36 | this._rooms.delete(room.id) 37 | this._wasUpdated() 38 | } 39 | 40 | deleteMapRoom (room) { 41 | for (let i = 0; i < this.roomsMap.length; i++) { 42 | if (this.roomsMap[i].i === room.tagId) this.roomsMap.splice(i, 1) 43 | } 44 | this._wasUpdated() 45 | } 46 | 47 | getRoom (roomId) { 48 | return this._rooms.get(roomId) 49 | } 50 | 51 | getRooms () { 52 | return this._rooms.values() 53 | } 54 | 55 | addMapRoom (map) { 56 | this.roomsMap.push(map) 57 | this._wasUpdated() 58 | } 59 | 60 | updateMap (map) { 61 | this.roomsMap = map 62 | this._wasUpdated() 63 | } 64 | 65 | get id () { return this._id } 66 | set id (val) { 67 | if (typeof val === undefined || this._id === val) return 68 | this._id = val 69 | this._wasUpdated() 70 | } 71 | get name () { return this._name } 72 | set name (val) { 73 | if (!val || this._name === val) return 74 | this._name = val 75 | this._wasUpdated() 76 | } 77 | 78 | _wasUpdated () { 79 | const wasValid = this.isValid 80 | this.isValid = ( 81 | this._name !== null && 82 | this._id !== null 83 | ) 84 | 85 | if (!this.isValid) return 86 | 87 | if (!wasValid) this.emit('valid') 88 | 89 | this.emit('update', { entity: this }) 90 | } 91 | 92 | toJSON () { 93 | const representation = { rooms: {} } 94 | representation.name = this.name 95 | representation.id = this.id 96 | representation.roomsMap = this.roomsMap 97 | for (const room of this.getRooms()) { 98 | if (room.isValid) representation.rooms[room.id] = room.toJSON() 99 | } 100 | 101 | return JSON.parse(JSON.stringify(representation)) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /server/lib/infrastructure/infrastructure.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents the whole devices infrastructure 5 | */ 6 | class Infrastructure extends EventEmitter { 7 | /** 8 | * Constructor 9 | */ 10 | constructor () { 11 | super() 12 | 13 | this._devices = new Map() 14 | 15 | this._tags = new Map() 16 | 17 | this._houseFloors = new Map() 18 | 19 | this._automation = { 20 | model: null, 21 | xml: '', 22 | script: '' 23 | } 24 | 25 | Object.seal(this) 26 | } 27 | 28 | /** 29 | * Returns whether or not the device exists in the infrastructure 30 | * @param {string} deviceId the device ID 31 | * @returns {bool} whether or not the device exists 32 | */ 33 | hasDevice (deviceId) { 34 | return this._devices.has(deviceId) 35 | } 36 | 37 | /** 38 | * Adds a device to the infrastructure 39 | * @param {Device} device 40 | */ 41 | addDevice (device) { 42 | this._devices.set(device.id, device) 43 | device.on('valid', () => { 44 | this.emit('newDevice', device) 45 | }) 46 | device.on('newNode', (node) => { 47 | this.emit('newNode', node) 48 | }) 49 | device.on('newProperty', (property) => { 50 | this.emit('newProperty', property) 51 | }) 52 | device.on('update', (update) => { 53 | this.emit('update', update) 54 | }) 55 | this._wasUpdated() 56 | } 57 | 58 | /** 59 | * Gets a device from the infrastructure 60 | * @param {string} deviceId the device ID 61 | * @returns {Device} the device 62 | */ 63 | getDevice (deviceId) { 64 | return this._devices.get(deviceId) 65 | } 66 | 67 | /** 68 | * Gets all devices from the infrastructure 69 | * @returns {Iterable.} the devices 70 | */ 71 | getDevices () { 72 | return this._devices.values() 73 | } 74 | 75 | /** 76 | * Returns whether or not the infrastructure has the given tag 77 | * @returns {bool} whether or not the tag exists 78 | */ 79 | hasTag (tagId) { 80 | return this._tags.has(tagId) 81 | } 82 | 83 | /** 84 | * Adds a tag 85 | * @param {Tag} tag the tag 86 | */ 87 | addTag (tag) { 88 | this._tags.set(tag.id, tag) 89 | tag.on('update', (update) => { 90 | this.emit('update', update) 91 | }) 92 | this._wasUpdated() 93 | } 94 | 95 | /** 96 | * Gets a tag 97 | * @param {string} tagId the tag ID 98 | * @returns {Tag} the tag 99 | */ 100 | getTag (tagId) { 101 | return this._tags.get(tagId) 102 | } 103 | 104 | deleteTag (tagId) { 105 | this._tags.delete(tagId) 106 | this._wasUpdated() 107 | } 108 | /** 109 | * Gets all tags from the infrastructure 110 | * @returns {Iterable.} the tags 111 | */ 112 | getTags () { 113 | return this._tags.values() 114 | } 115 | 116 | hasFloor (floorId) { 117 | return this._houseFloors.has(floorId) 118 | } 119 | 120 | /** 121 | * Adds a floor 122 | * @param {Floor} the floor 123 | */ 124 | addFloor (floor) { 125 | this._houseFloors.set(floor.id, floor) 126 | floor.on('update', (update) => { 127 | this.emit('update', update) 128 | }) 129 | this._wasUpdated() 130 | } 131 | 132 | /** 133 | * Gets a floor 134 | * @param {string} floorId the floor ID 135 | * @returns {Floor} the floor 136 | */ 137 | getFloor (floorId) { 138 | return this._houseFloors.get(floorId) 139 | } 140 | 141 | deleteFloor (floorId) { 142 | this._houseFloors.delete(floorId) 143 | this._wasUpdated() 144 | } 145 | 146 | /** 147 | * Gets all floors from the infrastructure 148 | * @returns {Iterable.} the floors 149 | */ 150 | getFloors () { 151 | return this._houseFloors.values() 152 | } 153 | 154 | setAutomation (opts) { 155 | this._automation.xml = opts.xml 156 | this._automation.script = opts.script 157 | 158 | this.emit('automationUpdated') 159 | } 160 | 161 | getAutomation () { 162 | return { 163 | script: this._automation.script, 164 | xml: this._automation.xml 165 | } 166 | } 167 | 168 | _wasUpdated () { 169 | this.emit('update', { entity: this }) 170 | } 171 | 172 | toJSON () { 173 | const representation = { devices: {}, tags: {}, house: { floors: {} }, automation: '' } 174 | 175 | for (const device of this.getDevices()) { 176 | if (device.isValid) representation.devices[device.id] = device.toJSON() 177 | } 178 | 179 | for (const tag of this.getTags()) { 180 | if (tag.isValid) representation.tags[tag.id] = tag.toJSON() 181 | } 182 | 183 | for (const floor of this.getFloors()) { 184 | if (floor.isValid) representation.house.floors[floor.id] = floor.toJSON() 185 | } 186 | 187 | representation.automation = this.getAutomation().xml 188 | 189 | return JSON.parse(JSON.stringify(representation)) 190 | } 191 | } 192 | 193 | export default new Infrastructure() 194 | -------------------------------------------------------------------------------- /server/lib/infrastructure/node.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a node 5 | */ 6 | export default class Node extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._device = null 11 | 12 | this._id = null 13 | this._type = null 14 | this._name = null 15 | this._propertiesDefinition = null 16 | 17 | this._properties = new Map() 18 | 19 | this._tags = new Set() 20 | 21 | this.isValid = false 22 | 23 | this.model = null 24 | 25 | Object.seal(this) 26 | } 27 | 28 | hasProperty (propertyId) { 29 | return this._properties.has(propertyId) 30 | } 31 | 32 | addProperty (property) { 33 | this._properties.set(property.id, property) 34 | property.on('valid', () => { 35 | this.emit('newProperty', property) 36 | }) 37 | property.on('update', (update) => { 38 | this.emit('update', update) 39 | }) 40 | this._wasUpdated() 41 | } 42 | 43 | getProperty (nodeId) { 44 | return this._properties.get(nodeId) 45 | } 46 | 47 | getProperties () { 48 | return this._properties.values() 49 | } 50 | 51 | addTag (tag) { 52 | this._tags.add(tag) 53 | this._wasUpdated() 54 | } 55 | deleteTag (tag) { 56 | this._tags.delete(tag) 57 | this._wasUpdated() 58 | } 59 | 60 | hasTag (tag) { 61 | return this._tags.has(tag) 62 | } 63 | 64 | getTags () { 65 | return this._tags.values() 66 | } 67 | 68 | get device () { return this._device } 69 | set device (val) { 70 | if (!val || this._device === val) return 71 | this._device = val 72 | this._wasUpdated() 73 | } 74 | get id () { return this._id } 75 | set id (val) { 76 | if (!val || this._id === val) return 77 | this._id = val 78 | this._wasUpdated() 79 | } 80 | get name () { return this._name } 81 | set name (val) { 82 | if (!val || this._name === val) return 83 | this._name = val 84 | this._wasUpdated() 85 | } 86 | get type () { return this._type } 87 | set type (val) { 88 | if (!val || this._type === val) return 89 | this._type = val 90 | if (!this._name) this._name = val 91 | this._wasUpdated() 92 | } 93 | get propertiesDefinition () { return this._propertiesDefinition } 94 | set propertiesDefinition (val) { 95 | if (!val || this._propertiesDefinition === val) return 96 | this._propertiesDefinition = val 97 | this._wasUpdated() 98 | } 99 | 100 | _wasUpdated () { 101 | const wasValid = this.isValid 102 | this.isValid = ( 103 | this._device !== null && 104 | this._id !== null && 105 | this._name !== null && 106 | this._type !== null && 107 | this._propertiesDefinition !== null 108 | ) 109 | 110 | if (!this.isValid) return 111 | 112 | if (!wasValid) { 113 | if (this._device.isValid) this.emit('valid') 114 | else { 115 | this._device.once('valid', () => { 116 | this.emit('valid') 117 | }) 118 | } 119 | } 120 | 121 | this.emit('update', { entity: this }) 122 | } 123 | 124 | toJSON () { 125 | const representation = {} 126 | representation.id = this.id 127 | representation.type = this.type 128 | representation.name = this.name 129 | representation.propertiesDefinition = this.propertiesDefinition 130 | representation.properties = {} 131 | for (const property of this.getProperties()) { 132 | if (property.isValid) representation.properties[property.id] = property.toJSON() 133 | } 134 | representation.tags = [] 135 | for (const tag of this.getTags()) representation.tags.push(tag.id) 136 | 137 | return JSON.parse(JSON.stringify(representation)) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /server/lib/infrastructure/property.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a property 5 | */ 6 | export default class Property extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._node = null 11 | 12 | this._id = null 13 | this._value = null 14 | this._settable = null 15 | 16 | this.isValid = false 17 | 18 | this.model = null 19 | 20 | Object.seal(this) 21 | } 22 | 23 | get node () { return this._node } 24 | set node (val) { 25 | if (!val || this._node === val) return 26 | this._node = val 27 | this._wasUpdated() 28 | } 29 | get id () { return this._id } 30 | set id (val) { 31 | if (!val || this._id === val) return 32 | this._id = val 33 | this._wasUpdated() 34 | } 35 | get value () { return this._value } 36 | set value (val) { 37 | if (this._value === val) return 38 | this._value = val; this._wasUpdated() 39 | } 40 | get settable () { return this._settable } 41 | set settable (val) { 42 | if (this._value === val) return 43 | this._settable = val; this._wasUpdated() 44 | } 45 | 46 | _wasUpdated () { 47 | const wasValid = this.isValid 48 | this.isValid = ( 49 | this._node !== null && 50 | this._id !== null && 51 | this._settable !== null 52 | ) 53 | 54 | if (!this.isValid) return 55 | 56 | if (!wasValid) { 57 | if (this._node.device.isValid) this.emit('valid') 58 | else { 59 | this._node.device.once('valid', () => { 60 | this.emit('valid') 61 | }) 62 | } 63 | } 64 | 65 | this.emit('update', { entity: this }) 66 | } 67 | 68 | toJSON () { 69 | const representation = {} 70 | representation.id = this.id 71 | representation.value = this.value 72 | representation.settable = this.settable 73 | 74 | return JSON.parse(JSON.stringify(representation)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/lib/infrastructure/room.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a floor 5 | */ 6 | export default class Floor extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._floor = null 11 | 12 | this._id = null 13 | this._name = null 14 | this._tagId = null 15 | 16 | this.isValid = false 17 | 18 | this.model = null 19 | 20 | Object.seal(this) 21 | } 22 | 23 | get floor () { return this._floor } 24 | set floor (val) { 25 | if (!val || this._floor === val) return 26 | this._floor = val 27 | this._wasUpdated() 28 | } 29 | get id () { return this._id } 30 | set id (val) { 31 | if (!val || this._id === val) return 32 | this._id = val 33 | this._wasUpdated() 34 | } 35 | get name () { return this._name } 36 | set name (val) { 37 | if (!val || this._name === val) return 38 | this._name = val 39 | this._wasUpdated() 40 | } 41 | get tagId () { return this._tagId } 42 | set tagId (val) { 43 | if (!val || this._tagId === val) return 44 | this._tagId = val 45 | this._wasUpdated() 46 | } 47 | 48 | _wasUpdated () { 49 | const wasValid = this.isValid 50 | this.isValid = ( 51 | this._name !== null && 52 | this._id !== null && 53 | this._floor !== null && 54 | this._tagId !== null 55 | ) 56 | 57 | if (!this.isValid) return 58 | 59 | if (!wasValid) this.emit('valid') 60 | 61 | this.emit('update', { entity: this }) 62 | } 63 | 64 | toJSON () { 65 | const representation = {} 66 | representation.id = this.id 67 | representation.name = this.name 68 | representation.tagId = this.tagId 69 | 70 | return JSON.parse(JSON.stringify(representation)) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /server/lib/infrastructure/tag.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | 3 | /** 4 | * This class represents a tag 5 | */ 6 | export default class Tag extends EventEmitter { 7 | constructor () { 8 | super() 9 | 10 | this._id = null 11 | 12 | this.isValid = false 13 | 14 | this.model = null 15 | 16 | Object.seal(this) 17 | } 18 | 19 | get id () { return this._id } 20 | set id (val) { 21 | if (!val || this._id === val) return 22 | this._id = val 23 | this._wasUpdated() 24 | } 25 | 26 | _wasUpdated () { 27 | const wasValid = this.isValid 28 | this.isValid = ( 29 | this._id !== null 30 | ) 31 | 32 | if (!this.isValid) return 33 | 34 | if (!wasValid) this.emit('valid') 35 | 36 | this.emit('update', { entity: this }) 37 | } 38 | 39 | toJSON () { 40 | const representation = {} 41 | representation.id = this.id 42 | 43 | return JSON.parse(JSON.stringify(representation)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/lib/log.js: -------------------------------------------------------------------------------- 1 | import c from 'clor/c' 2 | import {EOL} from 'os' 3 | 4 | export const LOG_LEVELS = { 5 | FATAL: 0, 6 | ERROR: 1, 7 | WARN: 2, 8 | INFO: 3, 9 | DEBUG: 4 10 | } 11 | 12 | class Log { 13 | constructor () { 14 | this.logLevel = LOG_LEVELS.INFO 15 | } 16 | 17 | setLevel (logLevel) { 18 | this.logLevel = logLevel 19 | } 20 | 21 | print (text) { 22 | process.stdout.write(text + EOL) 23 | } 24 | 25 | log (message, meta, type) { 26 | let output = this._getDate() 27 | output += ' | ' + this._getColoredType(type) 28 | output += ' ' 29 | output += (undefined !== message ? message : '') 30 | if (meta && Object.keys(meta).length) { 31 | output += EOL 32 | let stringify = JSON.stringify(meta, null, 2) 33 | let splitted = stringify.split('\n') 34 | splitted.forEach(function (line, index) { 35 | output += ' ' + line 36 | if (index < splitted.length - 1) { 37 | output += EOL 38 | } 39 | }) 40 | } 41 | this.print(output) 42 | } 43 | 44 | fatal (message, meta) { 45 | if (this.logLevel >= LOG_LEVELS.FATAL) { 46 | return this.log(message, meta, LOG_LEVELS.FATAL) 47 | } 48 | } 49 | 50 | error (message, meta) { 51 | if (this.logLevel >= LOG_LEVELS.ERROR) { 52 | return this.log(message, meta, LOG_LEVELS.ERROR) 53 | } 54 | } 55 | 56 | warn (message, meta) { 57 | if (this.logLevel >= LOG_LEVELS.WARN) { 58 | return this.log(message, meta, LOG_LEVELS.WARN) 59 | } 60 | } 61 | 62 | info (message, meta) { 63 | if (this.logLevel >= LOG_LEVELS.INFO) { 64 | return this.log(message, meta, LOG_LEVELS.INFO) 65 | } 66 | } 67 | 68 | debug (message, meta) { 69 | if (this.logLevel >= LOG_LEVELS.DEBUG) { 70 | return this.log(message, meta, LOG_LEVELS.DEBUG) 71 | } 72 | } 73 | 74 | _getDate () { 75 | const pad = n => n < 10 ? '0' + n : n 76 | const date = new Date() 77 | return date.getUTCFullYear() + '-' + 78 | pad(date.getUTCMonth() + 1) + '-' + 79 | pad(date.getUTCDate()) + ' ' + 80 | pad(date.getUTCHours()) + ':' + 81 | pad(date.getUTCMinutes()) + ':' + 82 | pad(date.getUTCSeconds()) 83 | } 84 | 85 | _getColoredType (type) { 86 | switch (type) { 87 | case LOG_LEVELS.FATAL: 88 | return c`fatal:` 89 | case LOG_LEVELS.ERROR: 90 | return c`error:` 91 | case LOG_LEVELS.WARN: 92 | return c` warn:` 93 | case LOG_LEVELS.INFO: 94 | return c` info:` 95 | case LOG_LEVELS.DEBUG: 96 | return c`debug:` 97 | default: 98 | return ' log:' 99 | } 100 | } 101 | } 102 | 103 | export default new Log() 104 | -------------------------------------------------------------------------------- /server/lib/mqtt-client.js: -------------------------------------------------------------------------------- 1 | import mqtt from 'mqtt' 2 | 3 | /** 4 | * This function creates an MQTT client 5 | * @param {Object} options options to be passed to the `mqtt` module `connect()` function 6 | * @returns {module:mqtt.Client} promise, to be resolved on success with the settings or rejected on failure 7 | */ 8 | export default function createMqttClient (options) { 9 | return mqtt.connect(options) 10 | } 11 | -------------------------------------------------------------------------------- /server/lib/settings.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import toml from 'toml' 5 | 6 | /** 7 | * This function loads settings from a TOML file 8 | * @returns {Promise} promise, to be resolved on success with the settings or rejected on failure 9 | */ 10 | export default function load (dataDir) { 11 | return new Promise((resolve, reject) => { 12 | fs.readFile(path.join(dataDir, './settings.toml'), 'utf8', (err, data) => { 13 | if (err) return reject(err) 14 | 15 | const settings = toml.parse(data) 16 | resolve(settings) 17 | }) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /server/lib/statistics.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from './database' 2 | import {STATS_TYPES, STATS_GRANULARITY} from '../../common/statistics' 3 | 4 | export async function getStatistics (opts) { 5 | const {deviceId, nodeId, propertyId, type, granularity, range} = opts 6 | 7 | if (type === STATS_TYPES.GRAPH) { 8 | let result 9 | switch (granularity) { 10 | case STATS_GRANULARITY.HOUR: 11 | result = await bookshelf.knex.raw(` 12 | SELECT 13 | date(date / 1000, 'unixepoch') AS day, 14 | strftime('%H', datetime(date / 1000, 'unixepoch')) AS hour, 15 | min(CAST(value AS NUMERIC)) AS minimum, 16 | avg(CAST(value AS NUMERIC)) AS average, 17 | max(CAST(value AS NUMERIC)) AS maximum 18 | FROM property_history h 19 | INNER JOIN properties p ON h.property_id = p.id 20 | INNER JOIN nodes n ON p.node_id = n.id 21 | INNER JOIN devices d ON n.device_id = d.id 22 | WHERE d.id = ? AND n.device_node_id = ? AND p.node_property_id = ? AND day = ? 23 | GROUP BY day, hour 24 | ORDER BY day, hour; 25 | `, [deviceId, nodeId, propertyId, range.day]) 26 | 27 | return result 28 | case STATS_GRANULARITY.DAY: 29 | result = await bookshelf.knex.raw(` 30 | SELECT 31 | date(date / 1000, 'unixepoch') AS day, 32 | min(CAST(value AS NUMERIC)) AS minimum, 33 | avg(CAST(value AS NUMERIC)) AS average, 34 | max(CAST(value AS NUMERIC)) AS maximum 35 | FROM property_history h 36 | INNER JOIN properties p ON h.property_id = p.id 37 | INNER JOIN nodes n ON p.node_id = n.id 38 | INNER JOIN devices d ON n.device_id = d.id 39 | WHERE d.id = ? AND n.device_node_id = ? AND p.node_property_id = ? AND day BETWEEN ? AND ? -- included 40 | GROUP BY day 41 | ORDER BY day; 42 | `, [deviceId, nodeId, propertyId, range.startDay, range.endDay]) 43 | 44 | return result 45 | case STATS_GRANULARITY.MONTH: 46 | result = await bookshelf.knex.raw(` 47 | SELECT 48 | strftime('%Y', date / 1000, 'unixepoch') AS year, 49 | strftime('%m', date / 1000, 'unixepoch') AS month, 50 | min(CAST(value AS NUMERIC)) AS minimum, 51 | avg(CAST(value AS NUMERIC)) AS average, 52 | max(CAST(value AS NUMERIC)) AS maximum 53 | FROM property_history h 54 | INNER JOIN properties p ON h.property_id = p.id 55 | INNER JOIN nodes n ON p.node_id = n.id 56 | INNER JOIN devices d ON n.device_id = d.id 57 | WHERE d.id = ? AND n.device_node_id = ? AND p.node_property_id = ? AND year = ? 58 | GROUP BY year, month 59 | ORDER BY year, month; 60 | `, [deviceId, nodeId, propertyId, range.year]) 61 | 62 | return result 63 | } 64 | } else if (type === STATS_TYPES.LIST) { 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/lib/validators/schemas/settings.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'title': 'Settings', 3 | 'type': 'object', 4 | 'properties': { 5 | 'bindings': { 6 | 'type': 'object', 7 | 'properties': { 8 | 'aqara': { 9 | 'type': 'object', 10 | 'properties': { 11 | 'enabled': { 12 | 'type': 'boolean' 13 | }, 14 | 'gateway': { 15 | 'type': 'object', 16 | 'properties': { 17 | 'ip': { 18 | 'type': 'string' 19 | }, 20 | 'password': { 21 | 'type': 'string' 22 | } 23 | }, 24 | 'required': ['ip', 'password'] 25 | } 26 | }, 27 | 'required': ['enabled', 'gateway'] 28 | }, 29 | 'yeelight': { 30 | 'type': 'object', 31 | 'properties': { 32 | 'enabled': { 33 | 'type': 'boolean' 34 | } 35 | }, 36 | 'required': ['enabled'] 37 | } 38 | }, 39 | 'required': ['aqara', 'yeelight'] 40 | }, 41 | 'mqtt': { 42 | 'type': 'object', 43 | 'properties': { 44 | 'host': { 45 | 'type': 'string' 46 | }, 47 | 'port': { 48 | 'type': 'integer' 49 | } 50 | }, 51 | 'required': ['host', 'port'] 52 | }, 53 | 'homie-esp8266': { 54 | 'type': 'object', 55 | 'properties': { 56 | 'wifi': { 57 | 'type': 'object', 58 | 'properties': { 59 | 'ssid': { 60 | 'type': 'string' 61 | }, 62 | 'password': { 63 | 'type': 'string' 64 | } 65 | }, 66 | 'required': ['ssid', 'password'] 67 | }, 68 | 'mqtt': { 69 | 'type': 'object', 70 | 'properties': { 71 | 'host': { 72 | 'type': 'string' 73 | }, 74 | 'port': { 75 | 'type': 'integer' 76 | } 77 | }, 78 | 'required': ['host', 'port'] 79 | } 80 | }, 81 | 'required': ['wifi', 'mqtt'] 82 | } 83 | }, 84 | 'required': ['bindings', 'mqtt', 'homie-esp8266'] 85 | } 86 | -------------------------------------------------------------------------------- /server/lib/validators/settings.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import schema from './schemas/settings' 3 | 4 | const ajv = new Ajv() 5 | 6 | export function validate (settings) { 7 | const valid = ajv.validate(schema, settings) 8 | if (valid) return { valid } 9 | else return { valid, errors: ajv.errors } 10 | } 11 | -------------------------------------------------------------------------------- /server/lib/websocket-server.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import {createServer} from 'http' 3 | import cookie from 'cookie' 4 | import express from 'express' 5 | import speakeasy from 'speakeasy' 6 | import uuid from 'uuid' 7 | import bodyParser from 'body-parser' 8 | import {Server as WebSocketServer} from 'ws' 9 | import {verify} from './hash' 10 | import AuthTokenModel from '../models/auth-token' 11 | import SettingModel from '../models/setting' 12 | 13 | /** 14 | * This function creates a WebSocket server. 15 | * The HTTP server created handles authentication 16 | * @param {ip: string, port: number, db: Database, settings: Object} opts options 17 | * @returns {Promise} promise, to be resolved on success with the WebSocket server instance or rejected on failure 18 | */ 19 | export default function createWebsocketServer (opts) { 20 | return new Promise((resolve, reject) => { 21 | const app = express() 22 | const httpServer = createServer() 23 | 24 | app.use(bodyParser.json()) 25 | 26 | app.use(function (req, res, next) { 27 | res.header('Access-Control-Allow-Origin', req.headers.origin) 28 | res.header('Access-Control-Allow-Credentials', 'true') 29 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 30 | next() 31 | }) 32 | 33 | app.post('/login', async function (req, res) { 34 | const passwordModel = await SettingModel.forge({ key: 'password' }).fetch() 35 | const matchPassword = await verify(passwordModel.attributes['value'], req.body.password) 36 | const otpSecretModel = await SettingModel.forge({ key: 'otp_secret' }).fetch() 37 | const matchOtp = speakeasy.totp.verify({ 38 | secret: otpSecretModel.attributes['value'], 39 | encoding: 'base32', 40 | token: req.body.otp 41 | }) 42 | 43 | if (matchPassword && matchOtp) { 44 | const token = uuid() 45 | await AuthTokenModel.forge({ token }).save(null, { method: 'insert' }) // we insert primary key so considered update by default 46 | const never = new Date(253402300000000) // year 999 47 | return res.cookie('ACCESSTOKEN', token, { 48 | expires: never, 49 | httpOnly: true 50 | }).sendStatus(200) 51 | } else { 52 | return res.sendStatus(401) 53 | } 54 | }) 55 | 56 | app.post('/logout', async function (req, res) { 57 | const cookies = req.headers.cookie ? cookie.parse(req.headers.cookie) : null 58 | if (!cookies || !cookies['ACCESSTOKEN']) return res.sendStatus(401) 59 | await AuthTokenModel.forge({ token: cookies['ACCESSTOKEN'] }).destroy() 60 | res.clearCookie('ACCESSTOKEN').sendStatus(204) 61 | }) 62 | 63 | if (process.env.NODE_ENV === 'production') { 64 | app.use(express.static(path.join(__dirname, '../../dist-app'))) 65 | app.use(function (req, res, next) { 66 | res.sendFile(path.join(__dirname, '../../dist-app/index.html')) 67 | }) 68 | } 69 | 70 | httpServer.on('request', app) 71 | 72 | const wss = new WebSocketServer({ 73 | server: httpServer, 74 | async verifyClient (info, cb) { 75 | const fail = () => cb(false, 401, 'Unauthorized') 76 | const cookies = info.req.headers.cookie ? cookie.parse(info.req.headers.cookie) : null 77 | if (!cookies || !cookies['ACCESSTOKEN']) return fail() 78 | const tokenInDb = await AuthTokenModel.forge({ token: cookies['ACCESSTOKEN'] }).fetch() 79 | if (tokenInDb) return cb(true) 80 | else return fail() 81 | } 82 | }) 83 | 84 | httpServer.listen(opts.port, opts.ip).on('listening', function onListening () { 85 | resolve(wss) 86 | }).on('error', function onError (err) { 87 | reject(err) 88 | }) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /server/migrations/20161219145339_initial.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema 3 | .createTable('devices', function (table) { 4 | table.string('id').primary().notNullable() 5 | table.string('name').notNullable() 6 | table.boolean('online').notNullable() 7 | table.string('local_ip').notNullable() 8 | table.string('mac').notNullable() 9 | table.integer('stats_signal').notNullable() 10 | table.integer('stats_uptime').notNullable() 11 | table.integer('stats_interval_in_seconds').notNullable() 12 | table.string('fw_name').notNullable() 13 | table.string('fw_version').notNullable() 14 | table.string('fw_checksum').notNullable() 15 | table.string('implementation').notNullable() 16 | }) 17 | .createTable('nodes', function (table) { 18 | table.increments('id').primary().notNullable() 19 | table.string('device_id').notNullable().references('id').inTable('devices') 20 | table.string('device_node_id').notNullable() 21 | table.string('name').notNullable() 22 | table.string('type').notNullable() 23 | table.string('properties').notNullable() 24 | 25 | table.unique(['device_id', 'device_node_id']) 26 | }) 27 | .createTable('properties', function (table) { 28 | table.increments('id').primary().notNullable() 29 | table.integer('node_id').notNullable().references('id').inTable('nodes') 30 | table.string('node_property_id').notNullable() 31 | table.boolean('settable').notNullable() 32 | }) 33 | .createTable('property_history', function (table) { 34 | table.increments('id').primary().notNullable() 35 | table.integer('property_id').notNullable().references('id').inTable('properties') 36 | table.string('value').notNullable() 37 | table.dateTime('date').notNullable() 38 | }) 39 | .createTable('auth_tokens', function (table) { 40 | table.string('token').primary().notNullable() 41 | }) 42 | } 43 | 44 | exports.down = function (knex) { 45 | return knex.schema 46 | .dropTableIfExists('auth_tokens') 47 | .dropTableIfExists('property_history') 48 | .dropTableIfExists('properties') 49 | .dropTableIfExists('nodes') 50 | .dropTableIfExists('devices') 51 | } 52 | -------------------------------------------------------------------------------- /server/migrations/20161219151706_tags.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema 3 | .createTable('tags', function (table) { 4 | table.string('id').primary().notNullable() 5 | }) 6 | .createTable('nodes_tags', function (table) { 7 | table.increments('id').primary().notNullable() 8 | table.string('node_id').notNullable().references('id').inTable('nodes') 9 | table.string('tag_id').notNullable().references('id').inTable('tags') 10 | 11 | table.unique(['node_id', 'tag_id']) 12 | }) 13 | } 14 | 15 | exports.down = function (knex) { 16 | return knex.schema 17 | .dropTableIfExists('nodes_tags') 18 | .dropTableIfExists('tags') 19 | } 20 | -------------------------------------------------------------------------------- /server/migrations/20161219152015_house-modelization.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema 3 | .createTable('floors', function (table) { 4 | table.increments('id').primary().notNullable() 5 | table.string('name').notNullable() 6 | table.json('rooms_map').notNullable() 7 | }) 8 | .createTable('rooms', function (table) { 9 | table.increments('id').primary().notNullable() 10 | table.string('name').notNullable() 11 | table.string('floor_id').notNullable().references('id').inTable('floors').onDelete('CASCADE') 12 | table.string('tag_id').notNullable().references('id').inTable('tags') 13 | 14 | table.unique(['node_id', 'tag_id']) 15 | }) 16 | } 17 | 18 | exports.down = function (knex) { 19 | return knex.schema 20 | .dropTableIfExists('rooms') 21 | .dropTableIfExists('floors') 22 | } 23 | -------------------------------------------------------------------------------- /server/migrations/20170116152739_automation.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema 3 | .createTable('automation_scripts', function (table) { 4 | table.increments('id').primary().notNullable() 5 | table.string('script').notNullable() 6 | table.string('blockly_xml').notNullable() 7 | }) 8 | } 9 | 10 | exports.down = function (knex) { 11 | return knex.schema 12 | .dropTableIfExists('automation_scripts') 13 | } 14 | -------------------------------------------------------------------------------- /server/migrations/20170119120420_settings.js: -------------------------------------------------------------------------------- 1 | import speakeasy from 'speakeasy' 2 | 3 | exports.up = function (knex) { 4 | return Promise.all([ 5 | knex.schema 6 | .createTable('settings', function (table) { 7 | table.string('key').primary().notNullable() 8 | table.string('value').notNullable() 9 | }).then(function () { 10 | return knex('settings').insert([ 11 | { key: 'password', value: '75c9aa9127e43df7b1e8f4bad7d887d4,adde47c3d7894070ffab1ca6b855761ffe942a0b3965ba534b67c46a84fcda56' }, 12 | { key: 'otp_secret', value: speakeasy.generateSecret().base32 } 13 | ]) 14 | }) 15 | ]) 16 | } 17 | 18 | exports.down = function (knex) { 19 | return knex.schema 20 | .dropTableIfExists('settings') 21 | } 22 | -------------------------------------------------------------------------------- /server/models/Device.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Device extends bookshelf.Model { 4 | get tableName () { return 'devices' } 5 | 6 | nodes () { 7 | return this.hasMany('Node') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Device', Device) 12 | -------------------------------------------------------------------------------- /server/models/Floor.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Floor extends bookshelf.Model { 4 | get tableName () { return 'floors' } 5 | 6 | rooms () { 7 | return this.hasMany('Room') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Floor', Floor) 12 | -------------------------------------------------------------------------------- /server/models/Node.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Node extends bookshelf.Model { 4 | get tableName () { return 'nodes' } 5 | 6 | device () { 7 | return this.belongsTo('Device') 8 | } 9 | 10 | properties () { 11 | return this.hasMany('Property') 12 | } 13 | 14 | tags () { 15 | return this.belongsToMany('Tag') 16 | } 17 | } 18 | 19 | export default bookshelf.model('Node', Node) 20 | -------------------------------------------------------------------------------- /server/models/Property.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Property extends bookshelf.Model { 4 | get tableName () { return 'properties' } 5 | 6 | node () { 7 | return this.belongsTo('Node') 8 | } 9 | 10 | history () { 11 | return this.hasMany('PropertyHistory') 12 | } 13 | } 14 | 15 | export default bookshelf.model('Property', Property) 16 | -------------------------------------------------------------------------------- /server/models/Room.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Room extends bookshelf.Model { 4 | get tableName () { return 'rooms' } 5 | 6 | floor () { 7 | return this.belongsTo('Floor') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Room', Room) 12 | -------------------------------------------------------------------------------- /server/models/Tag.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Tag extends bookshelf.Model { 4 | get tableName () { return 'tags' } 5 | 6 | nodes () { 7 | return this.belongsToMany('Node') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Tag', Tag) 12 | -------------------------------------------------------------------------------- /server/models/auth-token.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class AuthToken extends bookshelf.Model { 4 | get tableName () { return 'auth_tokens' } 5 | get idAttribute () { return 'token' } 6 | } 7 | 8 | export default bookshelf.model('AuthToken', AuthToken) 9 | -------------------------------------------------------------------------------- /server/models/automation-script.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class AutomationScript extends bookshelf.Model { 4 | get tableName () { return 'automation_scripts' } 5 | } 6 | 7 | export default bookshelf.model('AutomationScript', AutomationScript) 8 | -------------------------------------------------------------------------------- /server/models/device.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Device extends bookshelf.Model { 4 | get tableName () { return 'devices' } 5 | 6 | nodes () { 7 | return this.hasMany('Node') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Device', Device) 12 | -------------------------------------------------------------------------------- /server/models/floor.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Floor extends bookshelf.Model { 4 | get tableName () { return 'floors' } 5 | 6 | rooms () { 7 | return this.hasMany('Room') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Floor', Floor) 12 | -------------------------------------------------------------------------------- /server/models/node.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Node extends bookshelf.Model { 4 | get tableName () { return 'nodes' } 5 | 6 | device () { 7 | return this.belongsTo('Device') 8 | } 9 | 10 | properties () { 11 | return this.hasMany('Property') 12 | } 13 | 14 | tags () { 15 | return this.belongsToMany('Tag') 16 | } 17 | } 18 | 19 | export default bookshelf.model('Node', Node) 20 | -------------------------------------------------------------------------------- /server/models/property-history.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class PropertyHistory extends bookshelf.Model { 4 | get tableName () { return 'property_history' } 5 | 6 | property () { 7 | return this.belongsTo('Node') 8 | } 9 | } 10 | 11 | export default bookshelf.model('PropertyHistory', PropertyHistory) 12 | -------------------------------------------------------------------------------- /server/models/property.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Property extends bookshelf.Model { 4 | get tableName () { return 'properties' } 5 | 6 | node () { 7 | return this.belongsTo('Node') 8 | } 9 | 10 | history () { 11 | return this.hasMany('PropertyHistory') 12 | } 13 | } 14 | 15 | export default bookshelf.model('Property', Property) 16 | -------------------------------------------------------------------------------- /server/models/room.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Room extends bookshelf.Model { 4 | get tableName () { return 'rooms' } 5 | 6 | floor () { 7 | return this.belongsTo('Floor') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Room', Room) 12 | -------------------------------------------------------------------------------- /server/models/setting.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Setting extends bookshelf.Model { 4 | get tableName () { return 'settings' } 5 | get idAttribute () { return 'key' } 6 | } 7 | 8 | export default bookshelf.model('Setting', Setting) 9 | -------------------------------------------------------------------------------- /server/models/tag.js: -------------------------------------------------------------------------------- 1 | import {bookshelf} from '../lib/database' 2 | 3 | class Tag extends bookshelf.Model { 4 | get tableName () { return 'tags' } 5 | 6 | nodes () { 7 | return this.belongsToMany('Node') 8 | } 9 | } 10 | 11 | export default bookshelf.model('Tag', Tag) 12 | -------------------------------------------------------------------------------- /server/services/database.js: -------------------------------------------------------------------------------- 1 | import Device from '../lib/infrastructure/device' 2 | import Node from '../lib/infrastructure/node' 3 | import Property from '../lib/infrastructure/property' 4 | import Tag from '../lib/infrastructure/tag' 5 | import Floor from '../lib/infrastructure/floor' 6 | import Room from '../lib/infrastructure/room' 7 | 8 | import DeviceModel from '../models/device' 9 | import TagModel from '../models/tag' 10 | import FloorModel from '../models/floor' 11 | import AutomationScript from '../models/automation-script' 12 | 13 | /** 14 | * This function synchronizes the database with the infrastructure. 15 | * It iterates through all devices, nodes, properties and tags of the SQLite database, 16 | * and updates the infrastructure accordingly 17 | * @param {Infrastructure} infrastructure the infrastructure to synchronize against 18 | * @returns {Promise} promise, to be resolved on success or rejected on failure 19 | */ 20 | export async function getInfrastructure (infrastructure) { 21 | /* Tags */ 22 | 23 | const tags = await TagModel.fetchAll() 24 | 25 | for (const tagInDb of tags.models) { 26 | const tag = new Tag() 27 | tag.model = tagInDb 28 | tag.id = tagInDb['id'] 29 | infrastructure.addTag(tag) 30 | } 31 | 32 | /* Automation */ 33 | 34 | const automationScripts = await AutomationScript.fetchAll() 35 | 36 | for (const automationScriptInDb of automationScripts.models) { 37 | const automationInInfra = infrastructure.getAutomation() 38 | automationInInfra.model = automationScriptInDb 39 | infrastructure.setAutomation({ 40 | xml: automationScriptInDb.attributes['blockly_xml'], 41 | script: automationScriptInDb.attributes['script'] 42 | }) 43 | } 44 | 45 | /* House */ 46 | 47 | const floors = await FloorModel 48 | .fetchAll({ withRelated: ['rooms'] }) 49 | 50 | for (const floorInDb of floors.models) { 51 | const floor = new Floor() 52 | floor.model = floorInDb 53 | floor.id = floorInDb.attributes['id'] 54 | floor.name = floorInDb.attributes['name'] 55 | floor.roomsMap = JSON.parse(floorInDb.attributes['rooms_map']) 56 | infrastructure.addFloor(floor) 57 | 58 | for (const roomInDb of floorInDb.related('rooms').models) { 59 | const room = new Room() 60 | room.model = roomInDb 61 | room.floor = floor 62 | room.id = roomInDb.attributes['id'] 63 | room.name = roomInDb.attributes['name'] 64 | room.tagId = roomInDb.attributes['tag_id'] 65 | floor.addRoom(room) 66 | } 67 | } 68 | 69 | /* Devices */ 70 | 71 | const devices = await DeviceModel 72 | .fetchAll({ withRelated: ['nodes', 'nodes.tags', 'nodes.properties', { 73 | 'nodes.properties.history': function (qb) { 74 | qb.groupBy('property_id') 75 | } 76 | }]}) 77 | 78 | for (const deviceInDb of devices.models) { 79 | const device = new Device() 80 | device.model = deviceInDb 81 | device.id = deviceInDb.attributes['id'] 82 | device.name = deviceInDb.attributes['name'] 83 | device.online = deviceInDb.attributes['online'] 84 | device.localIp = deviceInDb.attributes['local_ip'] 85 | device.mac = deviceInDb.attributes['mac'] 86 | device.setStatProperty('signal', parseInt(deviceInDb.attributes['stats_signal'], 10)) 87 | device.setStatProperty('uptime', parseInt(deviceInDb.attributes['stats_uptime'], 10)) 88 | device.setStatProperty('interval', parseInt(deviceInDb.attributes['stats_interval_in_seconds'], 10)) 89 | device.setFirmwareProperty('name', deviceInDb.attributes['fw_name']) 90 | device.setFirmwareProperty('version', deviceInDb.attributes['fw_version']) 91 | device.setFirmwareProperty('checksum', deviceInDb.attributes['fw_checksum']) 92 | device.implementation = deviceInDb.attributes['implementation'] 93 | 94 | for (const nodeInDb of deviceInDb.related('nodes').models) { 95 | const node = new Node() 96 | node.model = nodeInDb 97 | node.device = device 98 | node.id = nodeInDb.attributes['device_node_id'] 99 | node.name = nodeInDb.attributes['name'] 100 | node.type = nodeInDb.attributes['type'] 101 | node.propertiesDefinition = nodeInDb.attributes['properties'] 102 | for (const tagInDb of nodeInDb.related('tags').models) { 103 | node.addTag(infrastructure.getTag(tagInDb.attributes.id)) 104 | } 105 | 106 | for (const propertyInDb of nodeInDb.related('properties').models) { 107 | const property = new Property() 108 | property.model = propertyInDb 109 | property.node = node 110 | property.id = propertyInDb.attributes['node_property_id'] 111 | property.value = propertyInDb.related('history').models.length === 1 ? propertyInDb.related('history').models[0].attributes['value'] : null 112 | property.settable = propertyInDb.attributes['settable'] 113 | 114 | node.addProperty(property) 115 | } 116 | 117 | device.addNode(node) 118 | } 119 | 120 | infrastructure.addDevice(device) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /server/start.js: -------------------------------------------------------------------------------- 1 | import createMqttClient from './lib/mqtt-client' 2 | import Client from './lib/client' 3 | import {bridgeMqttToInfrastructure} from './lib/bridges/mqtt-infrastructure' 4 | import {bridgeInfrastructureToDatabase} from './lib/bridges/infrastructure-database' 5 | import {bridgeInfrastructureToWebsocket} from './lib/bridges/infrastructure-websocket' 6 | import infrastructure from './lib/infrastructure/infrastructure' 7 | import {getInfrastructure} from './services/database' 8 | import {handleAutomation} from './lib/automator.js' 9 | 10 | /* import bindings */ 11 | 12 | import aqaraBinding from './bindings/aqara' 13 | import yeelightBinding from './bindings/yeelight' 14 | const BINDINGS = { 15 | aqara: aqaraBinding, 16 | yeelight: yeelightBinding 17 | } 18 | 19 | /* Register models */ 20 | 21 | import './models/auth-token' 22 | import './models/device' 23 | import './models/floor' 24 | import './models/node' 25 | import './models/property' 26 | import './models/property-history' 27 | import './models/room' 28 | import './models/tag' 29 | 30 | export default async function start ($deps) { 31 | /* Populate the infrastructure from the DB */ 32 | 33 | await getInfrastructure(infrastructure) 34 | 35 | /* Initialize the MQTT client */ 36 | 37 | const mqttClient = createMqttClient(`mqtt://${$deps.settings.mqtt.host}:${$deps.settings.mqtt.port}`) 38 | 39 | /* Handle infrastructure updates */ 40 | 41 | bridgeInfrastructureToDatabase({ $deps, infrastructure }) 42 | bridgeInfrastructureToWebsocket({ $deps, infrastructure }) 43 | 44 | /* Bridge the MQTT to the infrastructure */ 45 | 46 | bridgeMqttToInfrastructure({ $deps, mqttClient, infrastructure }) 47 | 48 | /* Handle automation */ 49 | 50 | handleAutomation({ $deps, infrastructure, mqttClient }) 51 | 52 | /* start bindings */ 53 | 54 | for (const binding of Object.keys($deps.settings.bindings)) { 55 | const settings = $deps.settings.bindings[binding] 56 | if (!settings.enabled) continue 57 | 58 | BINDINGS[binding]({ settings, log: $deps.log, mqttClient }) 59 | $deps.log.info(`${binding} binding started`) 60 | } 61 | 62 | /* Handle WS */ 63 | 64 | const clients = new Set() 65 | $deps.wss.on('connection', function onConnection (ws) { 66 | $deps.log.debug('connection on websocket') 67 | const client = new Client({ $deps, ws, mqttClient, infrastructure }) 68 | client.on('close', function onClientClose () { 69 | clients.delete(client) 70 | }) 71 | clients.add(client) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /settings.toml: -------------------------------------------------------------------------------- 1 | # Bindings configuration 2 | [bindings] 3 | 4 | [bindings.aqara] 5 | enabled = true 6 | 7 | [bindings.aqara.gateway] 8 | ip = "192.168.1.11" 9 | password = "0000000000000000" 10 | 11 | [bindings.yeelight] 12 | enabled = true 13 | 14 | # Credentials to your MQTT broker 15 | [mqtt] 16 | host = "127.0.0.1" 17 | port = 1883 18 | 19 | # Data needed for the configuration of homie-esp8266 devices 20 | [homie-esp8266] 21 | 22 | [homie-esp8266.wifi] 23 | ssid = "SSID" 24 | password = "password" 25 | 26 | [homie-esp8266.mqtt] 27 | host = "192.168.1.10" 28 | port = 1883 29 | -------------------------------------------------------------------------------- /test/homie-topic-parser.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import homieTopicParser, {TOPIC_TYPES} from '../server/lib/homie-topic-parser' 4 | 5 | test('parse a broadcast', t => { 6 | const result = homieTopicParser.parse('homie/$broadcast/level', 'value') 7 | t.deepEqual(result, { 8 | type: TOPIC_TYPES.BROADCAST, 9 | level: 'level', 10 | value: 'value' 11 | }) 12 | }) 13 | 14 | test('report invalid broadcast', t => { 15 | const result = homieTopicParser.parse('homie/$broadcast/leve|', 'value') 16 | t.deepEqual(result, { 17 | type: TOPIC_TYPES.INVALID 18 | }) 19 | }) 20 | 21 | test('report invalid first level topic', t => { 22 | const result = homieTopicParser.parse('homie/$lolipop', 'value') 23 | t.deepEqual(result, { 24 | type: TOPIC_TYPES.INVALID 25 | }) 26 | }) 27 | 28 | test('parse a device property', t => { 29 | const result = homieTopicParser.parse('homie/device/$property', 'value') 30 | t.deepEqual(result, { 31 | type: TOPIC_TYPES.DEVICE_PROPERTY, 32 | deviceId: 'device', 33 | property: 'property', 34 | value: 'value' 35 | }) 36 | }) 37 | 38 | test('parse a device property with subtopics', t => { 39 | const result = homieTopicParser.parse('homie/device/$fw/name', 'value') 40 | t.deepEqual(result, { 41 | type: TOPIC_TYPES.DEVICE_PROPERTY, 42 | deviceId: 'device', 43 | property: 'fw/name', 44 | value: 'value' 45 | }) 46 | }) 47 | 48 | test('report invalid device property if device is not a valid ID', t => { 49 | const result = homieTopicParser.parse('homie/devicé/$property', 'value') 50 | t.deepEqual(result, { 51 | type: TOPIC_TYPES.INVALID 52 | }) 53 | }) 54 | 55 | test('parse a node special property', t => { 56 | const result = homieTopicParser.parse('homie/device/node/$special', 'value') 57 | t.deepEqual(result, { 58 | type: TOPIC_TYPES.NODE_SPECIAL_PROPERTY, 59 | deviceId: 'device', 60 | nodeId: 'node', 61 | property: 'special', 62 | value: 'value' 63 | }) 64 | }) 65 | 66 | test('report invalid node special property if device is not a valid ID', t => { 67 | const result = homieTopicParser.parse('homie/devicé/node/$special', 'value') 68 | t.deepEqual(result, { 69 | type: TOPIC_TYPES.INVALID 70 | }) 71 | }) 72 | 73 | test('report invalid node special property if node is not a valid ID', t => { 74 | const result = homieTopicParser.parse('homie/device/nodé/$special', 'value') 75 | t.deepEqual(result, { 76 | type: TOPIC_TYPES.INVALID 77 | }) 78 | }) 79 | 80 | test('parse a node property', t => { 81 | const result = homieTopicParser.parse('homie/device/node/property', 'value') 82 | t.deepEqual(result, { 83 | type: TOPIC_TYPES.NODE_PROPERTY, 84 | deviceId: 'device', 85 | nodeId: 'node', 86 | property: 'property', 87 | value: 'value' 88 | }) 89 | }) 90 | 91 | test('report invalid node property if device is not a valid ID', t => { 92 | const result = homieTopicParser.parse('homie/devicé/node/property', 'value') 93 | t.deepEqual(result, { 94 | type: TOPIC_TYPES.INVALID 95 | }) 96 | }) 97 | 98 | test('report invalid node property if node is not a valid ID', t => { 99 | const result = homieTopicParser.parse('homie/device/nodé/property', 'value') 100 | t.deepEqual(result, { 101 | type: TOPIC_TYPES.INVALID 102 | }) 103 | }) 104 | 105 | test('report invalid node property if property is not a valid ID', t => { 106 | const result = homieTopicParser.parse('homie/device/node/proper|y', 'value') 107 | t.deepEqual(result, { 108 | type: TOPIC_TYPES.INVALID 109 | }) 110 | }) 111 | 112 | test('parse a node property set topic', t => { 113 | const result = homieTopicParser.parse('homie/device/node/property/set', 'value') 114 | t.deepEqual(result, { 115 | type: TOPIC_TYPES.NODE_PROPERTY_SET, 116 | deviceId: 'device', 117 | nodeId: 'node', 118 | property: 'property', 119 | value: 'value' 120 | }) 121 | }) 122 | 123 | test('report invalid node property set if device is not a valid ID', t => { 124 | const result = homieTopicParser.parse('homie/devicé/node/property/set', 'value') 125 | t.deepEqual(result, { 126 | type: TOPIC_TYPES.INVALID 127 | }) 128 | }) 129 | 130 | test('report invalid node property set if node is not a valid ID', t => { 131 | const result = homieTopicParser.parse('homie/device/nodé/property/set', 'value') 132 | t.deepEqual(result, { 133 | type: TOPIC_TYPES.INVALID 134 | }) 135 | }) 136 | 137 | test('report invalid node property set if property is not a valid ID', t => { 138 | const result = homieTopicParser.parse('homie/device/node/proper|y/set', 'value') 139 | t.deepEqual(result, { 140 | type: TOPIC_TYPES.INVALID 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /test/ws-messages.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {parseMessage, generateMessage, MESSAGE_TYPES} from '../common/ws-messages' 4 | 5 | test('generate and parse an event', t => { 6 | const options = { type: MESSAGE_TYPES.EVENT, event: 'super_event', value: 'Wooh' } 7 | const message = generateMessage(options) 8 | const parsed = parseMessage(message) 9 | t.deepEqual(parsed, options) 10 | }) 11 | 12 | test('generate and parse a request', t => { 13 | const options = { type: MESSAGE_TYPES.REQUEST, method: 'getStats', parameters: { timespan: 'all' } } 14 | const message = generateMessage(options) 15 | const parsed = parseMessage(message.text) 16 | t.is(parsed.type, options.type) 17 | t.is(parsed.method, options.method) 18 | t.deepEqual(parsed.parameters, options.parameters) 19 | t.regex(parsed.id, /^[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}$/) 20 | }) 21 | 22 | test('generate and parse a response', t => { 23 | const options = { type: MESSAGE_TYPES.RESPONSE, id: '110ec58a-a0f2-4ac4-8393-c866d813b8d1', value: 'Wooh' } 24 | const message = generateMessage(options) 25 | const parsed = parseMessage(message) 26 | t.deepEqual(parsed, options) 27 | }) 28 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Homie Dashboard', 3 | entry: './app/index.js', 4 | static: { 5 | from: './app/static' 6 | }, 7 | dist: './dist-app', 8 | template: './app/index.html', 9 | notify: true, 10 | resolve: true, 11 | vendor: ['axios', 'chart.js', 'eva.js', 'eventemitter3', 'fast-json-patch', 'uuid', 'vue', 'vue-color', 'vue-datepicker', 'moment', 'vue-grid-layout/dist/vue-grid-layout.min.js'], 12 | mergeConfig: { 13 | performance: { 14 | hints: false, 15 | assetFilter: function (assetFilename) { 16 | return assetFilename.endsWith('.js') 17 | } 18 | } 19 | } 20 | } 21 | --------------------------------------------------------------------------------