This commit is contained in:
Zakary Timson 2023-08-14 13:49:06 -04:00
commit aa0f98e742
84 changed files with 19695 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

11
LICENSE Normal file
View File

@ -0,0 +1,11 @@
Copyright (c) 2023 Zakary Timson
All Rights Reserved.
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.

64
README.md Normal file
View File

@ -0,0 +1,64 @@
<!-- Header -->
<div id="top" align="center">
<br />
<!-- Logo -->
<img src="https://git.zakscode.com/repo-avatars/02c3c82a2c192095fa04a6ea4d92d9614d37ab508fc948938b37a9cfd3196734" alt="Logo" width="200" height="200">
<!-- Title -->
### GrowBot
<!-- Description -->
Plant Manager
<!-- Repo badges -->
[![Version](https://img.shields.io/badge/dynamic/json.svg?label=Version&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/GrowBot/tags&query=$[0].name)](https://git.zakscode.com/ztimson/GrowBot/tags)
[![Pull Requests](https://img.shields.io/badge/dynamic/json.svg?label=Pull%20Requests&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/GrowBot&query=open_pr_counter)](https://git.zakscode.com/ztimson/GrowBot/pulls)
</div>
## Table of Contents
- [GrowBot](#top)
- [About](#about)
- [Built With](#built-with)
- [Setup](#setup)
- [Development](#development)
- [License](#license)
## About
### Built With
[![Angular](https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular)](https://angular.io/)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![Node](https://img.shields.io/badge/Node.js-000000?style=for-the-badge&logo=nodedotjs)](https://nodejs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/)
## Setup
<details>
<summary>
<h3 id="development" style="display: inline">
Development
</h3>
</summary>
#### Prerequisites
- [CMake](https://cmake.org/download/)
- [Node.js](https://nodejs.org/en/download)
#### Instructions
1. Install the build tools: `npm install -g node-gyp windows-build-tools`
2. Install dependencies:
1. Client: `cd client && npm install`
2. Server: `cd ../server && npm install`
3. Start the Node server: `npm run start`
4. Start the Angular client: `cd client && npm run start`
5. Open http://localhost:4200
</details>
## License
Copyright © 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing
See the [license](./LICENSE) for more information.

18
client/.browserslistrc Normal file
View File

@ -0,0 +1,18 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

16
client/.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
client/.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

112
client/angular.json Normal file
View File

@ -0,0 +1,112 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"GrowBot": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/GrowBot",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.json",
"aot": true,
"assets": [
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "GrowBot:build"
},
"configurations": {
"production": {
"browserTarget": "GrowBot:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "GrowBot:build"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "GrowBot:serve"
},
"configurations": {
"production": {
"devServerTarget": "GrowBot:serve:production"
}
}
}
}
}
},
"defaultProject": "GrowBot",
"cli": {
"analytics": false
}
}

13574
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
client/package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "grow-bot",
"version": "0.3.10",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "~10.0.5",
"@angular/cdk": "^10.1.0",
"@angular/common": "~10.0.5",
"@angular/compiler": "~10.0.5",
"@angular/core": "~10.0.5",
"@angular/forms": "~10.0.5",
"@angular/material": "^10.1.0",
"@angular/platform-browser": "~10.0.5",
"@angular/platform-browser-dynamic": "~10.0.5",
"@angular/router": "~10.0.5",
"bootstrap-scss": "^4.5.0",
"chart.js": "^2.9.3",
"chartjs-plugin-annotation": "^0.5.7",
"ng2-charts": "^2.3.3",
"rxjs": "~6.5.5",
"socket.io-client": "^2.3.0",
"tslib": "^2.0.0",
"zone.js": "~0.10.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1000.4",
"@angular/cli": "~10.0.4",
"@angular/compiler-cli": "~10.0.5",
"@types/node": "^12.11.1",
"@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~3.3.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~3.9.5"
}
}

View File

@ -0,0 +1,50 @@
import {
trigger,
animate,
transition,
style,
query, group
} from '@angular/animations';
export const collapseUp = trigger('collapseUp', [
transition('* => void', [
style({opacity: 1, transform: 'translateY(0%)'}),
animate('0.5s', style({opacity: 0, transform: 'translateY(-100%)'}))
])
]);
export const expandDown = trigger('expandDown', [
transition('void => *', [
style({opacity: 0, transform: 'translateY(-100%)'}),
animate('0.5s', style({opacity: 1, transform: 'translateY(0%)'}))
])
]);
export const fadeIn = trigger('fadeIn', [
transition('void => *', [
style({opacity: 0}),
animate('0.5s ease-in-out', style({opacity: 1}))
])
]);
export const fadeOut = trigger('fadeOut', [
transition('* => void', [
style({opacity: 1}),
animate('0.5s ease-in-out', style({opacity: 0}))
])
]);
export const routerTransition = trigger('routerTransition', [
transition('* <=> *', [
query(':enter, :leave', style({position: 'fixed', width: '100%'}), {optional: true}),
group([
query(':enter', [
style({transform: 'translateX(100%)'}),
animate('0.5s ease-in-out', style({transform: 'translateX(0%)'}))
], {optional: true}), query(':leave', [
style({transform: 'translateX(0%)'}),
animate('0.5s ease-in-out', style({transform: 'translateX(-100%)'}))
], {optional: true}),
])
])
]);

View File

@ -0,0 +1,27 @@
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {CameraComponent} from "./views/camera/camera.component";
import {ClimateComponent} from "./views/climate/climate.component";
import {DashboardComponent} from "./views/dashboard/dashboard.component";
import {WaterComponent} from "./views/water/water.component";
import {GrowOpsComponent} from "./views/growOps/growOps.component";
import {ScheduleComponent} from "./views/schedule/schedule.component";
import {NotesComponent} from "./views/notes/notes.component";
import {SettingsComponent} from "./views/settings/settings.component";
const routes: Routes = [
{path: 'camera', component: CameraComponent},
{path: 'climate', component: ClimateComponent},
{path: 'growops', component: GrowOpsComponent},
{path: 'notes', component: NotesComponent},
{path: 'schedule', component: ScheduleComponent},
{path: 'settings', component: SettingsComponent},
{path: 'water', component: WaterComponent},
{path: '**', component: DashboardComponent, data: {noAnimation: true}},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@ -0,0 +1,67 @@
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MaterialModule} from "./material.module";
import {LogoComponent} from "./components/logo/logo.component";
import {MenuComponent} from "./components/menu/menu.component";
import {StreamComponent} from "./components/stream/stream.component";
import {CameraComponent} from "./views/camera/camera.component";
import {FullscreenCameraComponent} from "./views/fullscreenCamera/fullscreenCamera.component";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatNativeDateModule} from "@angular/material/core";
import {HttpClientModule} from "@angular/common/http";
import {ClimateComponent} from "./views/climate/climate.component";
import {ChartsModule} from "ng2-charts";
import {ClimateGraphComponent} from "./components/climateGraph/climateGraph.component";
import {DashboardComponent} from "./views/dashboard/dashboard.component";
import {AppComponent} from "./views/app.component";
import {TemperatureComponent} from "./components/temperature/temperature.component";
import {FanAutoComponent} from "./components/fanAuto/fanAuto.component";
import {LightAutoComponent} from "./components/lightAuto/lightAuto.component";
import {FanToggleComponent} from "./components/fanToggle/fanToggle.component";
import {LightToggleComponent} from "./components/lightToggle/lightToggle.component";
import {WaterComponent} from "./views/water/water.component";
import {GrowOpsComponent} from "./views/growOps/growOps.component";
import {ScheduleComponent} from "./views/schedule/schedule.component";
import {NotesComponent} from "./views/notes/notes.component";
import {SettingsComponent} from "./views/settings/settings.component";
@NgModule({
declarations: [
AppComponent,
CameraComponent,
ClimateComponent,
ClimateGraphComponent,
DashboardComponent,
FanAutoComponent,
FanToggleComponent,
FullscreenCameraComponent,
GrowOpsComponent,
LightAutoComponent,
LightToggleComponent,
LogoComponent,
MenuComponent,
NotesComponent,
ScheduleComponent,
SettingsComponent,
StreamComponent,
TemperatureComponent,
WaterComponent
],
imports: [
AppRoutingModule,
BrowserModule,
BrowserAnimationsModule,
ChartsModule,
FormsModule,
HttpClientModule,
MaterialModule,
MatNativeDateModule,
ReactiveFormsModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

5
client/src/app/bootstrap.scss vendored Normal file
View File

@ -0,0 +1,5 @@
@import 'node_modules/bootstrap-scss/bootstrap';
.text-primary {
color: #689f38 !important
}

View File

@ -0,0 +1,31 @@
<div class="w-100 h-100">
<div>
<div style="width: 250px">
<mat-form-field appearance="fill" color="accent">
<mat-label>Time Frame</mat-label>
<mat-select [(ngModel)]="timeFrame">
<mat-option value="12">12 hours</mat-option>
<mat-option value="24">24 hours</mat-option>
<mat-option value="72">3 Days</mat-option>
<mat-option value="168">7 Days</mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-checkbox class="mr-5 red" [(ngModel)]="toggleTemp" (change)="refresh()">Temperature</mat-checkbox>
<mat-checkbox class="mr-5 green" [(ngModel)]="toggleHumidity" (change)="refresh()">Humidity</mat-checkbox>
<mat-checkbox class="mr-5 yellow" [(ngModel)]="toggleLight" (change)="refresh()">Lights</mat-checkbox>
<mat-checkbox class="mr-5 blue" [(ngModel)]="toggleFans" (change)="refresh()">Fans</mat-checkbox>
</div>
</div>
<mat-divider></mat-divider>
<canvas baseChart class="mt-4"
[datasets]="data | async"
[labels]="labels | async"
[colors]="colors"
[legend]="false"
chartType="line"
[plugins]="plugins"
[options]="options | async">
</canvas>
</div>

View File

@ -0,0 +1,79 @@
.red {
::ng-deep.mat-checkbox-label {
color: rgb(150, 80, 80) !important;
}
::ng-deep.mat-checkbox-frame {
border-color: rgb(150,80,80);
}
&.mat-checkbox-checked {
::ng-deep.mat-checkbox-background {
background-color: rgb(150,80,80) !important;
}
}
::ng-deep.mat-ripple-element {
background: rgb(150,80,80) !important;
}
}
.green {
::ng-deep.mat-checkbox-label {
color: rgb(80,150,80) !important;
}
::ng-deep.mat-checkbox-frame {
border-color: rgb(80,150,80);
}
&.mat-checkbox-checked {
::ng-deep.mat-checkbox-background {
background-color: rgb(80,150,80) !important;
}
}
::ng-deep.mat-ripple-element {
background: rgb(80,150,80) !important;
}
}
.blue {
::ng-deep.mat-checkbox-label {
color: rgb(80,80,230) !important;
}
::ng-deep.mat-checkbox-frame {
border-color: rgb(80,80,230);
}
&.mat-checkbox-checked {
::ng-deep.mat-checkbox-background {
background-color: rgb(80,80,230) !important;
}
}
::ng-deep.mat-ripple-element {
background: rgb(80,80,230) !important;
}
}
.yellow {
::ng-deep.mat-checkbox-label {
color: rgb(150,150,80) !important;
}
::ng-deep.mat-checkbox-frame {
border-color: rgb(150,150,80);
}
&.mat-checkbox-checked {
::ng-deep.mat-checkbox-background {
background-color: rgb(150,150,80) !important;
}
}
::ng-deep.mat-ripple-element {
background: rgb(150,150,80) !important;
}
}

View File

@ -0,0 +1,135 @@
import {Component} from "@angular/core";
import {ChartDataSets} from "chart.js";
import * as pluginAnnotations from 'chartjs-plugin-annotation';
import {BehaviorSubject} from "rxjs";
@Component({
selector: 'climate-graph',
templateUrl: './climateGraph.component.html',
styleUrls: ['./climateGraph.component.scss']
})
export class ClimateGraphComponent {
timeFrame = '12';
xLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July'];
tempData: number[] = [28, 48, 40, 19, 86, 27, 90];
toggleTemp = true;
humidityData: number[] = [38, 28, 50, 9, 76, 17, 80];
toggleHumidity = true;
lightData: number[][] = [[1, 3], [4, 6]];
toggleLight = false;
fanData: number[][] = [[2, 5]];
toggleFans = false;
colors = [{
backgroundColor: 'rgba(150,0,0,0.5)',
borderColor: 'rgba(150,0,0,0.5)',
pointBackgroundColor: 'rgba(0,0,0,0)',
pointBorderColor: 'rgba(0,0,0,0)',
pointHoverBackgroundColor: 'rgba(0,0,0,0)',
pointHoverBorderColor: 'rgba(0,0,0,0)',
}, {
backgroundColor: 'rgba(0,150,0,0.5)',
borderColor: 'rgba(0,150,0,0.5)',
pointBackgroundColor: 'rgba(0,0,0,0)',
pointBorderColor: 'rgba(0,0,0,0)',
pointHoverBackgroundColor: 'rgba(0,0,0,0)',
pointHoverBorderColor: 'rgba(0,0,0,0)',
}];
data = new BehaviorSubject(null);
labels = new BehaviorSubject(null);
options = new BehaviorSubject(null);
plugins = [pluginAnnotations];
constructor() {
this.refresh();
}
refresh() {
this.data.next([
{data: this.tempData, label: 'Temperature', fill: false, showLine: this.toggleTemp},
{data: this.humidityData, label: 'Humidity', fill: false, showLine: this.toggleHumidity}
]);
this.labels.next(this.xLabels);
let options = {
responsive: true,
scales: {
xAxes: [{}],
yAxes: [
{
id: 'y-axis-0',
position: 'left',
gridLines: {
color: 'rgba(0,0,0,0)',
},
ticks: {
callback: (val) => `${val} °C`,
beginAtZero: true,
maxTicksLimit: 10,
precision: 0,
stepSize: 5
}
},
{
id: 'y-axis-1',
position: 'right',
gridLines: {
color: 'rgba(0,0,0,0)',
},
ticks: {
callback: (val) => `${Math.round(val * 100)} %`,
beginAtZero: true,
maxTicksLimit: 10,
precision: 0,
stepSize: 0.1,
suggestedMax: 1,
suggestedMin: 0
}
}
]
},
annotation: {
annotations: [],
},
};
if(this.toggleLight) {
this.lightData.forEach((dataPoint: number[]) => {
options.annotation.annotations.push({
type: 'box',
drawTime: 'beforeDatasetsDraw',
xScaleID: 'x-axis-0',
yScaleID: 'y-axis-0',
xMin: dataPoint[0],
xMax: dataPoint[1],
yMin: 0,
yMax: 100,
borderWidth: 3,
borderColor: 'orange',
backgroundColor: 'rgba(255,255,0,0.1)',
});
});
}
if(this.toggleFans) {
this.fanData.forEach((dataPoint: number[]) => {
options.annotation.annotations.push({
type: 'box',
drawTime: 'beforeDatasetsDraw',
xScaleID: 'x-axis-0',
yScaleID: 'y-axis-0',
xMin: dataPoint[0],
xMax: dataPoint[1],
yMin: 0,
yMax: 100,
borderWidth: 3,
borderColor: 'blue',
backgroundColor: 'rgba(0,0,255,0.1)',
});
});
}
this.options.next(options);
}
}

View File

@ -0,0 +1,41 @@
<div class="row">
<div class="col-12 col-xl-6">
<mat-slide-toggle color="accent" [(ngModel)]="climateService.fanConfig.autoFan" (change)="save()">Automatic</mat-slide-toggle>
</div>
<div class="col-12 col-xl-6 mt-4 mt-xl-0">
<mat-radio-group [disabled]="!climateService.fanConfig.autoFan" [(ngModel)]="climateService.fanConfig.fanMode" (change)="save()">
<mat-radio-button value="time" checked>Time</mat-radio-button>
<mat-radio-button class="ml-3" value="temp">Temp</mat-radio-button>
</mat-radio-group>
</div>
</div>
<div *ngIf="climateService.fanConfig.fanMode == 'time'" class="mt-4 row">
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>From</mat-label>
<input matInput type="time" [(ngModel)]="climateService.fanConfig.fanOn" (change)="save()" [disabled]="!climateService.fanConfig.autoFan">
</mat-form-field>
</div>
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>To</mat-label>
<input matInput type="time" [(ngModel)]="climateService.fanConfig.fanOff" (change)="save()" [disabled]="!climateService.fanConfig.autoFan">
</mat-form-field>
</div>
</div>
<div *ngIf="climateService.fanConfig.fanMode == 'temp'" class="mt-4 row">
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>Temperature</mat-label>
<input matInput type="number" [(ngModel)]="climateService.fanConfig.fanTemp" (change)="save()" [disabled]="!climateService.fanConfig.autoFan">
<span matSuffix>°C</span>
</mat-form-field>
</div>
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>Humidity</mat-label>
<input matInput type="number" [(ngModel)]="climateService.fanConfig.fanHumidity" (change)="save()" [disabled]="!climateService.fanConfig.autoFan">
<span matSuffix>%</span>
</mat-form-field>
</div>
</div>

View File

@ -0,0 +1,23 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'fan-auto',
templateUrl: './fanAuto.component.html'
})
export class FanAutoComponent {
private readonly TIMEOUT = 2500;
private debuf;
constructor(public climateService: ClimateService) { }
save() {
if(this.debuf == null) {
this.debuf = setTimeout(() => {
let ignore = this.climateService.saveFan();
this.debuf = null;
}, this.TIMEOUT)
}
}
}

View File

@ -0,0 +1,3 @@
<button mat-mini-fab [color]="climateService.fanConfig.on ? 'primary' : ''" (click)="climateService.toggleFan()">
<mat-icon>power_settings_new</mat-icon>
</button>

View File

@ -0,0 +1,10 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'fan-toggle',
templateUrl: './fanToggle.component.html'
})
export class FanToggleComponent {
constructor(public climateService: ClimateService) { }
}

View File

@ -0,0 +1,15 @@
<mat-slide-toggle color="accent" [(ngModel)]="climateService.lightConfig.autoLight" (change)="save()">Automatic</mat-slide-toggle>
<div class="mt-4 row">
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>From</mat-label>
<input matInput type="time" [(ngModel)]="climateService.lightConfig.lightOn" (change)="save()" [disabled]="!climateService.lightConfig.autoLight">
</mat-form-field>
</div>
<div class="col">
<mat-form-field class="w-100" appearance="fill" color="accent">
<mat-label>To</mat-label>
<input matInput type="time" [(ngModel)]="climateService.lightConfig.lightOff" (change)="save()" [disabled]="!climateService.lightConfig.autoLight">
</mat-form-field>
</div>
</div>

View File

@ -0,0 +1,23 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'light-auto',
templateUrl: './lightAuto.component.html'
})
export class LightAutoComponent {
private readonly TIMEOUT = 2500;
private debuf;
constructor(public climateService: ClimateService) { }
save() {
if(this.debuf == null) {
this.debuf = setTimeout(() => {
let ignore = this.climateService.saveLight();
this.debuf = null;
}, this.TIMEOUT)
}
}
}

View File

@ -0,0 +1,3 @@
<button mat-mini-fab [color]="climateService.lightConfig.on ? 'primary' : ''" (click)="climateService.toggleLight()">
<mat-icon>power_settings_new</mat-icon>
</button>

View File

@ -0,0 +1,10 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'light-toggle',
templateUrl: './lightToggle.component.html'
})
export class LightToggleComponent {
constructor(public climateService: ClimateService) { }
}

View File

@ -0,0 +1 @@
<img [ngClass]="{'glow': glow}" src="/assets/images/logo.png" alt="GrowBot">

View File

@ -0,0 +1,14 @@
.glow {
animation: glow;
animation-delay: 1s;
animation-duration: 2s;
animation-direction: alternate;
animation-timing-function: ease-in;
animation-iteration-count: infinite;
filter: drop-shadow(0px 0px 0px green);
}
@keyframes glow {
from {filter: drop-shadow(0px 0px 0px #00ff00);}
to {filter: drop-shadow(0px 0px 5px #00ff00);}
}

View File

@ -0,0 +1,12 @@
import {Component, Input} from "@angular/core";
@Component({
selector: 'logo',
templateUrl: `./logo.component.html`,
styleUrls: ['./logo.component.scss']
})
export class LogoComponent {
@Input() glow = true;
@Input() height = 500;
@Input() width = 500;
}

View File

@ -0,0 +1,23 @@
<mat-nav-list class="p-0">
<ng-container *ngFor="let item of items; let i = $index">
<mat-divider *ngIf="i > 0"></mat-divider>
<a mat-list-item [routerLink]="item.link" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
<span class="p-3 pl-0 mr-5">
<mat-icon *ngIf="item.icon" class="mr-4" style="vertical-align: sub">{{item.icon}}</mat-icon>
<span>{{item.text}}</span>
</span>
</a>
<ng-container *ngIf="item.sub?.length">
<ng-container *ngFor="let subItem of item.sub">
<mat-divider></mat-divider>
<a mat-list-item class="pl-4" [routerLink]="subItem.link" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
<span class="p-3 pl-0 mr-5">
<mat-icon *ngIf="subItem.icon" class="mr-4" style="vertical-align: sub">{{subItem.icon}}</mat-icon>
<span>{{subItem.text}}</span>
</span>
</a>
</ng-container>
</ng-container>
<mat-divider></mat-divider>
</ng-container>
</mat-nav-list>

View File

@ -0,0 +1,4 @@
.active {
background-color: #689f38;
color: #ffffff;
}

View File

@ -0,0 +1,11 @@
import {Component, Input} from "@angular/core";
import {MenuItem} from "../../models/menuItem";
@Component({
selector: 'menu',
templateUrl: `./menu.component.html`,
styleUrls: ['./menu.component.scss']
})
export class MenuComponent {
@Input() items: MenuItem[] = [];
}

View File

@ -0,0 +1,17 @@
<div class="stream h-100 w-100 position-relative">
<div class="top-controls text-white">
<button mat-icon-button matTooltip="Take Picture" (click)="cameraService.snap()"><mat-icon style=" text-shadow: 0 0 5px #000;">camera_alt</mat-icon></button>
<button mat-icon-button *ngIf="fullscreen" matTooltip="Close" [mat-dialog-close]><mat-icon style=" text-shadow: 0 0 5px #000;">fullscreen_exit</mat-icon></button>
<button mat-icon-button *ngIf="!fullscreen" matTooltip="Fullscreen" (click)="openFullscreen()"><mat-icon style=" text-shadow: 0 0 5px #000;">fullscreen</mat-icon></button>
</div>
<mat-progress-spinner *ngIf="loading" class="center" mode="indeterminate"></mat-progress-spinner>
<img id="stream" [src]="stream | async" width="100%" height="100%" alt="stream" [hidden]="loading">
<div class="bottom-controls text-white">
<button mat-icon-button *ngIf="pause" (click)="pause = !pause" matTooltip="Resume">
<mat-icon style="font-size: 2rem; text-shadow: 0 0 5px #000;">play_circle_outline</mat-icon>
</button>
<button mat-icon-button *ngIf="!pause" (click)="pause = !pause" matTooltip="Pause">
<mat-icon style="font-size: 2rem; text-shadow: 0 0 5px #000;">pause_circle_outline</mat-icon>
</button>
</div>
</div>

View File

@ -0,0 +1,33 @@
.stream {
min-height: 250px;
min-width: 250px;
.center {
position: absolute;
left: calc(50% - 50px);
top: calc(50% - 50px);
}
.top-controls {
position: absolute;
top: 1.5vh;
right: 1.5vh;
}
.bottom-controls {
position: absolute;
bottom: 1.5vh;
left: 50%;
transform: translateX(-50%);
}
::ng-deep.fill-it {
border-radius: 0;
max-width: 100% !important;
mat-dialog-container {
border-radius: 0;
padding: 0 !important;
}
}
}

View File

@ -0,0 +1,46 @@
import {Component, Input, OnDestroy, OnInit} from "@angular/core";
import {CameraService} from "../../services/camera.service";
import {filter, map, tap} from "rxjs/operators";
import {Observable} from "rxjs";
import {MatDialog} from "@angular/material/dialog";
import {FullscreenCameraComponent} from "../../views/fullscreenCamera/fullscreenCamera.component";
@Component({
selector: 'stream',
templateUrl: './stream.component.html',
styleUrls: ['./stream.component.scss']
})
export class StreamComponent implements OnDestroy, OnInit{
@Input() fullscreen = false;
@Input() pause: boolean = false;
loading = true;
stream: Observable<any>;
constructor(public cameraService: CameraService, private matDialog: MatDialog) {
this.stream = this.cameraService.stream.pipe(
filter(() => !this.pause),
tap(() => this.loading = false),
map(data => `data:image/jpeg;base64,${data}`)
);
}
openFullscreen() {
let orgStatus = this.pause
this.pause = true;
this.matDialog.open(FullscreenCameraComponent, {
width: '100%',
height: '100%',
panelClass: 'fill-it',
autoFocus: false
}).afterClosed().subscribe(() => this.pause = orgStatus);
}
ngOnInit() {
this.cameraService.start();
}
ngOnDestroy() {
this.cameraService.stop();
}
}

View File

@ -0,0 +1,31 @@
<div>
<div class="d-block d-md-none">
<div class="row">
<h1 class="ml-3 mb-1">Temperature: {{climateService.temp}} °C</h1>
</div>
<mat-divider></mat-divider>
<div class="row">
<h1 class="ml-3 mb-0">
Humidity: {{climateService.humidity | percent}}
</h1>
</div>
</div>
<div class="d-none d-md-block">
<div class="row">
<div class="col border-right pr-2">
<h1 class="mb-0">
Temperature
<br>
{{climateService.temp}} °C
</h1>
</div>
<div class="col border-left pl-3">
<h1 class="mb-0">
Humidity
<br>
{{climateService.humidity | percent}}
</h1>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'temperature',
templateUrl: './temperature.component.html'
})
export class TemperatureComponent {
constructor(public climateService: ClimateService) { }
}

View File

@ -0,0 +1,43 @@
import {NgModule} from '@angular/core';
import {MatIconModule} from "@angular/material/icon";
import {MatToolbarModule} from "@angular/material/toolbar";
import {MatDividerModule} from "@angular/material/divider";
import {MatSidenavModule} from "@angular/material/sidenav";
import {MatListModule} from "@angular/material/list";
import {MatButtonModule} from "@angular/material/button";
import {MatCardModule} from "@angular/material/card";
import {MatTooltipModule} from "@angular/material/tooltip";
import {MatDialogModule} from "@angular/material/dialog";
import {MatSlideToggleModule} from "@angular/material/slide-toggle";
import {MatButtonToggleModule} from "@angular/material/button-toggle";
import {MatCheckboxModule} from "@angular/material/checkbox";
import {MatInputModule} from "@angular/material/input";
import {MatSelectModule} from "@angular/material/select";
import {MatRadioModule} from "@angular/material/radio";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
const modules = [
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
MatCheckboxModule,
MatDialogModule,
MatDividerModule,
MatIconModule,
MatInputModule,
MatListModule,
MatRadioModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSidenavModule,
MatSlideToggleModule,
MatToolbarModule,
MatTooltipModule,
];
@NgModule({
imports: modules,
exports: modules
})
export class MaterialModule {
}

View File

@ -0,0 +1,99 @@
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons&display=swap);
@import 'node_modules/@angular/material/theming';
@include mat-core();
$GrowBot-primary: mat-palette($mat-light-green, 700);
$GrowBot-accent: mat-palette($mat-light-blue, 700);
$GrowBot-warn: mat-palette($mat-red);
$GrowBot-light-theme: mat-light-theme((
color: (
primary: $GrowBot-primary,
accent: $GrowBot-accent,
warn: $GrowBot-warn,
)
));
$GrowBot-dark-theme: mat-dark-theme((
color: (
primary: $GrowBot-primary,
accent: $GrowBot-accent,
warn: $GrowBot-warn,
)
));
.light-theme {
@include angular-material-theme($GrowBot-light-theme);
.mat-toolbar-row, .mat-toolbar-single-row {
height: 48px;
}
.mat-drawer-container {
background-color: rgba(255, 255, 255, 0) !important;
}
.mat-toolbar-row, .mat-toolbar-single-row {
height: 48px;
}
.p-0 {
mat-dialog-container {
padding: 0;
border-radius: 0;
}
}
.mat-list-item {
&.active {
color: #FFFFFF !important;
}
&:hover {
background: #94ba73 !important;
color: #ffffff;
}
&:focus {
background: #689f38 !important;
color: #ffffff;
}
}
}
.dark-theme {
@include angular-material-theme($GrowBot-dark-theme);
.mat-toolbar-row, .mat-toolbar-single-row {
height: 48px;
}
.mat-drawer-container {
background-color: rgba(255, 255, 255, 0) !important;
}
.mat-toolbar-row, .mat-toolbar-single-row {
height: 48px;
}
.p-0 {
mat-dialog-container {
padding: 0;
border-radius: 0;
}
}
.mat-list-item {
&:hover {
background: #94ba73 !important;
color: #ffffff;
}
&:focus {
background: #689f38 !important;
color: #ffffff;
}
}
}

View File

@ -0,0 +1,7 @@
export interface MenuItem {
class?: string;
icon?: string;
link?: string;
text: string;
sub?: MenuItem[];
}

View File

@ -0,0 +1,71 @@
import {Injectable} from "@angular/core";
import SocketIO from 'socket.io-client';
import {environment} from "../../environments/environment";
import {Subject} from "rxjs";
import {HttpClient} from "@angular/common/http";
@Injectable({
providedIn: 'root'
})
export class CameraService {
private socket;
images: string[] = [];
stream: Subject<string>;
config = {
timelapseEnabled: false,
timelapseFrequency: '0 0 12 * * *'
};
constructor(private http: HttpClient) {
this.socket = SocketIO(environment.api)
this.stream = new Subject();
}
async del(filename) {
let resp = <any>(await this.http.delete(`${environment.api}/timelapse/${filename}`).toPromise());
this.images = resp.files;
this.config = {
timelapseEnabled: resp.timelapseEnabled,
timelapseFrequency: resp.timelapseFrequency
};
}
async list() {
let resp = <any>(await this.http.get(`${environment.api}/timelapse`).toPromise());
this.images = resp.files;
this.config = {
timelapseEnabled: resp.timelapseEnabled,
timelapseFrequency: resp.timelapseFrequency
};
}
async save(config) {
let resp = <any>(await this.http.put(`${environment.api}/timelapse`, config).toPromise());
this.images = resp.files;
this.config = {
timelapseEnabled: resp.timelapseEnabled,
timelapseFrequency: resp.timelapseFrequency
};
}
async snap() {
let resp = <any>(await this.http.post(`${environment.api}/timelapse`, {}).toPromise());
this.images = resp.files;
this.config = {
timelapseEnabled: resp.timelapseEnabled,
timelapseFrequency: resp.timelapseFrequency
};
}
start() {
this.socket.on('stream', data => {
this.stream.next(data);
});
return this.stream;
}
stop() {
this.socket.off('stream');
}
}

View File

@ -0,0 +1,59 @@
import {Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {timer} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class ClimateService {
private readonly interval = 5 // Seconds
private first = true;
fanConfig = {
on: false,
autoFan: false,
fanMode: 'time',
fanOn: null,
fanOff: null,
fanTemp: null,
fanHumidity: null,
}
lightConfig = {
on: false,
autoLight: false,
lightOn: null,
lightOff: null
}
temp: number = 0;
humidity: number = 0;
constructor(private http: HttpClient) {
timer(0, this.interval * 1000).subscribe(async () => {
if(this.first) {
this.fanConfig = <any>(await this.http.get(`${environment.api}/fan/`).toPromise());
this.lightConfig = <any>(await this.http.get(`${environment.api}/light/`).toPromise());
this.first = false;
} else {
this.fanConfig.on = <any>(await this.http.get(`${environment.api}/fan/`).toPromise())['on'];
this.lightConfig.on = <any>(await this.http.get(`${environment.api}/light/`).toPromise())['on'];
}
});
}
async toggleFan() {
return this.fanConfig.on = <any>(await this.http.post(`${environment.api}/fan/`, {}).toPromise())['on'];
}
async toggleLight() {
return this.lightConfig.on = <any>(await this.http.post(`${environment.api}/light/`, {}).toPromise())['on'];
}
async saveFan() {
this.fanConfig = <any>(await this.http.put(`${environment.api}/fan/`, this.fanConfig).toPromise());
}
async saveLight() {
this.lightConfig = <any>(await this.http.put(`${environment.api}/light/`, this.lightConfig).toPromise());
}
}

View File

@ -0,0 +1,30 @@
export interface WebStorageOptions {
fieldName?: string;
encryptionKey?: string;
defaultValue?: any;
}
export function LocalStorage(opts: WebStorageOptions = {}) {
return storage(localStorage, opts);
}
export function SessionStorage(opts: WebStorageOptions = {}) {
return storage(sessionStorage, opts);
}
function storage(storageType: Storage, opts: WebStorageOptions) {
return function(target: object, key: string) {
if(!opts.fieldName) opts.fieldName = key;
Object.defineProperty(target, key, {
get: function() {
let value = storageType.getItem(<string>opts.fieldName);
if(!value && opts.defaultValue != null) return opts.defaultValue;
return JSON.parse(<string>value);
},
set: function(value) {
storageType.setItem(<string>opts.fieldName, JSON.stringify(value));
}
});
};
}

View File

@ -0,0 +1,29 @@
<div [ngClass]="{'light-theme': !darkMode, 'dark-theme': darkMode}">
<mat-toolbar *ngIf="!hide" [@collapseUp]="!hide" [@expandDown]="!hide">
<button mat-icon-button *ngIf="mobile" class="mr-2">
<mat-icon (click)="open = !open">menu</mat-icon>
</button>
<img src="assets/images/logo.png" class="mr-2" height="24px" width="auto">
<span>
<span style="font-weight: 500;">GrowBot</span>
<small class="text-primary ml-2">v{{version}}</small>
</span>
<span class="mx-auto"></span>
<span>
<mat-slide-toggle [(ngModel)]="darkMode"><mat-icon>invert_colors</mat-icon></mat-slide-toggle>
</span>
</mat-toolbar>
<mat-drawer-container class="fill-height" [hasBackdrop]="false">
<mat-drawer [mode]="mobile ? 'push' : 'side'" [opened]="open" [disableClose]="!mobile" [autoFocus]="false">
<menu class="m-0 p-0" [items]="menuItems"></menu>
</mat-drawer>
<mat-drawer-content>
<main class="h-100" [@routerTransition]="transition(o)">
<router-outlet #o="outlet"></router-outlet>
</main>
</mat-drawer-content>
</mat-drawer-container>
<div class="background">
<logo style="position: fixed; left: 50vw; top: 50vh; transform: translate(-50%, -50%);"></logo>
</div>
</div>

View File

@ -0,0 +1,12 @@
.background {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: -1;
}
.fill-height {
height: calc(100vh - 48px);
}

View File

@ -0,0 +1,61 @@
import {Component, HostBinding} from '@angular/core';
import {version} from '../../../package.json';
import {ActivatedRoute, NavigationEnd, Router} from "@angular/router";
import {BreakpointObserver, Breakpoints} from "@angular/cdk/layout";
import {filter} from "rxjs/operators";
import {collapseUp, expandDown, routerTransition} from "../animations";
import {MenuItem} from "../models/menuItem";
import {LocalStorage} from "../utils/webStorage";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [collapseUp, expandDown, routerTransition]
})
export class AppComponent {
// Theme
private body = document.getElementsByTagName('body')[0];
@LocalStorage({defaultValue: false}) private _darkMode: boolean;
get darkMode(): boolean { return this._darkMode; }
set darkMode(val: boolean) {
this._darkMode = val;
this.body.className = val ? 'dark-theme' : 'light-theme';
}
hide = false; // Hide nav
mobile = true; // Mobile or desktop size
noTransition = false; // Stop router transitions
open = false; // Side nav open
version = version; // Version number from package.json
menuItems: MenuItem[] = [
{text: 'Dashboard', icon: 'dashboard', link: '/'},
{text: 'Climate', icon: 'eco', link: '/climate'},
{text: 'Water', icon: 'waves', link: '/water'},
{text: 'Camera', icon: 'videocam', link: '/camera'},
{text: 'GrowOps', icon: 'spa', link: '/growops', sub: [
{text: 'Schedule', icon: 'event', link: '/schedule'},
{text: 'Notes', icon: 'notes', link: '/notes'},
]},
{text: 'Settings', icon: 'settings', link: '/settings'},
]
constructor(private router: Router, route: ActivatedRoute, breakpointObserver: BreakpointObserver) {
this.darkMode = this._darkMode;
router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
this.hide = route.root.firstChild != null ? !!route.root.firstChild.snapshot.data.hide : false;
this.open = !this.hide && !this.mobile;
});
breakpointObserver.observe([Breakpoints.Handset, Breakpoints.Tablet]).subscribe(result => {
this.mobile = result.matches;
this.open = !this.mobile;
})
}
transition(outlet) {
if(!outlet.isActivated || !!outlet.activatedRouteData.noAnimation || this.noTransition) return '';
return outlet.activatedRoute;
}
}

