Initial commit to GitHub

This commit is contained in:
2018-03-26 15:54:25 -04:00
commit 2124b2e976
58 changed files with 7999 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
.DS_Store
node_modules/
/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln

12
.jsbeautifyrc Normal file
View File

@@ -0,0 +1,12 @@
{
"css": {
"newline_between_rules": true,
"space_around_combinator": true,
"preserve_newlines": true,
"selector_separator_newline": true,
"end_with_newline": true
},
"html": {
}
}

9
.postcssrc.js Normal file
View File

@@ -0,0 +1,9 @@
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
"plugins": {
// to edit target browsers: use "browserslist" field in package.json
"postcss-import": {},
"autoprefixer": {}
}
}

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Chris Kaczor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# home-status
> Displays the status of various household devices
## Build Setup
``` bash
# install dependencies
npm install
# serve with hot reload at localhost:8080
npm run dev
# build for production with minification
npm run build
# build for production and view the bundle analyzer report
npm run build --report
```
For a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader).

41
build/build.js Normal file
View File

@@ -0,0 +1,41 @@
'use strict'
require('./check-versions')()
process.env.NODE_ENV = 'production'
const ora = require('ora')
const rm = require('rimraf')
const path = require('path')
const chalk = require('chalk')
const webpack = require('webpack')
const config = require('../config')
const webpackConfig = require('./webpack.prod.conf')
const spinner = ora('building for production...')
spinner.start()
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
if (err) throw err
webpack(webpackConfig, function(err, stats) {
spinner.stop()
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n\n')
if (stats.hasErrors()) {
console.log(chalk.red(' Build failed with errors.\n'))
process.exit(1)
}
console.log(chalk.cyan(' Build complete.\n'))
console.log(chalk.yellow(
' Tip: built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
))
})
})

48
build/check-versions.js Normal file
View File

@@ -0,0 +1,48 @@
'use strict'
const chalk = require('chalk')
const semver = require('semver')
const packageConfig = require('../package.json')
const shell = require('shelljs')
function exec(cmd) {
return require('child_process').execSync(cmd).toString().trim()
}
const versionRequirements = [{
name: 'node',
currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node
}]
if (shell.which('npm')) {
versionRequirements.push({
name: 'npm',
currentVersion: exec('npm --version'),
versionRequirement: packageConfig.engines.npm
})
}
module.exports = function() {
const warnings = []
for (let i = 0; i < versionRequirements.length; i++) {
const mod = versionRequirements[i]
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
warnings.push(mod.name + ': ' +
chalk.red(mod.currentVersion) + ' should be ' +
chalk.green(mod.versionRequirement)
)
}
}
if (warnings.length) {
console.log('')
console.log(chalk.yellow('To use this template, you must update following to modules:'))
console.log()
for (let i = 0; i < warnings.length; i++) {
const warning = warnings[i]
console.log(' ' + warning)
}
console.log()
process.exit(1)
}
}

100
build/utils.js Normal file
View File

@@ -0,0 +1,100 @@
'use strict'
const path = require('path')
const config = require('../config')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const pkg = require('../package.json')
exports.assetsPath = function(_path) {
const assetsSubDirectory = process.env.NODE_ENV === 'production' ?
config.build.assetsSubDirectory :
config.dev.assetsSubDirectory
return path.posix.join(assetsSubDirectory, _path)
}
exports.cssLoaders = function(options) {
options = options || {}
const cssLoader = {
loader: 'css-loader',
options: {
sourceMap: options.sourceMap
}
}
var postcssLoader = {
loader: 'postcss-loader',
options: {
sourceMap: options.sourceMap
}
}
// generate loader string to be used with extract text plugin
function generateLoaders(loader, loaderOptions) {
const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader]
if (loader) {
loaders.push({
loader: loader + '-loader',
options: Object.assign({}, loaderOptions, {
sourceMap: options.sourceMap
})
})
}
// Extract CSS when that option is specified
// (which is the case during production build)
if (options.extract) {
return ExtractTextPlugin.extract({
use: loaders,
fallback: 'vue-style-loader'
})
} else {
return ['vue-style-loader'].concat(loaders)
}
}
// https://vue-loader.vuejs.org/en/configurations/extract-css.html
return {
css: generateLoaders(),
postcss: generateLoaders(),
less: generateLoaders('less'),
sass: generateLoaders('sass', {
indentedSyntax: true
}),
scss: generateLoaders('sass'),
stylus: generateLoaders('stylus'),
styl: generateLoaders('stylus')
}
}
// Generate loaders for standalone style files (outside of .vue)
exports.styleLoaders = function(options) {
const output = []
const loaders = exports.cssLoaders(options)
for (const extension in loaders) {
const loader = loaders[extension]
output.push({
test: new RegExp('\\.' + extension + '$'),
use: loader
})
}
return output
}
exports.createNotifierCallback = function() {
const notifier = require('node-notifier')
return (severity, errors) => {
if (severity !== 'error') {
return
}
const error = errors[0]
const filename = error.file && error.file.split('!').pop()
notifier.notify({
title: pkg.name,
message: severity + ': ' + error.name,
subtitle: filename || '',
icon: path.join(__dirname, 'logo.png')
})
}
}

23
build/vue-loader.conf.js Normal file
View File

@@ -0,0 +1,23 @@
'use strict'
const utils = require('./utils')
const config = require('../config')
const isProduction = process.env.NODE_ENV === 'production'
const sourceMapEnabled = isProduction ?
config.build.productionSourceMap :
config.dev.cssSourceMap
module.exports = {
loaders: utils.cssLoaders({
sourceMap: sourceMapEnabled,
extract: isProduction
}),
cssSourceMap: sourceMapEnabled,
cacheBusting: config.dev.cacheBusting,
transformToRequire: {
video: 'src',
source: 'src',
img: 'src',
image: 'xlink:href'
}
}

View File

