diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3ccf9d1..246d755 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,27 +1,28 @@ { // 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, + "version": "2.0.0", "tasks": [ { - "taskName": "install", - "args": ["install"] - }, - { - "taskName": "update", - "args": ["update"] - }, - { - "taskName": "start", - "args": ["run", "start"] - }, - { - "taskName": "test", - "args": ["run", "test"] + "type": "npm", + "script": "start", + "problemMatcher": { + "owner": "webpack", + "severity": "error", + "fileLocation": "relative", + "pattern": [ + { + "regexp": "ERROR in (.*)", + "file": 1 + }, + { + "regexp": "\\((\\d+),(\\d+)\\):(.*)", + "line": 1, + "column": 2, + "message": 3 + } + ] + } } ] } \ No newline at end of file diff --git a/app/script/components/DeviceView.tsx b/app/script/components/DeviceView.tsx index 8c6470c..2671709 100644 --- a/app/script/components/DeviceView.tsx +++ b/app/script/components/DeviceView.tsx @@ -1,19 +1,19 @@ import * as classNames from "classnames"; -import {observer} from "mobx-react"; +import { observer } from "mobx-react"; import * as React from "react"; -import {Header, Item} from "semantic-ui-react"; -import {ProgramTable, RunSectionForm, SectionTable, SectionRunnerView} from "."; +import { Header, Item } from "semantic-ui-react"; +import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from "."; -import {SprinklersDevice} from "../sprinklers"; +import { SprinklersDevice } from "../sprinklers"; import FontAwesome = require("react-fontawesome"); -const ConnectionState = ({connected}: { connected: boolean }) => +const ConnectionState = ({ connected }: { connected: boolean }) => - +   {connected ? "Connected" : "Disconnected"} ; @@ -21,22 +21,22 @@ const ConnectionState = ({connected}: { connected: boolean }) => @observer export default class DeviceView extends React.PureComponent<{ device: SprinklersDevice }, {}> { render() { - const {id, connected, sections, programs, sectionRunner} = this.props.device; + const { id, connected, sections, programs, sectionRunner } = this.props.device; return ( - ("app/images/raspberry_pi.png")}/> + ("app/images/raspberry_pi.png")} />
Device {id} - +
- - - - + + + +
); diff --git a/app/script/components/DurationInput.tsx b/app/script/components/DurationInput.tsx index 198d140..c1da564 100644 --- a/app/script/components/DurationInput.tsx +++ b/app/script/components/DurationInput.tsx @@ -1,11 +1,11 @@ import * as React from "react"; -import {Input} from "semantic-ui-react"; -import {Duration} from "../sprinklers"; +import { Input, InputOnChangeData } from "semantic-ui-react"; +import { Duration } from "../sprinklers"; export default class DurationInput extends React.Component<{ duration: Duration, - onDurationChange?: (newDuration: Duration) => void; -}, {}> { + onDurationChange: (newDuration: Duration) => void; +}> { render() { const duration = this.props.duration; // const editing = this.props.onDurationChange != null; @@ -13,25 +13,25 @@ export default class DurationInput extends React.Component<{
+ value={duration.minutes} onChange={this.onMinutesChange} + label="M" labelPosition="right" /> + value={duration.seconds} onChange={this.onSecondsChange} max="60" + label="S" labelPosition="right" />
; } - private onMinutesChange = (e, {value}) => { - if (value.length === 0 || isNaN(value)) { + private onMinutesChange = (e: React.SyntheticEvent, { value }: InputOnChangeData) => { + if (value.length === 0 || isNaN(Number(value))) { return; } const newMinutes = parseInt(value, 10); this.props.onDurationChange(this.props.duration.withMinutes(newMinutes)); } - private onSecondsChange = (e, {value}) => { - if (value.length === 0 || isNaN(value)) { + private onSecondsChange = (e: React.SyntheticEvent, { value }: InputOnChangeData) => { + if (value.length === 0 || isNaN(Number(value))) { return; } const newSeconds = parseInt(value, 10); diff --git a/app/script/components/ProgramTable.tsx b/app/script/components/ProgramTable.tsx index 531d50c..2f867d9 100644 --- a/app/script/components/ProgramTable.tsx +++ b/app/script/components/ProgramTable.tsx @@ -14,7 +14,7 @@ export class ScheduleView extends React.PureComponent<{ schedule: Schedule }, {} @observer export default class ProgramTable extends React.PureComponent<{ programs: Program[] }, {}> { - private static renderRow(program: Program, i: number): JSX.Element[] { + private static renderRows(program: Program, i: number): JSX.Element[] | null { if (!program) { return null; } @@ -57,7 +57,7 @@ export default class ProgramTable extends React.PureComponent<{ programs: Progra { - Array.prototype.concat.apply([], this.props.programs.map(ProgramTable.renderRow)) + Array.prototype.concat.apply([], this.props.programs.map(ProgramTable.renderRows)) } diff --git a/app/script/components/RunSectionForm.tsx b/app/script/components/RunSectionForm.tsx index 6ff15c9..b4a6d82 100644 --- a/app/script/components/RunSectionForm.tsx +++ b/app/script/components/RunSectionForm.tsx @@ -1,7 +1,6 @@ import {computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {SyntheticEvent} from "react"; import {DropdownItemProps, DropdownProps, Form, Header, Segment} from "semantic-ui-react"; import {DurationInput} from "."; import {Duration, Section} from "../sprinklers"; @@ -37,7 +36,7 @@ export default class RunSectionForm extends React.Component<{ ; } - private onSectionChange = (e: SyntheticEvent, v: DropdownProps) => { + private onSectionChange = (e: React.SyntheticEvent, v: DropdownProps) => { this.setState({section: v.value as number}); } @@ -45,10 +44,13 @@ export default class RunSectionForm extends React.Component<{ this.setState({duration: newDuration}); } - private run = (e: SyntheticEvent) => { + private run = (e: React.SyntheticEvent) => { e.preventDefault(); + if (typeof this.state.section !== "number") { + return; + } const section: Section = this.props.sections[this.state.section]; - console.log(`should run section ${section} for ${this.state.duration}`); + console.log(`running section ${section} for ${this.state.duration}`); section.run(this.state.duration) .then((a) => console.log("ran section", a)) .catch((err) => console.error("error running section", err)); diff --git a/app/script/index.tsx b/app/script/index.tsx index 0202a3c..e8607c5 100644 --- a/app/script/index.tsx +++ b/app/script/index.tsx @@ -1,10 +1,10 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import {AppContainer} from "react-hot-loader"; +import { AppContainer } from "react-hot-loader"; import App from "./components/App"; -import {MqttApiClient} from "./mqtt"; -import {Message, UiStore} from "./ui"; +import { MqttApiClient } from "./mqtt"; +import { Message, UiStore } from "./ui"; const client = new MqttApiClient(); client.start(); @@ -15,14 +15,14 @@ uiStore.addMessage(new Message("asdf", "boo!", Message.Type.Error)); const rootElem = document.getElementById("app"); ReactDOM.render( - + , rootElem); if (module.hot) { module.hot.accept("./components/App", () => { const NextApp = require("./components/App").default as typeof App; ReactDOM.render( - + , rootElem); }); } diff --git a/app/script/mqtt.ts b/app/script/mqtt.ts index ecda84f..8dbc11a 100644 --- a/app/script/mqtt.ts +++ b/app/script/mqtt.ts @@ -1,26 +1,26 @@ -import {EventEmitter} from "events"; +import { EventEmitter } from "events"; import "paho-mqtt"; import { Duration, - ISectionRun, ISprinklersApi, - ITimeOfDay, Program, + ProgramItem, Schedule, Section, + SectionRun, SectionRunner, SprinklersDevice, + TimeOfDay, } from "./sprinklers"; -import {checkedIndexOf} from "./utils"; +import { checkedIndexOf } from "./utils"; import MQTT = Paho.MQTT; -export class MqttApiClient extends EventEmitter implements ISprinklersApi { +export class MqttApiClient implements ISprinklersApi { client: MQTT.Client; connected: boolean; devices: { [prefix: string]: MqttSprinklersDevice } = {}; constructor() { - super(); this.client = new MQTT.Client(location.hostname, 1884, MqttApiClient.newClientId()); this.client.onMessageArrived = (m) => this.onMessageArrived(m); this.client.onConnectionLost = (e) => this.onConnectionLost(e); @@ -80,6 +80,10 @@ export class MqttApiClient extends EventEmitter implements ISprinklersApi { private processMessage(m: MQTT.Message) { // console.log("message arrived: ", m); + if (m.destinationName == null) { + console.warn(`revieved invalid message: ${m}`); + return; + } const topicIdx = m.destinationName.indexOf("/"); // find the first / const prefix = m.destinationName.substr(0, topicIdx); // assume prefix does not contain a / const topic = m.destinationName.substr(topicIdx + 1); @@ -130,7 +134,7 @@ class MqttSprinklersDevice extends SprinklersDevice { doSubscribe() { const c = this.apiClient.client; this.subscriptions - .forEach((filter) => c.subscribe(filter, {qos: 1})); + .forEach((filter) => c.subscribe(filter, { qos: 1 })); } doUnsubscribe() { @@ -186,7 +190,7 @@ class MqttSprinklersDevice extends SprinklersDevice { } matches = topic.match(/^section_runner$/); if (matches != null) { - (this.sectionRunner as MqttSectionRunner).onMessage(null, payload); + (this.sectionRunner as MqttSectionRunner).onMessage(payload); return; } matches = topic.match(/^responses\/(\d+)$/); @@ -219,7 +223,15 @@ class MqttSprinklersDevice extends SprinklersDevice { } cancelSectionRunById(id: number) { - return this.makeRequest(`section_runner/cancel_id`, {id}); + return this.makeRequest(`section_runner/cancel_id`, { id }); + } + + pauseSectionRunner() { + return this.makeRequest(`section_runner/pause`); + } + + unpauseSectionRunner() { + return this.makeRequest(`section_runner/unpause`); } //noinspection JSMethodCanBeStatic @@ -227,7 +239,7 @@ class MqttSprinklersDevice extends SprinklersDevice { return Math.floor(Math.random() * 1000000000); } - private makeRequest(topic: string, payload: object | string): Promise { + private makeRequest(topic: string, payload: object | string = {}): Promise { return new Promise((resolve, reject) => { const payloadStr = (typeof payload === "string") ? payload : JSON.stringify(payload); @@ -254,7 +266,7 @@ interface IResponseData { [key: string]: any; } -type ResponseCallback = (IResponseData) => void; +type ResponseCallback = (data: IResponseData) => void; interface ISectionJSON { name: string; @@ -276,8 +288,19 @@ class MqttSection extends Section { } } +interface ITimeOfDayJSON { + hour: number; + minute: number; + second: number; + millisecond: number; +} + +function timeOfDayFromJSON(json: ITimeOfDayJSON): TimeOfDay { + return new TimeOfDay(json.hour, json.minute, json.second, json.millisecond); +} + interface IScheduleJSON { - times: ITimeOfDay[]; + times: ITimeOfDayJSON[]; weekdays: number[]; from?: string; to?: string; @@ -285,7 +308,7 @@ interface IScheduleJSON { function scheduleFromJSON(json: IScheduleJSON): Schedule { const sched = new Schedule(); - sched.times = json.times; + sched.times = json.times.map(timeOfDayFromJSON); sched.weekdays = json.weekdays; sched.from = json.from == null ? null : new Date(json.from); sched.to = json.to == null ? null : new Date(json.to); @@ -322,11 +345,10 @@ class MqttProgram extends Program { this.enabled = json.enabled; } if (json.sequence != null) { - // tslint:disable:object-literal-sort-keys - this.sequence = json.sequence.map((item) => ({ - section: item.section, - duration: Duration.fromSeconds(item.duration), - })); + this.sequence = json.sequence.map((item) => (new ProgramItem( + item.section, + Duration.fromSeconds(item.duration), + ))); } if (json.sched != null) { this.schedule = scheduleFromJSON(json.sched); @@ -334,23 +356,42 @@ class MqttProgram extends Program { } } +export interface ISectionRunJSON { + id: number; + section: number; + duration: number; + startTime?: number; + pauseTime?: number; +} + +function sectionRunFromJSON(json: ISectionRunJSON) { + const run = new SectionRun(); + run.id = json.id; + run.section = json.section; + run.duration = Duration.fromSeconds(json.duration); + run.startTime = json.startTime == null ? null : new Date(json.startTime); + run.pauseTime = json.pauseTime == null ? null : new Date(json.pauseTime); + return run; +} + interface ISectionRunnerJSON { - queue: ISectionRun[]; - current?: ISectionRun; + queue: ISectionRunJSON[]; + current: ISectionRunJSON | null; + paused: boolean; } class MqttSectionRunner extends SectionRunner { - onMessage(topic: string, payload: string) { + onMessage(payload: string) { const json = JSON.parse(payload) as ISectionRunnerJSON; this.updateFromJSON(json); } updateFromJSON(json: ISectionRunnerJSON) { - if (!json.queue) { // null means empty queue + if (!json.queue || !json.queue.length) { // null means empty queue this.queue.clear(); } else { - this.queue.replace(json.queue); + this.queue.replace(json.queue.map(sectionRunFromJSON)); } - this.current = json.current; + this.current = json.current == null ? null : sectionRunFromJSON(json.current); } } diff --git a/app/script/sprinklers.ts b/app/script/sprinklers.ts index 403d733..d7900fc 100644 --- a/app/script/sprinklers.ts +++ b/app/script/sprinklers.ts @@ -1,4 +1,4 @@ -import {IObservableArray, observable} from "mobx"; +import { IObservableArray, observable } from "mobx"; export abstract class Section { device: SprinklersDevice; @@ -22,11 +22,22 @@ export abstract class Section { } } -export interface ITimeOfDay { +export class TimeOfDay { hour: number; minute: number; second: number; millisecond: number; + + constructor(hour: number, minute: number = 0, second: number = 0, millisecond: number = 0) { + this.hour = hour; + this.minute = minute; + this.second = second; + this.millisecond = millisecond; + } + + static fromDate(date: Date): TimeOfDay { + return new TimeOfDay(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + } } export enum Weekday { @@ -34,17 +45,17 @@ export enum Weekday { } export class Schedule { - times: ITimeOfDay[] = []; + times: TimeOfDay[] = []; weekdays: Weekday[] = []; - from?: Date = null; - to?: Date = null; + from: Date | null = null; + to: Date | null = null; } export class Duration { minutes: number = 0; seconds: number = 0; - constructor(minutes: number, seconds: number) { + constructor(minutes: number = 0, seconds: number = 0) { this.minutes = minutes; this.seconds = seconds; } @@ -82,11 +93,16 @@ export class Duration { } } -export interface IProgramItem { +export class ProgramItem { // the section number section: number; // duration of the run duration: Duration; + + constructor(section: number, duration: Duration) { + this.section = section; + this.duration = duration; + } } export class Program { @@ -101,7 +117,7 @@ export class Program { schedule: Schedule = new Schedule(); @observable - sequence: IProgramItem[] = []; + sequence: ProgramItem[] = []; @observable running: boolean = false; @@ -120,21 +136,38 @@ export class Program { } } -export interface ISectionRun { +export class SectionRun { id: number; section: number; - duration: number; - startTime?: Date; + duration: Duration; + startTime: Date | null; + pauseTime: Date | null; + + constructor(id: number = 0, section: number = 0, duration: Duration = new Duration()) { + this.id = id; + this.section = section; + this.duration = duration; + this.startTime = null; + this.pauseTime = null; + } + + toString() { + return `SectionRun{id=${this.id}, section=${this.section}, duration=${this.duration},` + + ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`; + } } export class SectionRunner { device: SprinklersDevice; @observable - queue: IObservableArray = observable([]); + queue: IObservableArray = observable([]); @observable - current: ISectionRun = null; + current: SectionRun | null = null; + + @observable + paused: boolean = false; constructor(device: SprinklersDevice) { this.device = device; @@ -145,7 +178,7 @@ export class SectionRunner { } toString(): string { - return `SectionRunner{queue="${this.queue}", current="${this.current}"}`; + return `SectionRunner{queue="${this.queue}", current="${this.current}", paused=${this.paused}}`; } } @@ -154,10 +187,10 @@ export abstract class SprinklersDevice { connected: boolean = false; @observable - sections: IObservableArray
= [] as IObservableArray
; + sections: IObservableArray
= observable.array
(); @observable - programs: IObservableArray = [] as IObservableArray; + programs: IObservableArray = observable.array(); @observable sectionRunner: SectionRunner; @@ -169,12 +202,16 @@ export abstract class SprinklersDevice { abstract runProgram(program: number | Program): Promise<{}>; abstract cancelSectionRunById(id: number): Promise<{}>; + + abstract pauseSectionRunner(): Promise<{}>; + + abstract unpauseSectionRunner(): Promise<{}>; } export interface ISprinklersApi { - start(); + start(): void; getDevice(id: string): SprinklersDevice; - removeDevice(id: string); + removeDevice(id: string): void; } diff --git a/package.json b/package.json index d698612..cd833bb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "clean": "rm -rf ./dist ./build", "build": "webpack --config ./webpack/prod.config.js", "start": "webpack-dev-server --config ./webpack/dev.config.js", - "lint": "tslint \"app/script/**/*\" --force" + "lint": "tslint --project . --force" }, "repository": { "type": "git", @@ -29,6 +29,7 @@ "@types/react": "^16", "@types/react-dom": "^15.5.0", "@types/react-fontawesome": "^1.5.0", + "@types/react-hot-loader": "^3.0.4", "@types/react-transition-group": "^1.1.1", "classnames": "^2.2.5", "core-js": "^2.4.1", diff --git a/tsconfig.json b/tsconfig.json index 88d4781..caae317 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "experimentalDecorators": true, "target": "es5", "lib": ["es6", "dom"], - "typeRoots": ["node_modules/@types"] + "typeRoots": ["node_modules/@types"], + "strict": true }, "files": [ "./node_modules/@types/webpack-env/index.d.ts", diff --git a/tslint.json b/tslint.json index c05a219..1c0575b 100644 --- a/tslint.json +++ b/tslint.json @@ -26,6 +26,13 @@ { "order": "fields-first" } + ], + "object-literal-sort-keys": [ + false + ], + "no-submodule-imports": false, + "no-unused-variable": [ + true ] }, "rulesDirectory": [] diff --git a/yarn.lock b/yarn.lock index 34ff7c9..0333983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,6 +34,12 @@ dependencies: "@types/react" "*" +"@types/react-hot-loader@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/react-hot-loader/-/react-hot-loader-3.0.4.tgz#7fc081509830c64218d8a99a865e2fb4a94572ad" + dependencies: + "@types/react" "*" + "@types/react-transition-group@^1.1.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-1.1.2.tgz#a349e70788a6dc723a5f439217011ef926c27b4f"