View File

@ -0,0 +1,57 @@
<div class="container-fluid">
<div class="row pt-3">
<div class="col-12 col-lg-8">
<mat-card style="padding: 3px">
<mat-card-content>
<stream></stream>
</mat-card-content>
</mat-card>
</div>
<div class="col-12 col-lg-4 mt-3 mt-lg-0">
<mat-card>
<mat-card-content>
<div>
<h2>Timelapse</h2>
<mat-divider></mat-divider>
<div>
<mat-slide-toggle class="mt-3" [(ngModel)]="cameraService.config.timelapseEnabled" color="accent" (change)="save()">Enable</mat-slide-toggle>
<mat-form-field class="w-100 mt-3" appearance="fill">
<mat-label>Photo Frequency</mat-label>
<mat-select [(ngModel)]="cameraService.config.timelapseFrequency" [disabled]="!cameraService.config.timelapseEnabled" (selectionChange)="save()">
<mat-option value="0 * * * * *">Minute</mat-option>
<mat-option value="0 0 * * * *">Hourly</mat-option>
<mat-option value="0 0 12 * * *">Daily</mat-option>
<mat-option value="0 0 12 */2 * *">Alternating Days</mat-option>
<mat-option value="0 0 12 * * 0">Weekly</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="mt-3">
<h2>Images</h2>
<mat-divider></mat-divider>
<div>
<div *ngIf="cameraService.images.length == 0" class="pt-4 text-primary">No Images</div>
<mat-list>
<mat-list-item *ngFor="let image of cameraService.images" class="hover">
<div class="w-100 d-flex align-items-center hover">
<div class="pr-2 pt-1">
<mat-icon>image</mat-icon>
</div>
<div class="flex-grow-1">
<a target="_blank" [href]="environment.api + '/images/' + image">{{image}}</a>
</div>
<div>
<button mat-icon-button class="text-danger" (click)="cameraService.del(image)"><mat-icon>delete</mat-icon></button>
</div>
</div>
</mat-list-item>
</mat-list>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,21 @@
import {Component} from "@angular/core";
import {CameraService} from "../../services/camera.service";
import {environment} from "../../../environments/environment";
@Component({
selector: 'camera',
templateUrl: `./camera.component.html`
})
export class CameraComponent {
environment = environment;
config;
constructor(public cameraService: CameraService) {
let ignore = this.cameraService.list();
}
async save() {
await this.cameraService.save(this.cameraService.config);
this.config = this.cameraService.config;
}
}