@@ -0,0 +1,69 @@
'use strict'
const path = require('path')
const utils = require('./utils')
const config = require('../config')
const vueLoaderConfig = require('./vue-loader.conf')
function resolve(dir) {
return path.join(__dirname, '..', dir)
}
module.exports = {
context: path.resolve(__dirname, '../'),
entry: {
app: './src/main.ts'
},
output: {
path: config.build.assetsRoot,
filename: '[name].js',
publicPath: process.env.NODE_ENV === 'production' ?
config.build.assetsPublicPath :
config.dev.assetsPublicPath
},
resolve: {
extensions: ['.js', '.ts', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src'),
}
},
module: {
rules: [{
test: /\.vue$/,
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/]
}
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('media/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
}
]
}
}

81
build/webpack.dev.conf.js Normal file
View File

@@ -0,0 +1,81 @@
'use strict'
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const portfinder = require('portfinder')
const devWebpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({
sourceMap: config.dev.cssSourceMap,
usePostCSS: true
})
},
// cheap-module-eval-source-map is faster for development
devtool: config.dev.devtool,
// these devServer options should be customized in /config/index.js
devServer: {
clientLogLevel: 'warning',
historyApiFallback: true,
hot: true,
compress: true,
host: process.env.HOST ||  config.dev.host,
port: process.env.PORT ||  config.dev.port,
open: config.dev.autoOpenBrowser,
overlay: config.dev.errorOverlay ? {
warnings: false,
errors: true,
} : false,
publicPath: config.dev.assetsPublicPath,
proxy: config.dev.proxyTable,
quiet: true, // necessary for FriendlyErrorsPlugin
watchOptions: {
poll: config.dev.poll,
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/dev.env')
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update.
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'src/index.html',
inject: true
}),
]
})
module.exports = new Promise((resolve, reject) => {
portfinder.basePort = process.env.PORT || config.dev.port
portfinder.getPort((err, port) => {
if (err) {
reject(err)
} else {
// publish the new Port, necessary for e2e tests
process.env.PORT = port
// add port to devServer config
devWebpackConfig.devServer.port = port
// Add FriendlyErrorsPlugin
devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
compilationSuccessInfo: {
messages: [`Your application is running here: http://${config.dev.host}:${port}`],
},
onErrors: config.dev.notifyOnErrors ?
utils.createNotifierCallback() :
undefined
}))
resolve(devWebpackConfig)
}
})
})

148
build/webpack.prod.conf.js Normal file
View File

@@ -0,0 +1,148 @@
'use strict'
const path = require('path')
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
const env = require('../config/prod.env')
const webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({
sourceMap: config.build.productionSourceMap,
extract: true,
usePostCSS: true
})
},
devtool: config.build.productionSourceMap ? config.build.devtool : false,
output: {
path: config.build.assetsRoot,
filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
}),
// FIXME: Dont use this temporary
// UglifyJs do not support ES6+, you can also use babel-minify for better treeshaking: https://github.com/babel/minify
// new webpack.optimize.UglifyJsPlugin({
// compress: {
// warnings: false
// },
// sourceMap: config.build.productionSourceMap,
// parallel: true
// }),
// extract css into its own file
new ExtractTextPlugin({
filename: utils.assetsPath('css/[name].[contenthash].css'),
// set the following option to `true` if you want to extract CSS from
// codesplit chunks into this main css file as well.
// This will result in *all* of your app's CSS being loaded upfront.
allChunks: false,
}),
// Compress extracted CSS. We are using this plugin so that possible
// duplicated CSS from different components can be deduped.
new OptimizeCSSPlugin({
cssProcessorOptions: config.build.productionSourceMap ?
{
safe: true,
map: {
inline: false
}
} :
{
safe: true
}
}),
// generate dist index.html with correct asset hash for caching.
// you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: config.build.index,
template: 'src/index.html',
inject: true,
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
chunksSortMode: 'dependency'
}),
// keep module.id stable when vender modules does not change
new webpack.HashedModuleIdsPlugin(),
// enable scope hoisting
new webpack.optimize.ModuleConcatenationPlugin(),
// split vendor js into its own file
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function(module) {
// any required modules inside node_modules are extracted to vendor
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(
path.join(__dirname, '../node_modules')
) === 0
)
}
}),
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity
}),
// This instance extracts shared chunks from code splitted chunks and bundles them
// in a separate chunk, similar to the vendor chunk
// see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk
new webpack.optimize.CommonsChunkPlugin({
name: 'app',
async: 'vendor-async',
children: true,
minChunks: 3
}),
// copy custom static assets
new CopyWebpackPlugin([{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}])
]
})
if (config.build.productionGzip) {
const CompressionWebpackPlugin = require('compression-webpack-plugin')
webpackConfig.plugins.push(
new CompressionWebpackPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: new RegExp(
'\\.(' +
config.build.productionGzipExtensions.join('|') +
')$'
),
threshold: 10240,
minRatio: 0.8
})
)
}
if (config.build.bundleAnalyzerReport) {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = webpackConfig

7
config/dev.env.js Normal file
View File

@@ -0,0 +1,7 @@
'use strict'
const merge = require('webpack-merge')
const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"'
})

81
config/index.js Normal file
View File

@@ -0,0 +1,81 @@
'use strict'
// Template version: 1.2.4
// see http://vuejs-templates.github.io/webpack for documentation.
const path = require('path')
module.exports = {
dev: {
// Paths
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {},
// Various Dev Server settings
host: 'localhost', // can be overwritten by process.env.HOST
port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
autoOpenBrowser: false,
errorOverlay: true,
notifyOnErrors: true,
poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
// Use Eslint Loader?
// If true, your code will be linted during bundling and
// linting errors and warnings will be shown in the console.
useEslint: true,
// If true, eslint errors and warnings will also be shown in the error overlay
// in the browser.
showEslintErrorsInOverlay: false,
/**
* Source Maps
*/
// https://webpack.js.org/configuration/devtool/#development
devtool: 'eval-source-map',
// If you have problems debugging vue-files in devtools,
// set this to false - it *may* help
// https://vue-loader.vuejs.org/en/options.html#cachebusting
cacheBusting: true,
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false,
},
build: {
// Template for index.html
index: path.resolve(__dirname, '../dist/index.html'),
// Paths
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/status/',
/**
* Source Maps
*/
productionSourceMap: true,
// https://webpack.js.org/configuration/devtool/#production
devtool: '#source-map',
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to
// View the bundle analyzer report after build finishes:
// `npm run build --report`
// Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report
}
}

