This commit is contained in:
2023-08-14 13:49:06 -04:00
commit aa0f98e742
84 changed files with 19695 additions and 0 deletions
+16
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
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
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
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
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
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
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
View File
File diff suppressed because it is too large Load Diff
+53
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"
}
}
+50
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}),
])
])
]);
+27
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 { }
+67
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
View File
@@ -0,0 +1,5 @@
@import 'node_modules/bootstrap-scss/bootstrap';
.text-primary {
color: #689f38 !important
}
@@ -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>
@@ -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;
}
}
@@ -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);
}
}
@@ -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>
@@ -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)
}
}
}
@@ -0,0 +1,3 @@
<button mat-mini-fab [color]="climateService.fanConfig.on ? 'primary' : ''" (click)="climateService.toggleFan()">
<mat-icon>power_settings_new</mat-icon>
</button>
@@ -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) { }
}
@@ -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>
@@ -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)
}
}
}
@@ -0,0 +1,3 @@
<button mat-mini-fab [color]="climateService.lightConfig.on ? 'primary' : ''" (click)="climateService.toggleLight()">
<mat-icon>power_settings_new</mat-icon>
</button>
@@ -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) { }
}
@@ -0,0 +1 @@
<img [ngClass]="{'glow': glow}" src="/assets/images/logo.png" alt="GrowBot">
@@ -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);}
}
@@ -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;
}
@@ -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>
@@ -0,0 +1,4 @@
.active {
background-color: #689f38;
color: #ffffff;
}
@@ -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[] = [];
}
@@ -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>
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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>
@@ -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) { }
}
+43
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 {
}
+99
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;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export interface MenuItem {
class?: string;
icon?: string;
link?: string;
text: string;
sub?: MenuItem[];
}
+71
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');
}
}
@@ -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());
}
}
+30
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));
}
});
};
}
+29
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>
+12
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);
}
+61
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;
}
}
@@ -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>
@@ -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;
}
}
@@ -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>
@@ -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 { }
@@ -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>
@@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent{ }
@@ -0,0 +1 @@
<stream [fullscreen]="true"></stream>
@@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'fullscreen-camera',
templateUrl: `./fullscreenCamera.component.html`,
})
export class FullscreenCameraComponent { }
@@ -0,0 +1,9 @@
import {Component} from "@angular/core";
@Component({
selector: 'grow-ops',
templateUrl: 'growOps.component.html'
})
export class GrowOpsComponent {
constructor() { }
}
@@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'notes',
templateUrl: './notes.component.html'
})
export class NotesComponent { }
@@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'schedule',
templateUrl: 'schedule.component.html'
})
export class ScheduleComponent { }
@@ -0,0 +1,7 @@
import {Component} from "@angular/core";
@Component({
selector: 'settings',
templateUrl: 'settings.component.html'
})
export class SettingsComponent { }
@@ -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

@@ -0,0 +1,3 @@
export const environment = {
production: true
};
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
api: 'http://localhost:5000',
production: false
};
+24
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
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
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
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
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
View File
@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store
+3881
View File
File diff suppressed because it is too large Load Diff
+30
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"
}
}
+61
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();
}
+36
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)
}
}
@@ -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();
}
+23
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
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}`));
+17
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;
}
}
@@ -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);
}
}
+17
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');
}
}
+44
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
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/**/*"]
}