View File

@ -0,0 +1,57 @@
<div class="container-fluid">
<div class="row" style="align-items: flex-end;">
<div class="d-none d-lg-block col-lg-4">
<mat-card>
<mat-card-content>
<temperature></temperature>
</mat-card-content>
</mat-card>
</div>
<div class="col-12 col-lg-4 mt-4">
<mat-card>
<mat-card-content>
<div class="d-flex">
<div style="writing-mode: vertical-lr;">
<light-toggle></light-toggle>
<h1 class=" pt-3 d-inline">Light</h1>
</div>
<div class="flex-grow-1 ml-3 border-left p-3">
<light-auto></light-auto>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-12 col-lg-4 mt-4">
<mat-card>
<mat-card-content>
<div class="d-flex">
<div style="writing-mode: vertical-lr;">
<fan-toggle></fan-toggle>
<h1 class=" pt-3 d-inline">Fan</h1>
</div>
<div class="flex-grow-1 ml-3 border-left p-3">
<fan-auto></fan-auto>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="d-block d-lg-none col-12 mt-4">
<mat-card>
<mat-card-content>
<temperature></temperature>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row my-4">
<div class="col">
<mat-card>
<mat-card-content>
<climate-graph></climate-graph>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,8 @@
import {Component} from "@angular/core";
import {ClimateService} from "../../services/climate.service";
@Component({
selector: 'climate',
templateUrl: 'climate.component.html'
})
export class ClimateComponent { }