4
config/prod.env.js Normal file
View File

@@ -0,0 +1,4 @@
'use strict'
module.exports = {
NODE_ENV: '"production"'
}

71
package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "home-status",
"version": "1.0.0",
"description": "Displays the status of various household devices",
"author": "Chris Kaczor",
"private": true,
"scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
"start": "npm run dev",
"build": "node build/build.js"
},
"dependencies": {
"@types/highcharts": "^5.0.15",
"@types/signalr-no-jquery": "^0.1.0",
"font-awesome": "^4.7.0",
"highcharts": "^6.0.4",
"moment": "^2.20.1",
"regression": "^2.0.1",
"signalr-no-jquery": "^0.1.8",
"socket.io-client": "^2.0.4",
"vue": "^2.5.13",
"vue-grid-layout": "^2.1.11",
"vue-highcharts": "^0.0.10",
"vue-router": "^3.0.1",
"vuetify": "^0.17.6"
},
"devDependencies": {
"@types/socket.io-client": "^1.4.32",
"autoprefixer": "^7.2.4",
"chalk": "^2.3.0",
"copy-webpack-plugin": "^4.3.1",
"css-loader": "^0.28.8",
"eventsource-polyfill": "^0.9.6",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.6",
"friendly-errors-webpack-plugin": "^1.6.1",
"html-webpack-plugin": "^2.30.1",
"node-notifier": "^5.1.2",
"node-sass": "^4.7.2",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"ora": "^1.3.0",
"portfinder": "^1.0.13",
"postcss-import": "^11.0.0",
"postcss-loader": "^2.0.10",
"rimraf": "^2.6.2",
"sass-loader": "^6.0.6",
"semver": "^5.4.1",
"shelljs": "^0.8.1",
"ts-loader": "^3.2.0",
"tslint": "^5.9.1",
"typescript": "^2.6.2",
"url-loader": "^0.6.2",
"vue-loader": "^13.7.0",
"vue-property-decorator": "^6.0.0",
"vue-style-loader": "^3.0.3",
"vue-template-compiler": "^2.5.13",
"webpack": "^3.10.0",
"webpack-bundle-analyzer": "^2.9.2",
"webpack-dev-server": "^2.10.1",
"webpack-merge": "^4.1.1"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,11 @@
<style lang="scss" src="./PressureTrend.vue.scss" scoped></style>
<script lang="ts" src="./PressureTrend.vue.ts"></script>
<template>
<div class="pressure-arrow-container">
<div class="pressure-arrow">
<v-icon :style="style()">fa-long-arrow-up fa-5x</v-icon>
</div>
</div>
</template>

View File

@@ -0,0 +1,11 @@
.pressure-arrow-container {
height: 100%;
}
.pressure-arrow {
text-align: center;
position: relative;
top: 50%;
transform: translateY(-50%);
}

View File

@@ -0,0 +1,64 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import moment from 'moment';
import regression from 'regression';
import { WeatherService, ValueType, HistoryEntry } from '@/services/WeatherService.ts';
@Component
export default class PressureTrend extends Vue {
pressureDifference: number | null = null;
async mounted() {
this.update();
setInterval(this.update, 60000);
}
async update() {
const end: moment.Moment = moment();
const start: moment.Moment = moment(end).subtract(3, 'hours');
const weatherData = await WeatherService.getDeviceHistory(ValueType.Pressure, start.toDate(), end.toDate());
if (!weatherData) {
return;
}
const points: Array<Array<number>> = [];
weatherData[0].Value.forEach((historyEntry: HistoryEntry) => {
if (historyEntry.Value >= 900 && historyEntry.Value <= 1050) {
const point = [moment(historyEntry.ReadTime).unix(), historyEntry.Value];
points.push(point);
}
});
const result = regression.linear(points, { precision: 10 });
const regressionPoints = result.points;
this.pressureDifference = regressionPoints[regressionPoints.length - 1][1] - regressionPoints[0][1];
}
style(): string {
let degrees: number = 0;
if (!this.pressureDifference) {
degrees = 90;
} else if (Math.abs(this.pressureDifference) <= 1.0) {
degrees = 90;
} else if (this.pressureDifference > 1.0 && this.pressureDifference <= 2.0) {
degrees = 60;
} else if (this.pressureDifference > 2.0) {
degrees = 45;
} else if (this.pressureDifference < -1.0 && this.pressureDifference >= -2.0) {
degrees = 115;
} else if (this.pressureDifference < -2.0) {
degrees = 150;
}
return `transform: rotate(${degrees}deg)`;
}
}

15
src/config/Config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { IConfig } from './IConfig';
class Config implements IConfig {
weather = {
host: null,
port: 9090
};
laundry = {
host: null,
port: 9091
};
}
export const config = new Config();

9
src/config/IConfig.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface IServerConfig {
host: string | null;
port: number;
}
export interface IConfig {
weather: IServerConfig;
laundry: IServerConfig;
}

21
src/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicons/favicon-32x32.png?v=oLdWoAx4Jd">
<link rel="icon" type="image/png" sizes="16x16" href="static/favicons/favicon-16x16.png?v=oLdWoAx4Jd">
<link rel="shortcut icon" href="static/favicons/favicon.ico?v=oLdWoAx4Jd">
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
<title>Home Status</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

35
src/main.ts Normal file
View File

@@ -0,0 +1,35 @@
import Vue from 'vue';
import Vuetify from 'vuetify';
Vue.use(Vuetify);
import Highcharts from 'highcharts';
import VueHighcharts from 'vue-highcharts';
import highchartsMore from 'highcharts/highcharts-more';
import highchartsExport from 'highcharts/modules/exporting';
highchartsMore(Highcharts);
highchartsExport(Highcharts);
Vue.use(VueHighcharts, { Highcharts });
import router from './router';
import App from './views/App/App.vue';
import { WeatherService } from '@/services/WeatherService';
import { LaundryService } from '@/services/LaundryService';
import { config } from '@/config/Config';
Promise.all([
WeatherService.start(config.weather.host || localStorage['host'] || window.location.host, config.weather.port),
LaundryService.start(config.laundry.host || localStorage['host'] || window.location.host, config.laundry.port)
]).then(() => {
new Vue({
el: '#app',
router,
template: '<App/>',
components: { App }
});
});

35
src/router/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);
import Dashboard from '@/views/Dashboard/Dashboard.vue';
import Laundry from '@/views/Laundry/Laundry.vue';
import Weather from '@/views/Weather/Weather.vue';
import WeatherHistory from '@/views/WeatherHistory/WeatherHistory.vue';
export default new Router({
routes: [
{
path: '/',
name: 'Dashboard',
component: Dashboard
},
{
path: '/laundry',
name: 'Laundry',
component: Laundry
},
{
path: '/weather',
name: 'Weather',
component: Weather
},
{
path: '/weather-history/:type',
name: 'WeatherHistory',
component: WeatherHistory
}
]
});

