diff --git a/.gitignore b/.gitignore index 54c353b..dadcda3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules -npm-debug* \ No newline at end of file +npm-debug* +/dist \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b7e67d1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${workspaceRoot}/bin/www" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..c2fe27d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "0.1.0", + "command": "npm", + "isShellCommand": true, + "showOutput": "always", + "suppressTaskName": true, + "tasks": [ + { + "taskName": "install", + "args": ["install"] + }, + { + "taskName": "update", + "args": ["update"] + }, + { + "taskName": "start:dev", + "args": ["run", "start:dev"] + }, + { + "taskName": "test", + "args": ["run", "test"] + } + ] +} \ No newline at end of file diff --git a/app/images/raspberry_pi.png b/app/images/raspberry_pi.png new file mode 100644 index 0000000..72a685d Binary files /dev/null and b/app/images/raspberry_pi.png differ diff --git a/app/script/App.tsx b/app/script/App.tsx new file mode 100644 index 0000000..9d1cb09 --- /dev/null +++ b/app/script/App.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { observer } from "mobx-react"; +import { SprinklersDevice } from "./sprinklers"; +import "semantic-ui-css/semantic.min.css"; +import "app/style/app.css"; +import { Item } from "semantic-ui-react"; +import * as classNames from "classnames"; + +@observer +class Device extends React.Component<{ device: SprinklersDevice }, any> { + render() { + const {id, connected} = this.props.device; + return ( + + + + + Device {id} + + + + {connected ? "Connected" : "Disconnected"} + + + + + ); + } +} + +@observer +export default class App extends React.Component<{ device: SprinklersDevice }, any> { + render() { + return + } +} \ No newline at end of file diff --git a/app/script/index.tsx b/app/script/index.tsx new file mode 100644 index 0000000..2f312a8 --- /dev/null +++ b/app/script/index.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +import App from "./App"; +import {MqttApiClient} from "./mqtt"; + +const client = new MqttApiClient(); +client.start(); +const device = client.getDevice("grinklers"); + +const container = document.createElement("div"); +document.body.appendChild(container); + +ReactDOM.render(, container); \ No newline at end of file diff --git a/app/script/mqtt.ts b/app/script/mqtt.ts new file mode 100644 index 0000000..6cc0e9c --- /dev/null +++ b/app/script/mqtt.ts @@ -0,0 +1,120 @@ +import "paho-mqtt/mqttws31"; +/// +import MQTT = Paho.MQTT; + +import { EventEmitter } from "events"; +import { SprinklersDevice, SprinklersApi } from "./sprinklers"; + + +export class MqttApiClient extends EventEmitter implements SprinklersApi { + client: MQTT.Client + + connected: boolean + + devices: { [prefix: string]: MqttSprinklersDevice } = {}; + + constructor() { + super(); + this.client = new Paho.MQTT.Client(location.hostname, 1884, MqttApiClient.newClientId()); + this.client.onMessageArrived = m => this.onMessageArrived(m); + this.client.onConnectionLost = e => this.onConnectionLost(e); + } + + static newClientId() { + return "sprinklers3-MqttApiClient-" + Math.round(Math.random() * 1000); + } + + start() { + console.log("connecting to mqtt with client id %s", this.client.clientId); + this.client.connect({ + onFailure: (e) => { + console.log("mqtt error: ", e.errorMessage); + }, + onSuccess: () => { + console.log("mqtt connected") + this.connected = true; + for (const prefix in this.devices) { + const device = this.devices[prefix]; + device.doSubscribe(); + } + } + }) + } + + getDevice(prefix: string): SprinklersDevice { + if (!this.devices[prefix]) { + const device = this.devices[prefix] = new MqttSprinklersDevice(this, prefix); + if (this.connected) { + device.doSubscribe(); + } + } + return this.devices[prefix]; + } + + removeDevice(prefix: string) { + const device = this.devices[prefix]; + if (!device) return; + device.doUnsubscribe(); + delete this.devices[prefix]; + } + + private onMessageArrived(m: MQTT.Message) { + // console.log("message arrived: ", m) + for (const prefix in this.devices) { + const device = this.devices[prefix]; + device.onMessage(m); + } + } + + private onConnectionLost(e: MQTT.MQTTError) { + this.connected = false; + } +} + +class MqttSprinklersDevice extends SprinklersDevice { + readonly apiClient: MqttApiClient; + readonly prefix: string; + + constructor(apiClient: MqttApiClient, prefix: string) { + super(); + this.apiClient = apiClient; + this.prefix = prefix; + } + + private getSubscriptions() { + return [ + `${this.prefix}/connected` + ]; + } + + doSubscribe() { + const c = this.apiClient.client; + this.getSubscriptions() + .forEach(filter => c.subscribe(filter, { qos: 1 })); + + } + + doUnsubscribe() { + const c = this.apiClient.client; + this.getSubscriptions() + .forEach(filter => c.unsubscribe(filter)); + } + + onMessage(m: MQTT.Message) { + const postfix = m.destinationName.replace(`${this.prefix}/`, ""); + if (postfix === m.destinationName) + return; + switch (postfix) { + case "connected": + this.connected = (m.payloadString == "true"); + console.log(`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`) + break; + default: + console.warn(`MqttSprinklersDevice recieved invalid message`, m) + } + } + + get id(): string { + return this.prefix; + } +} \ No newline at end of file diff --git a/app/script/paho-mqtt.d.ts b/app/script/paho-mqtt.d.ts new file mode 100644 index 0000000..6581e1c --- /dev/null +++ b/app/script/paho-mqtt.d.ts @@ -0,0 +1,75 @@ +declare namespace Paho { + namespace MQTT { + interface MQTTError { errorCode: string, errorMessage: string } + interface WithInvocationContext { invocationContext: object } + interface ErrorWithInvocationContext extends MQTTError, WithInvocationContext {} + interface OnSubscribeSuccessParams extends WithInvocationContext { grantedQos: number } + type OnConnectionLostHandler = (error: MQTTError) => void; + type OnMessageHandler = (message: Message) => void; + interface ConnectionOptions { + timeout?: number; + userName?: string; + password?: string; + willMessage?: Message; + keepAliveInterval?: number; + cleanSession?: boolean; + useSSL?: boolean; + invocationContext?: object; + onSuccess?: (o: WithInvocationContext) => void; + mqttVersion?: number; + onFailure?: (e: ErrorWithInvocationContext) => void; + hosts?: Array; + ports?: Array; + } + interface SubscribeOptions { + qos?: number; + invocationContext?: object; + onSuccess?: (o: OnSubscribeSuccessParams) => void; + onFailure?: (e: ErrorWithInvocationContext) => void; + timeout?: number; + } + interface UnsubscribeOptions { + invocationContext?: object; + onSuccess?: (o: WithInvocationContext) => void; + onFailure?: (e: ErrorWithInvocationContext) => void; + timeout?: number; + } + class Client { + + constructor(host: string, port: number, path: string, clientId: string); + constructor(host: string, port: number, clientId: string); + constructor(hostUri: string, clientId: string); + + readonly clientId: string; + readonly host: string; + readonly path: string; + readonly port: number; + + onConnectionLost: OnConnectionLostHandler; + onMessageArrived: OnMessageHandler; + onMessageDelivered: OnMessageHandler; + + connect(connectionOptions?: ConnectionOptions); + disconnect(); + + getTraceLog(): Object[]; + startTrace(); + stopTrace(); + + send(message: Message); + subscribe(filter: string, subcribeOptions?: SubscribeOptions); + unsubscribe(filter: string, unsubcribeOptions?: UnsubscribeOptions); + } + + class Message { + constructor(payload: String | ArrayBuffer); + + destinationName: string; + readonly duplicate: boolean; + readonly payloadBytes: ArrayBuffer; + readonly payloadString: string; + qos: number; + retained: boolean; + } + } +} diff --git a/app/script/sprinklers.ts b/app/script/sprinklers.ts new file mode 100644 index 0000000..12bc8dd --- /dev/null +++ b/app/script/sprinklers.ts @@ -0,0 +1,67 @@ +import { observable } from "mobx"; + +class Section { + @observable + name: string = "" + + @observable + state: boolean = false +} + +class TimeOfDay { + hour: number + minute: number + second: number + millisecond: number + +} + +enum Weekday { + Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday +} + +class Schedule { + times: TimeOfDay[] = []; + weekdays: Weekday[] = []; + from?: Date = null; + to?: Date = null; +} + +class ProgramItem { + section: number = -1; + // duration in milliseconds + duration: number = 0; +} + +class Program { + @observable + name: string = "" + @observable + enabled: boolean = false + + @observable + schedule: Schedule = new Schedule() + + @observable + sequence: Array = []; +} + +export abstract class SprinklersDevice { + @observable + connected: boolean = false; + + @observable + sections: Array
= []; + + @observable + programs: Array = []; + + abstract get id(): string; +} + +export interface SprinklersApi { + start(); + getDevice(id: string) : SprinklersDevice; + + removeDevice(id: string) +} \ No newline at end of file diff --git a/app/style/app.css b/app/style/app.css new file mode 100644 index 0000000..e827e6d --- /dev/null +++ b/app/style/app.css @@ -0,0 +1,7 @@ +.device--connected { + color: #00FF00; +} + +.device--disconnected { + color: #FF0000; +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..a505dbb --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require("./built/") \ No newline at end of file diff --git a/package.json b/package.json index 97ae421..b7af761 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "description": "A frontend for mqtt based IoT sprinklers systems", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "clean": "rm -rf ./dist", + "build": "webpack", + "start:dev": "webpack-dev-server --content-base ./dist/" }, "repository": { "type": "git", @@ -16,5 +19,31 @@ "bugs": { "url": "https://github.com/amikhalev/sprinklers3/issues" }, - "homepage": "https://github.com/amikhalev/sprinklers3#readme" + "homepage": "https://github.com/amikhalev/sprinklers3#readme", + "dependencies": { + "@types/classnames": "0.0.32", + "@types/node": "^7.0.13", + "@types/react": "^15.0.23", + "@types/react-dom": "^15.5.0", + "classnames": "^2.2.5", + "mobx": "^3.1.9", + "mobx-react": "^4.1.8", + "paho-mqtt": "^1.0.3", + "react": "^15.5.4", + "react-dom": "^15.5.4", + "semantic-ui-css": "^2.2.10", + "semantic-ui-react": "^0.67.0" + }, + "devDependencies": { + "awesome-typescript-loader": "^3.1.3", + "css-loader": "^0.28.0", + "file-loader": "^0.11.1", + "html-webpack-plugin": "^2.28.0", + "source-map-loader": "^0.2.1", + "style-loader": "^0.17.0", + "ts-loader": "^2.0.3", + "typescript": "^2.3.1", + "webpack": "^2.4.1", + "webpack-dev-server": "^2.4.4" + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a3d3274 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "sourceMap": true, + "jsx": "react", + "experimentalDecorators": true, + "target": "es5", + "typeRoots": ["node_modules/@types"] + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..c65637c --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,30 @@ +const path = require("path"); +const webpack = require("webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./app/script/index.tsx", + devtool: "sourcemap", + output: { + path: path.resolve(__dirname, "dist"), + filename: "bundle.js" + }, + resolve: { + extensions: [".ts", ".tsx", ".js"], + alias: { + app: path.resolve("./app") + } + }, + module: { + rules: [ + { test: /\.tsx?$/, loader: "awesome-typescript-loader" }, + { test: /\.css$/, loader: "style-loader!css-loader" }, + { test: /\.(ttf|eot|svg|woff(2)?|png|jpg)(\?[a-z0-9=&.]+)?$/, loader: "file-loader" } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + title: "sprinklers3" + }) + ] +}; \ No newline at end of file