View File

@ -0,0 +1,22 @@
<div class="container-fluid">
<div class="row">
<div class="col-12 col-lg-4 mt-4">
<div class="row">
<div class="col-12">
<mat-card>
<mat-card-content>
<temperature></temperature>
</mat-card-content>
</mat-card>
</div>
<div class="col-12 mt-4">
<mat-card style="padding: 3px">
<mat-card-content>
<stream></stream>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent{ }

View File

@ -0,0 +1 @@
<stream [fullscreen]="true"></stream>

View File

@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'fullscreen-camera',
templateUrl: `./fullscreenCamera.component.html`,
})
export class FullscreenCameraComponent { }

View File

@ -0,0 +1,9 @@
import {Component} from "@angular/core";
@Component({
selector: 'grow-ops',
templateUrl: 'growOps.component.html'
})
export class GrowOpsComponent {
constructor() { }
}

View File

@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'notes',
templateUrl: './notes.component.html'
})
export class NotesComponent { }

View File

@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'schedule',
templateUrl: 'schedule.component.html'
})
export class ScheduleComponent { }

View File

@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'settings',
templateUrl: 'settings.component.html'
})
export class SettingsComponent { }

View File

@ -0,0 +1,9 @@
import {Component} from "@angular/core";
@Component({
selector: 'water',
templateUrl: 'water.component.html'
})
export class WaterComponent {
constructor() { }
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -0,0 +1,3 @@
export const environment = {
production: true
};

View File

@ -0,0 +1,4 @@
export const environment = {
api: 'http://localhost:5000',
production: false
};

24
client/src/index.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<base href="/">
<title>GrowBot</title>
<link rel="icon" type="image/png" href="/assets/images/logo.png">
<!-- TODO: meta, manifest & PWA
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#032f1f">
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body style="background-color: #053825">
<app-root>
<img style="position: fixed; left: 50vw; top: 50vh; transform: translate(-50%, -50%);" src="/assets/images/logo.png" src="GrowBot">
</app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>

12
client/src/main.ts Normal file
View File

@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));