View File

@@ -0,0 +1,33 @@
import io from 'socket.io-client';
export class LaundryStatus {
washer: boolean = false;
dryer: boolean = false;
}
export class LaundryService {
static socket: SocketIOClient.Socket | null;
static status: LaundryStatus = new LaundryStatus();
static start(server: string, port: number) {
if (this.socket) {
return;
}
this.socket = io(`http://${server}:${port}`);
this.socket.on('status', (statusString: string) => {
const newStatus = JSON.parse(statusString);
if (newStatus.washer !== undefined) {
this.status.washer = newStatus.washer;
}
if (newStatus.dryer !== undefined) {
this.status.dryer = newStatus.dryer;
}
});
this.socket.emit('getStatus');
}
}

View File

@@ -0,0 +1,100 @@
import Vue from 'vue';
import { hubConnection, Connection, Proxy } from 'signalr-no-jquery';
import moment from 'moment';
export class WeatherDeviceReading {
Value: number;
ReadTime: string;
}
export class WindDirectionReading extends WeatherDeviceReading {
WindDirectionString: string;
}
export class RainReading extends WeatherDeviceReading {
Inches: number;
}
export class TemperatureReading extends WeatherDeviceReading {
DegreesF: number;
}
export class WeatherDeviceValue {
ValueType: ValueType;
Current: WeatherDeviceReading;
}
export class WeatherDevice {
Address: string;
DisplayName: string;
Errors: number;
Id: number;
Indoor: boolean;
LastRead: string;
Operations: number;
RefreshFrequency: number;
SupportedValues: Array<ValueType>;
Type: number;
Values: { [valueName: string]: WeatherDeviceValue };
}
export enum ValueType {
Temperature,
Pressure,
Humidity,
WindSpeed,
WindDirection,
Rain
}
export type HistoryEntry = {
ValueType: ValueType;
Value: number;
ReadTime: string;
};
export type HistoryResult = { Key: WeatherDevice, Value: Array<HistoryEntry> };
export class WeatherService {
static deviceMap: { [deviceId: number]: WeatherDevice } = {};
private static connection: Connection;
private static proxy: Proxy;
static async start(server: string, port: number) {
this.connection = hubConnection(`http://${server}:${port}/signalr/`);
this.proxy = this.connection.createHubProxy('weatherHub');
await this.connection.start();
this.proxy.on('deviceRefreshed', (updatedDevice: WeatherDevice) => {
Vue.set(this.deviceMap, updatedDevice.Id.toString(), updatedDevice);
});
const devices = await this.proxy.invoke('getDevices');
devices.forEach((device: WeatherDevice) => {
Vue.set(this.deviceMap, device.Id.toString(), device);
});
}
static async getDeviceHistory(valueType: ValueType, start: Date, end: Date): Promise<any[] | null> {
const startString = moment(start).toISOString();
const endString = moment(end).toISOString();
if (valueType === ValueType.WindDirection) {
const data = await this.proxy.invoke('getWindDirectionHistory', startString, endString);
return data;
} else if (valueType === ValueType.WindSpeed) {
const data = await this.proxy.invoke('getWindSpeedHistory', 5, startString, endString);
return data;
} else {
const data = await this.proxy.invoke('getGenericHistory', valueType, startString, endString);
return data;
}
}
}

51
src/views/App/App.vue Normal file
View File

@@ -0,0 +1,51 @@
<style lang="scss" src="./App.vue.scss"></style>
<script lang="ts" src="./App.vue.ts"></script>
<template>
<v-app id="inspire" light>
<v-navigation-drawer clipped fixed v-model="drawer" app width="250">
<v-list dense>
<span v-for="item in items" v-bind:key="item.title">
<v-list-group v-if="item.items" :value="item.active" v-model="item.expanded">
<v-list-tile slot="item" :to="item.to">
<v-list-tile-action>
<v-icon>{{ item.action }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ item.title }}</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-icon>keyboard_arrow_down</v-icon>
</v-list-tile-action>
</v-list-tile>
<v-list-tile v-for="subItem in item.items" v-bind:key="subItem.title" :to="subItem.to">
<v-list-tile-content>
<v-list-tile-title>{{ subItem.title }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-group>
<v-list-tile v-else slot="item" :to="item.to" exact>
<v-list-tile-action>
<v-icon>{{ item.action }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ item.title }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</span>
</v-list>
</v-navigation-drawer>
<v-toolbar app fixed clipped-left color="indigo" dark dense flat>
<v-toolbar-side-icon @click.stop="drawer = !drawer"></v-toolbar-side-icon>
<v-toolbar-title>Home Status</v-toolbar-title>
</v-toolbar>
<v-content>
<v-container fluid fill-height>
<v-layout>
<router-view></router-view>
</v-layout>
</v-container>
</v-content>
</v-app>
</template>

View File

@@ -0,0 +1,43 @@
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@import '@/../vuetify/dist/vuetify.min.css';
html {
overflow: auto;
}
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.view-container {
overflow-x: hidden;
overflow-y: auto;
width: 100%;
height: 100%;
}
.view-loading-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 998;
background-color: rgba(0, 0, 0, 0.25);
}
.view-loading-progress {
left: 50%;
transform: translate(-50%, -50%);
top: 35%;
z-index: 999;
position: absolute;
}
.container {
position: relative;
}