63
client/src/polyfills.ts Normal file
View File

@ -0,0 +1,63 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

27
client/src/styles.scss Normal file
View File

@ -0,0 +1,27 @@
@import 'src/app/bootstrap';
@import 'src/app/material';
html, body { height: 100%; }
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
button {
outline: none !important;
}
// Scrollbar
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
background-color: #FFFFFF15;
}
::-webkit-scrollbar {
width: 10px;
background-color: #FFFFFF15;
}
::-webkit-scrollbar-thumb {
background-color: #689f38;
}

33
client/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./out-tsc/app",
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"importHelpers": true,
"target": "es2015",
"module": "es2020",
"types": [],
"lib": [
"es2018",
"dom"
]
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

3
server/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

3881
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
server/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "grow-bot",
"version": "0.1.0",
"description": "",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"serve": "npm run watch | nodemon dist/main.js",
"watch": "npm run build && tsc -w"
},
"keywords": [],
"author": "",
"dependencies": {
"@types/node": "^14.0.26",
"configstore": "^5.0.1",
"cors": "^2.8.5",
"cron": "^1.8.2",
"express": "^4.16.4",
"opencv4nodejs": "^5.6.0",
"pg": "^8.3.0",
"reflect-metadata": "^0.1.13",
"socket.io": "^2.3.0",
"typeorm": "^0.2.25"
},
"devDependencies": {
"@types/express": "^4.16.1",
"nodemon": "^1.19.0",
"typescript": "^3.4.5"
}
}