67
src/views/App/App.vue.ts Normal file
View File

@@ -0,0 +1,67 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { ValueType, WeatherService } from '@/services/WeatherService';
import { LaundryService } from '@/services/LaundryService';
@Component
export default class App extends Vue {
drawer: boolean | null = null;
items: any = [
{
action: 'home',
title: 'Dashboard',
to: '/'
},
{
action: 'local_laundry_service',
title: 'Laundry',
to: '/laundry'
},
{
action: 'cloud',
title: 'Weather',
to: '/weather'
},
{
action: 'multiline_chart',
title: 'Weather Charts',
expanded: false,
route: 'WeatherHistory',
items: [
{
title: 'Temperature',
to: '/weather-history/' + ValueType.Temperature
},
{
title: 'Pressure',
to: '/weather-history/' + ValueType.Pressure
},
{
title: 'Humidity',
to: '/weather-history/' + ValueType.Humidity
},
{
title: 'Wind direction',
to: '/weather-history/' + ValueType.WindDirection
},
{
title: 'Wind speed',
to: '/weather-history/' + ValueType.WindSpeed
},
{
title: 'Rain',
to: '/weather-history/' + ValueType.Rain
}
]
}
];
async mounted() {
this.items.forEach((item: any) => {
if (item.route) {
item.expanded = this.$route.name === item.route;
}
});
}
}

View File

@@ -0,0 +1,69 @@
<style lang="scss" src="./Dashboard.vue.scss" scoped></style>
<script lang="ts" src="./Dashboard.vue.ts"></script>
<style lang="scss" src="./Grid.scss"></style>
<template>
<div id="dashboard-container" v-if="ready">
<v-toolbar height="42" flat>
<v-spacer></v-spacer>
<v-btn small outline color="grey darken-1" @click="startEdit()">
<v-icon left class="button-icon">edit</v-icon>
Edit
</v-btn>
<v-btn small outline color="grey darken-1" @click="toggleLocked()">
<v-icon left class="button-icon">lock</v-icon>
{{ locked ? 'Unlock' : 'Lock' }}
</v-btn>
</v-toolbar>
<v-dialog class="dashboard-panels-dialog" v-model="editing" persistent width="50%" scrollable>
<v-card>
<v-card-title class="indigo dashboard-panels-header">
<span class="title white--text">
Dashboard Panels
</span>
<v-spacer></v-spacer>
<v-btn icon="icon" v-on:click="editing = false" class="white--text">
<v-icon>close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="dashboard-panels-text">
<div class="dashboard-panels-item" v-for="panel in allPanels" :key="panel.componentName">
<div class="dashboard-panels-item-name">
{{ panel.name }}
</div>
<div>
{{ panel.description }}
</div>
<v-btn class="dashboard-panels-item-button" color="primary" small white--text @click="addPanel(panel)" v-if="!isPanelAdded(panel)">
Add
</v-btn>
<v-btn class="dashboard-panels-item-button" color="primary" small white--text @click="removePanel(panel)" v-if="isPanelAdded(panel)">
Remove
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<grid-layout :layout="panels" :col-num="100" :row-height="10" :is-draggable="!locked" :is-resizable="!locked" :vertical-compact="false" :margin="[10, 10]" :use-css-transforms="true" @layout-updated="savePanels">
<grid-item v-for="panel in panels" :key="panel.i" :x="panel.x" :y="panel.y" :w="panel.w" :h="panel.h" :i="panel.i">
<v-card flat class="dashboard-panel">
<v-card-title class="indigo dashboard-panel-header">
<span class="title white--text">
{{ panel.name }}
</span>
<v-spacer></v-spacer>
<v-btn v-show="!locked" small icon title="Remove" @click="removePanel(panel)" dark>
<v-icon>close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="dashboard-panel-text">
<component :is="panel.componentName"></component>
</v-card-text>
</v-card>
</grid-item>
</grid-layout>
</div>
</template>

View File

@@ -0,0 +1,79 @@
.vue-grid-item {
border-radius: 6px;
}
.vue-grid-item:not(.vue-grid-placeholder) {
border: 1px solid #dddddd;
}
#dashboard-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.button-icon {
font-size: 20px;
margin-right: 8px;
}
.dashboard-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
}
.dashboard-panel-header {
height: 26px;
min-height: 26px;
padding-right: 0;
& .title {
font-size: 10pt !important;
}
& button .icon {
font-size: 12pt;
}
}
.dashboard-panel-text {
padding: 6px 14px;
height: calc(100% - 34px);
}
.dashboard-panels-header {
height: 48px;
min-height: 48px;
padding-right: 0;
}
.dashboard-panels-text {
padding: 0;
height: 50vh;
}
.dashboard-panels-item {
padding: 10px 15px;
position: relative;
&:nth-child(2) {
background-color: #eee;
}
}
.dashboard-panels-item-name {
font-size: 13pt;
font-weight: bold;
}
.dashboard-panels-item-button {
position: absolute;
top: 12px;
right: 5px;
}

View File

@@ -0,0 +1,102 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import VueGridLayout from 'vue-grid-layout';
import Laundry from '@/views/Laundry/Laundry.vue';
import Weather from '@/views/Weather/Weather.vue';
import PressureTrend from '@/components/PressureTrend/PressureTrend.vue';
Vue.component('GridLayout', VueGridLayout.GridLayout);
Vue.component('GridItem', VueGridLayout.GridItem);
Vue.component('Laundry', Laundry);
Vue.component('Weather', Weather);
Vue.component('PressureTrend', PressureTrend);
class DashboardPanel {
name: string;
componentName: string;
description: string;
defaultSize: { height: number, width: number };
}
class DashboardPanelLayout {
x: number;
y: number;
w: number;
h: number;
i: string;
name: string;
componentName: string;
}
@Component
export default class Dashboard extends Vue {
ready: boolean = false;
locked: boolean = true;
editing: boolean = false;
panels: Array<DashboardPanelLayout> = [];
allPanels: Array<DashboardPanel> = [
{ name: 'Weather', componentName: 'Weather', description: 'Text summary of current weather conditions', defaultSize: { height: 10, width: 30 } },
{ name: 'Laundry', componentName: 'Laundry', description: 'Current washer and dryer status', defaultSize: { height: 6, width: 15 } },
{ name: 'Pressure Trend', componentName: 'PressureTrend', description: 'An arrow showing the barometric pressure trend for the last three hours.', defaultSize: { height: 8, width: 20 } }
];
mounted() {
const savedPanels = localStorage.getItem('panels');
if (savedPanels) {
this.panels = JSON.parse(savedPanels);
} else {
this.allPanels.forEach((panel) => this.addPanel(panel));
}
this.ready = true;
}
toggleLocked() {
this.locked = !this.locked;
}
startEdit() {
this.editing = true;
}
savePanels() {
const savedPanels = JSON.stringify(this.panels);
localStorage.setItem('panels', savedPanels);
}
isPanelAdded(panel: DashboardPanel) {
return this.panels.find((currentPanel) => currentPanel.componentName === panel.componentName);
}
addPanel(panel: DashboardPanel) {
this.panels.push({
x: 0,
y: 0,
h: panel.defaultSize.height,
w: panel.defaultSize.width,
i: this.panels.length.toString(),
name: panel.name,
componentName: panel.componentName,
});
this.savePanels();
}
removePanel(panel: DashboardPanel) {
const index = this.panels.findIndex((currentPanel) => currentPanel.componentName === panel.componentName);
this.panels.splice(index, 1);
this.savePanels();
}
}

View File

@@ -0,0 +1,3 @@
.vue-grid-item.vue-grid-placeholder {
background-color: #3f51b5;
}

View File

@@ -0,0 +1,26 @@
<style lang="scss" src="./Laundry.vue.scss" scoped></style>
<script lang="ts" src="./Laundry.vue.ts"></script>
<template>
<div>
<table>
<tr>
<td class="device-name">
Washer
</td>
<td :class="laundryStatus.washer.toString()">
{{ laundryStatus.washer ? 'On' : 'Off' }}
</td>
</tr>
<tr>
<td class="device-name">
Dryer
</td>
<td :class="laundryStatus.dryer.toString()">
{{ laundryStatus.dryer ? 'On' : 'Off' }}
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,13 @@
.device-name {
font-weight: bold;
padding-right: 10px;
text-align: right;
}
.true {
color: darkgoldenrod;
}
.false {
color: darkgreen;
}

View File

@@ -0,0 +1,9 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { LaundryService, LaundryStatus } from '@/services/LaundryService.ts';
@Component
export default class Laundry extends Vue {
laundryStatus: LaundryStatus = LaundryService.status;
}

View File

@@ -0,0 +1,18 @@
<style lang="scss" src="./Weather.vue.scss" scoped></style>
<script lang="ts" src="./Weather.vue.ts"></script>
<template>
<div>
<table>
<tr v-for="device in devices" v-bind:key="device.Id">
<td class="device-name">
{{ device.DisplayName }}
</td>
<td>
{{ formatDevice(device) }}
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,5 @@
.device-name {
font-weight: bold;
padding-right: 10px;
text-align: right;
}

View File

@@ -0,0 +1,60 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { WeatherService, WeatherDevice, ValueType, TemperatureReading, RainReading, WindDirectionReading, WeatherDeviceValue } from '@/services/WeatherService.ts';
@Component
export default class Weather extends Vue {
private deviceMap = WeatherService.deviceMap;
formatDevice(device: WeatherDevice): string {
let valueDisplay: string = '';
for (const value of Object.values(device.Values)) {
switch (value.ValueType) {
case ValueType.Temperature:
const tempReading = value.Current as TemperatureReading;
valueDisplay += ' ' + tempReading.DegreesF.toFixed(2) + '°F';
break;
case ValueType.Humidity:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + '%';
break;
case ValueType.Pressure:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + ' hPa';
break;
case ValueType.Rain:
const rainReading = value.Current as RainReading;
valueDisplay += ' ' + rainReading.Inches.toFixed(2) + '"';
break;
case ValueType.WindSpeed:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + ' MPH';
break;
case ValueType.WindDirection:
const windReading = value.Current as WindDirectionReading;
valueDisplay += ' ' + windReading.WindDirectionString;
break;
}
}
return valueDisplay;
}
get devices(): Array<WeatherDevice> {
return Object.values(this.deviceMap).sort((a, b) => a.Type - b.Type);
}
}

View File

@@ -0,0 +1,35 @@
#chart-subtitle {
cursor: pointer;
color: #1976d2;
&:hover {
text-decoration: underline;
}
}
.chart-day-arrow {
position: relative;
top: -1px;
cursor: not-allowed;
&:hover:not(.disabled) {
text-decoration: underline;
}
&:not(.disabled) {
cursor: pointer;
color: #1976d2;
}
}
#chart-day-previous {
padding-right: 10px;
}
#chart-day-next {
padding-left: 10px;
}
.chart-settings {
margin-left: 250px;
}

View File