View File

@ -0,0 +1,61 @@
import {ClimateService} from "../services/climateService";
import ConfigStore from 'configstore';
import {Express} from "express";
export function FanController(app: Express, config: ConfigStore, climate: ClimateService) {
const ENDPOINT = '/fan'
app.get(ENDPOINT, get)
app.post(ENDPOINT, post);
app.put(ENDPOINT, put);
let onCron, offCron;
function cron() {
if(config.get('climate.autoFan')) {
if (onCron != null) {
onCron.stop();
onCron = null;
}
} else if (onCron != null || offCron != null) {
onCron.stop();
onCron = null;
offCron.stop();
offCron = null;
}
}
function get(req, res) {
let resp = {
on: climate.fanState(),
autoFan: config.get('climate.autoFan'),
fanMode: config.get('climate.fanMode'),
fanOn: config.get('climate.fanOn'),
fanOff: config.get('climate.fanOff'),
fanTemp: config.get('climate.fanTemp'),
fanHumidity: config.get('climate.fanHumidity'),
};
res.json(resp);
}
function post(req, res) {
console.log('Toggling fan');
climate.toggleFan();
get(req, res);
}
function put(req, res) {
console.log('Updating fan config');
console.log(req.body);
if(req.body['autoFan'] != null) config.set('climate.autoFan', req.body['autoFan']);
if(req.body['fanMode'] != null) config.set('climate.fanMode', req.body['fanMode']);
if(req.body['fanOn'] != null) config.set('climate.fanOn', req.body['fanOn']);
if(req.body['fanOff'] != null) config.set('climate.fanOff', req.body['fanOff']);
if(req.body['fanTemp'] != null) config.set('climate.fanTemp', req.body['fanTemp']);
if(req.body['fanHumidity'] != null) config.set('climate.fanHumidity', req.body['fanHumidity']);
cron();
get(req, res)
}
cron();
}