@@ -0,0 +1,48 @@
<style lang="scss" src="./WeatherHistory.vue.scss" scoped></style>
<script lang="ts" src="./WeatherHistory.vue.ts"></script>
<style lang="scss" src="./Chart.scss"></style>
<template>
<div class="view-container">
<div class="view-loading-overlay" v-if="loading">
<v-progress-circular indeterminate class="view-loading-progress indigo--text" size="64" />
</div>
<div v-if="ready" id="chart-container">
<v-toolbar height="42" flat>
<v-menu offset-y>
<v-btn small outline slot="activator" color="grey darken-1">
{{ timeSpanItems[selectedTimeSpan] }}
</v-btn>
<v-list dense>
<v-list-tile v-for="(text, value) in timeSpanItems" :key="value" @click="selectedTimeSpan = Number(value)">
<v-list-tile-title>{{ text }}</v-list-tile-title>
</v-list-tile>
</v-list>
</v-menu>
<v-btn v-show="selectedTimeSpan === timeSpans.Day" small outline color="grey darken-1" @click="handleDateArrowClick(-1)">
<v-icon>skip_previous</v-icon>
</v-btn>
<v-menu v-show="selectedTimeSpan === timeSpans.Day" lazy :close-on-content-click="false" v-model="showDateMenu" offset-y full-width>
<v-btn id="date-button" small outline slot="activator" color="grey darken-1">
{{ getSelectedDateDisplayString() }}
</v-btn>
<v-date-picker v-model="selectedDateIsoString" no-title autosave></v-date-picker>
</v-menu>
<v-btn v-show="selectedTimeSpan === timeSpans.Day && !isSelectedDateToday()" small outline color="grey darken-1" @click="handleDateArrowClick(1)">
<v-icon>skip_next</v-icon>
</v-btn>
<v-btn v-show="selectedTimeSpan === timeSpans.Day && !isSelectedDateToday()" small outline color="grey darken-1" @click="resetToToday">
Today
</v-btn>
</v-toolbar>
<highcharts id="chart" :options="chartConfig" ref="highcharts"></highcharts>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
#chart-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
#chart {
position: absolute;
top: 42px;
bottom: 0;
left: 0;
right: 0;
}
#date-button {
margin-left: 0;
margin-right: 0;
}

View File

@@ -0,0 +1,360 @@
import Vue from 'vue';
import { Component, Prop, Watch } from 'vue-property-decorator';
import moment from 'moment';
import { WeatherService, ValueType } from '@/services/WeatherService.ts';
import * as Highcharts from 'highcharts';
import { AxisOptions } from 'highcharts';
enum TimeSpan {
Last24Hours,
Day,
Custom
}
@Component
export default class Weather extends Vue {
loading: boolean = true;
ready: boolean = false;
selectedValueType: ValueType | null = null;
selectedTimeSpan: TimeSpan = TimeSpan.Last24Hours;
selectedDate: moment.Moment = moment().startOf('day');
timeSpans: typeof TimeSpan = TimeSpan;
timeSpanItems: { [value: number]: string } = {};
chartConfig: Highcharts.Options | null = null;
showDateMenu: boolean = false;
async mounted() {
Highcharts.setOptions({
global: {
useUTC: false
}
});
this.timeSpanItems[TimeSpan.Last24Hours] = 'Last 24 hours';
this.timeSpanItems[TimeSpan.Day] = 'Day';
this.selectedValueType = Number(this.$route.params['type']);
}
prepareData(deviceList: any, displayName: string = '', valueName: string, minValue?: number, additive?: boolean) {
const chartData: any[] = [];
deviceList.forEach((device: any) => {
let deviceName;
if (typeof device.Key === 'string') {
deviceName = device.Key;
} else {
deviceName = displayName === undefined ? device.Key : device.Key[displayName];
}
const deviceData = {
name: deviceName,
data: [] as any
};
let previousValue: number | null = null;
device.Value.forEach((value: any) => {
let currentValue: number | null = value[valueName];
const readTime = moment(value.ReadTime);
if (minValue && currentValue) {
if (currentValue < minValue) {
currentValue = null;
}
}
if (currentValue != null) {
if (additive && previousValue !== null) {
currentValue += previousValue;
}
}
deviceData.data.push([readTime.valueOf(), currentValue] as any);
previousValue = currentValue;
});
chartData.push(deviceData);
});
return chartData;
}
prepareDataByValueType(valueType: ValueType, deviceData: any): any {
let chartData: any;
switch (valueType) {
case ValueType.Temperature:
chartData = this.prepareData(deviceData, 'DisplayName', 'DegreesF', -40);
return { chartData: chartData, categoryData: undefined };
case ValueType.Pressure:
chartData = this.prepareData(deviceData, 'DisplayName', 'Value', 850);
return { chartData: chartData, categoryData: undefined };
case ValueType.Humidity:
chartData = this.prepareData(deviceData, 'DisplayName', 'Value', 0);
return { chartData: chartData, categoryData: undefined };
case ValueType.WindDirection:
const categoryData: any[] = [];
chartData = [];
deviceData.forEach((device: any) => {
categoryData.push(device.Key);
chartData.push(device.Value);
});
return { chartData: chartData, categoryData: categoryData };
case ValueType.WindSpeed:
chartData = this.prepareData(deviceData, undefined, 'Value');
return { chartData: chartData, categoryData: undefined };
case ValueType.Rain:
chartData = this.prepareData(deviceData, 'DisplayName', 'Inches', undefined, true);
return { chartData: chartData, categoryData: undefined };
default:
return null;
}
}
loadChart(chartData: any, categoryData: any) {
switch (this.selectedValueType) {
case ValueType.Temperature:
this.chartConfig = this.createChartConfig(chartData, 'Temperature', 'Degrees F', '°F');
break;
case ValueType.Pressure:
this.chartConfig = this.createChartConfig(chartData, 'Pressure', 'hPa', ' hPa');
break;
case ValueType.Humidity:
this.chartConfig = this.createChartConfig(chartData, 'Humidity', '%', '%');
break;
case ValueType.WindDirection:
this.chartConfig = {
chart: {
polar: true,
type: 'column'
},
legend: {
enabled: false
},
xAxis: {
categories: categoryData,
tickmarkPlacement: 'on'
},
yAxis: {
labels: {
enabled: false
}
},
plotOptions: {
series: {
shadow: false,
pointPlacement: 'on',
animation: false
},
column: {
groupPadding: 0
}
},
title: {
text: 'Wind Direction'
},
series: [{
type: 'column',
name: 'Samples',
data: chartData
}]
};
break;
case ValueType.WindSpeed:
this.chartConfig = this.createChartConfig(chartData, 'Wind Speed', 'MPH', ' MPH');
if (this.chartConfig.yAxis) {
(this.chartConfig.yAxis as AxisOptions).min = 0;
}
break;
case ValueType.Rain:
this.chartConfig = this.createChartConfig(chartData, 'Rain', '', '"');
if (this.chartConfig.yAxis) {
(this.chartConfig.yAxis as AxisOptions).min = 0;
}
break;
default:
this.chartConfig = this.createChartConfig(null, '', '', '');
break;
}
}
createChartConfig(chartData: any, title: string, yAxisTitle: string, tooltipSuffix: string): Highcharts.Options {
const chartConfig = {
chart: {
type: 'line',
zoomType: 'x'
},
tooltip: {
xDateFormat: '%A %B %e: %I:%M:%S %p',
valueDecimals: 3,
valueSuffix: tooltipSuffix
},
xAxis: {
type: 'datetime',
dateTimeLabelFormats: {
minute: '%I:%M %p',
hour: '%I:%M %p',
second: '%I:%M:%S %p',
day: '%I:%M %p'
}
},
yAxis: {
title: {
text: null
},
labels: {
formatter(): string {
return (this as any).value + tooltipSuffix;
}
}
},
plotOptions: {
series: {
marker: {
enabled: false
},
animation: false
}
},
title: {
text: title,
y: 18
},
series: chartData
};
return chartConfig;
}
@Watch('$route')
onRouteChange() {
this.selectedValueType = Number(this.$route.params['type']);
}
@Watch('selectedValueType')
@Watch('selectedTimeSpan')
@Watch('selectedDate')
async refreshChart() {
if (this.selectedValueType === null) {
return;
}
this.loading = true;
let start: Date;
let end: Date;
if (this.selectedTimeSpan === TimeSpan.Custom) {
// start = moment('2014-01-01 00:00:00 -05:00').toDate();
// end = moment('2015-01-01 00:00:00 -05:00').toDate();
// weatherService.getDailySummary($scope.selectedValueType.id, $scope.selectedDevice.id, start, end).done(data => {
// var preparedData = this.prepareDataByValueType($scope.selectedValueType.id, data);
// this.loadChart($scope, preparedData.chartData, preparedData.categoryData);
// $scope.chartConfig.loading = false;
// $scope.$apply();
// });
} else {
switch (this.selectedTimeSpan) {
case TimeSpan.Last24Hours: {
start = moment().subtract(24, 'h').toDate();
end = moment().toDate();
break;
}
case TimeSpan.Day: {
start = moment(this.selectedDate).startOf('d').toDate();
end = moment(this.selectedDate).endOf('d').toDate();
break;
}
default: {
return;
}
}
const deviceData = await WeatherService.getDeviceHistory(this.selectedValueType, start, end);
const preparedData = this.prepareDataByValueType(this.selectedValueType, deviceData);
this.loadChart(preparedData.chartData, preparedData.categoryData);
this.loading = false;
this.ready = true;
}
}
handleDateArrowClick(value: number) {
this.selectedDate.add(value, 'day');
this.refreshChart();
}
isSelectedDateToday(): boolean {
const isToday = this.selectedDate.startOf('day').isSame(moment().startOf('day'));
return isToday;
}
get selectedDateIsoString(): string {
return this.selectedDate.format('YYYY-MM-DD');
}
set selectedDateIsoString(value: string) {
this.selectedDate = moment(value);
}
getSelectedDateDisplayString(): string {
return this.selectedDate.format('LL');
}
resetToToday() {
this.selectedDate = moment().startOf('day');
}
}

0
static/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

BIN
static/favicons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"module": "es2015",
"moduleResolution": "node",
"target": "es5",
"strict": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"rootDir": ".",
"sourceMap": true,
"lib": [
"dom",
"es2017"
],
"typeRoots": [
"node_modules/@types",
"src/typings"
],
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
},
"exclude": [
"node_modules"
]
}

56
tslint.json Normal file
View File

@@ -0,0 +1,56 @@
{
"rules": {
"class-name": true,
"comment-format": [
true,
"check-space"
],
"indent": [
true,
"tabs"
],
"no-duplicate-variable": true,
"no-eval": true,
"no-internal-module": false,
"no-trailing-whitespace": false,
"no-var-keyword": true,
"one-line": [
true,
"check-open-brace",
"check-whitespace"
],
"quotemark": [
true,
"single"
],
"semicolon": [
true
],
"triple-equals": [
true,
"allow-null-check"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
}
],
"variable-name": [
true,
"ban-keywords"
],
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
]
}
}

20
typings/regression.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module 'regression' {
type NumberArray = Array<number>;
export class Result {
equation: Array<NumberArray>;
points: Array<NumberArray>;
string: string;
}
export class Options {
order?: number;
precision?: number;
}
export const _default: {
linear: (points: Array<NumberArray>, options?: Options) => Result;
}
export default _default;
}

4
typings/vue-grid-layout.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'vue-grid-layout' {
export class GridLayout { }
export class GridItem { }
}

7
typings/vue-highcharts.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module 'vue-highcharts' {
export const _default: {
install: any;
}
export default _default;
}

4
typings/vue-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

5776
yarn.lock Normal file

File diff suppressed because it is too large Load Diff