View File

@ -0,0 +1,36 @@
import {ClimateService} from "../services/climateService";
import ConfigStore from 'configstore';
import {Express} from "express";
export function LightController(app: Express, config: ConfigStore, climate: ClimateService) {
const ENDPOINT = '/light'
app.get(ENDPOINT, get)
app.post(ENDPOINT, post);
app.put(ENDPOINT, put);
function get(req, res) {
let resp = {
on: climate.lightState(),
autoLight: config.get('climate.autoLight'),
lightOn: config.get('climate.lightOn'),
lightOff: config.get('climate.lightOff')
};
res.json(resp);
}
function post(req, res) {
console.log('Toggling light');
climate.toggleLight();
get(req, res);
}
function put(req, res) {
console.log('Updating light config');
console.log(req.body);
if(req.body['autoLight'] != null) config.set('climate.autoLight', req.body['autoLight']);
if(req.body['lightOn'] != null) config.set('climate.lightOn', req.body['lightOn']);
if(req.body['lightOff'] != null) config.set('climate.lightOff', req.body['lightOff']);
get(req, res)
}
}

View File

@ -0,0 +1,77 @@
import express, {Express} from 'express';
import ConfigStore from 'configstore';
import fs from 'fs';
import {environment} from "../environments/environment";
import {CameraService} from "../services/cameraService";
import {CronJob} from 'cron';
export function TimelapseController(app: Express, config: ConfigStore, camera: CameraService) {
const SAVE_DIR = environment.imageDir;
const ENDPOINT = '/timelapse'
app.use('/images', express.static(SAVE_DIR))
app.delete(ENDPOINT + '/:filename', del);
app.get(ENDPOINT, get)
app.post(ENDPOINT, post);
app.put(ENDPOINT, put);
let timelapseCron: CronJob;
function cron() {
if(config.get('camera.timelapseEnabled') == true) {
if(timelapseCron != null) {
timelapseCron.stop();
timelapseCron = null;
}
timelapseCron = new CronJob(config.get('camera.timelapseFrequency'), () => {
console.log('Snapping timelapse picture')
let date = new Date();
let image = camera.snap();
let filename = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}.jpg`;
fs.writeFileSync(`${SAVE_DIR}/${filename}`, image, 'base64');
});
timelapseCron.start();
} else if(timelapseCron != null) {
timelapseCron.stop();
timelapseCron = null;
}
}
function del(req, res) {
let filename = req.params.filename;
console.log(`Deleting ${filename}`);
fs.unlinkSync(`${SAVE_DIR}/${filename}`);
get(req, res);
}
function get(req, res) {
let resp = {
timelapseEnabled: config.get('camera.timelapseEnabled'),
timelapseFrequency: config.get('camera.timelapseFrequency'),
files: fs.readdirSync(SAVE_DIR)
};
res.json(resp);
}
function post(req, res) {
console.log('Snapping picture')
let date = new Date();
let image = camera.snap();
let filename = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}.jpg`;
fs.writeFileSync(`${SAVE_DIR}/${filename}`, image, 'base64');
get(req, res);
}
function put(req, res) {
console.log('Updating timelapse');
console.log(req.body);
if(req.body['timelapseEnabled'] != null) config.set('camera.timelapseEnabled', req.body['timelapseEnabled']);
if(req.body['timelapseFrequency'] != null) config.set('camera.timelapseFrequency', req.body['timelapseFrequency']);
cron();
get(req, res);
}
if(!fs.existsSync(SAVE_DIR)) fs.mkdirSync(SAVE_DIR);
cron();
}

View File

@ -0,0 +1,23 @@
export const environment = {
cors: 'http://localhost:4200',
imageDir: __dirname + '/../images',
port: 5000,
defaultConfig: {
camera: {
timelapseEnabled: true,
timelapseFrequency: '0 0 12 * * *' // '0 0 12 * * *'
},
climate: {
autoFan: false,
fanMode: 'time',
fanOn: null,
fanOff: null,
fanTemp: null,
fanHumidity: null,
autoLight: false,
lightOn: null,
lightOff: null,
logRate: '1m',
}
}
}

34
server/src/main.ts Normal file
View File

@ -0,0 +1,34 @@
import ConfigStore from 'configstore';
import express from 'express';
import {environment} from './environments/environment';
import {CameraService} from "./services/cameraService";
import SocketIO from 'socket.io';
import * as http from 'http';
import CORS from 'cors';
import {CameraConnectionService} from "./services/cameraConnectionService";
import {TimelapseController} from "./controllers/timelapseController";
import {ClimateService} from "./services/climateService";
import {FanController} from "./controllers/fanController";
import {LightController} from "./controllers/lightController";
// Configuration
const app = express()
const server = http.createServer(app);
const socket = SocketIO(server);
app.use(express.json());
app.use(CORS({origin: environment.cors, credentials: true}));
const config = new ConfigStore('grow-bot', environment.defaultConfig);
config.set(environment.defaultConfig);
// Services
const camera = new CameraService();
const cameraConnectionService = new CameraConnectionService(socket, camera);
const climateService = new ClimateService();
// Controllers
FanController(app, config, climateService);
LightController(app, config, climateService);
TimelapseController(app, config, camera);
// Start server
server.listen(environment.port, () => console.log(`Starting Server: http://localhost:${environment.port}`));

View File

@ -0,0 +1,17 @@
import * as fs from 'fs';
export class Image {
constructor(private image) {}
save(path: string) {
return fs.writeFileSync(path, this.image)
}
toString() {
return this.image;
}
valueOf() {
return this.image;
}
}

View File

@ -0,0 +1,22 @@
import {CameraService} from "./cameraService";
export class CameraConnectionService {
private readonly FPS = 4;
private broadcast;
constructor(private socket, private camera: CameraService) {
this.socket.on('connection', (s) => {
let address = s.request.connection.remoteAddress;
console.log(`Client Connecting: ${address}`)
})
this.beginBroadcast();
}
beginBroadcast() {
this.broadcast = setInterval(() => {
let frame = this.camera.snap();
this.socket.emit('stream', frame);
}, 1000 / this.FPS);
}
}

View File

@ -0,0 +1,17 @@
import cv from 'opencv4nodejs';
import {Image} from "../models/image";
export class CameraService {
private capture;
constructor(private resolution: [number, number] = [1920, 1080]) {
this.capture = new cv.VideoCapture(0);
this.capture.set(cv.CAP_PROP_FRAME_HEIGHT, this.resolution[0]/1); // hack to turn int into double
this.capture.set(cv.CAP_PROP_FRAME_WIDTH, this.resolution[1]/1);
}
snap() {
let frame = this.capture.read();
return cv.imencode('.jpg', frame).toString('base64');
}
}

View File

@ -0,0 +1,44 @@
export class ClimateService {
private readonly interval = 3 // Seconds
private fanStatus = false;
private lightStatus = false;
private temp = [25.0];
private humidity = [0.65];
constructor() {
setInterval(() => {
let up = Math.random() > 0.5,
lastTemp = this.temp[this.temp.length - 1],
lastHumid = this.humidity[this.humidity.length - 1];
this.temp.push(lastTemp + (up ? 1 : -1) * Math.random())
this.humidity.push(lastHumid + (up ? 1 : -1) * Math.random())
}, this.interval * 1000)
}
public fanState() {
return this.fanStatus;
}
public lightState() {
return this.lightStatus;
}
public getHumidity() {
return this.humidity;
}
public getTemp() {
return this.temp;
}
public toggleFan() {
this.fanStatus = !this.fanStatus;
return this.fanState();
}
public toggleLight() {
this.lightStatus = !this.lightStatus;
return this.lightState();
}
}

20
server/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": false,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*"]
},
"typeRoots": [
"./node_modules/@types"
],
"lib": ["es2015"]
},
"include": ["src/**/*"]
}