diff --git a/.drone.yml b/.drone.yml index 739c08e..9f4fa58 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,4 +4,4 @@ pipeline: commands: - apk add yarn python make g++ - yarn install --frozen-lockfile - - yarn build \ No newline at end of file + - yarn build diff --git a/README.md b/README.md index 1b8260f..7e8e184 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,16 @@ ### Docker ```shell - # for production build (http://localhost:8080) docker-compose up # for development with hot-reload (http://localhost:8081) docker-compose -f docker-compose.dev.yml up - ``` + ### Not docker ```shell - yarn install # for production build (http://localhost:8080) @@ -25,6 +23,4 @@ yarn start:pretty # for development build (http://localhost:8081) yarn start:dev - - -``` \ No newline at end of file +``` diff --git a/build.sh b/build.sh index 82739b8..644f000 100755 --- a/build.sh +++ b/build.sh @@ -23,4 +23,4 @@ docker container cp "$EXTRACT_CONTAINER:/app/public" "$BUILD_DIR/public" docker container rm -f "$EXTRACT_CONTAINER" echo "Building dist image $DIST_IMAGE" -docker build -t "$DIST_IMAGE" . \ No newline at end of file +docker build -t "$DIST_IMAGE" . diff --git a/client/App.tsx b/client/App.tsx index 29885f8..5a0ad23 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -13,29 +13,29 @@ import "semantic-ui-css/semantic.css"; import "@client/styles/app"; function NavContainer() { - return ( - - + return ( + + - - - - - - - + + + + + + + - - - ); + + + ); } export default function App() { - return ( - - - - - - ); + return ( + + + + + + ); } diff --git a/client/components/DeviceImage.tsx b/client/components/DeviceImage.tsx index ad444f0..4208fe4 100644 --- a/client/components/DeviceImage.tsx +++ b/client/components/DeviceImage.tsx @@ -2,5 +2,7 @@ import * as React from "react"; import { Item, ItemImageProps } from "semantic-ui-react"; export default function DeviceImage(props: ItemImageProps) { - return ; + return ( + + ); } diff --git a/client/components/DeviceView.tsx b/client/components/DeviceView.tsx index 724c180..bbf553d 100644 --- a/client/components/DeviceView.tsx +++ b/client/components/DeviceView.tsx @@ -9,146 +9,182 @@ import * as p from "@client/pages"; import * as route from "@client/routePaths"; import { AppState, injectState } from "@client/state"; import { ISprinklersDevice } from "@common/httpApi"; -import { ConnectionState as ConState, SprinklersDevice } from "@common/sprinklersRpc"; +import { + ConnectionState as ConState, + SprinklersDevice +} from "@common/sprinklersRpc"; import { Route, RouteComponentProps, withRouter } from "react-router"; -import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from "."; +import { + ProgramTable, + RunSectionForm, + SectionRunnerView, + SectionTable +} from "."; import "@client/styles/DeviceView"; -const ConnectionState = observer(({ connectionState, className }: - { connectionState: ConState, className?: string }) => { +const ConnectionState = observer( + ({ + connectionState, + className + }: { + connectionState: ConState; + className?: string; + }) => { const connected = connectionState.isDeviceConnected; let connectionText: string; let iconName: SemanticICONS = "unlinkify"; let clazzName: string = "disconnected"; if (connected) { - connectionText = "Connected"; - iconName = "linkify"; - clazzName = "connected"; + connectionText = "Connected"; + iconName = "linkify"; + clazzName = "connected"; } else if (connectionState.noPermission) { - connectionText = "No permission for this device"; - iconName = "ban"; + connectionText = "No permission for this device"; + iconName = "ban"; } else if (connected === false) { - connectionText = "Device Disconnected"; + connectionText = "Device Disconnected"; } else if (connectionState.clientToServer === false) { - connectionText = "Disconnected from server"; + connectionText = "Disconnected from server"; } else { - connectionText = "Unknown"; - iconName = "question"; - clazzName = "unknown"; + connectionText = "Unknown"; + iconName = "question"; + clazzName = "unknown"; } const classes = classNames("connectionState", clazzName, className); return ( -
-   - {connectionText} -
+
+ +   + {connectionText} +
); -}); + } +); interface DeviceViewProps { - deviceId: number; - appState: AppState; - inList?: boolean; + deviceId: number; + appState: AppState; + inList?: boolean; } class DeviceView extends React.Component { - deviceInfo: ISprinklersDevice | null = null; - device: SprinklersDevice | null = null; + deviceInfo: ISprinklersDevice | null = null; + device: SprinklersDevice | null = null; - componentWillUnmount() { - if (this.device) { - this.device.release(); - } + componentWillUnmount() { + if (this.device) { + this.device.release(); } + } - renderBody() { - const { inList, appState: { uiStore, routerStore } } = this.props; - if (!this.deviceInfo || !this.device) { - return null; - } - const { connectionState, sectionRunner, sections } = this.device; - if (!connectionState.isAvailable || inList) { - return null; - } - return ( - - - - - - - - - - - - - - - - ); + renderBody() { + const { + inList, + appState: { uiStore, routerStore } + } = this.props; + if (!this.deviceInfo || !this.device) { + return null; + } + const { connectionState, sectionRunner, sections } = this.device; + if (!connectionState.isAvailable || inList) { + return null; } + return ( + + + + + + + + + + + + + + + + ); + } - updateDevice() { - const { userStore, sprinklersRpc } = this.props.appState; - const id = this.props.deviceId; - // tslint:disable-next-line:prefer-conditional-expression - if (this.deviceInfo == null || this.deviceInfo.id !== id) { - this.deviceInfo = userStore.findDevice(id); - } - if (!this.deviceInfo || !this.deviceInfo.deviceId) { - if (this.device) { - this.device.release(); - this.device = null; - } - } else { - if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { - if (this.device) { - this.device.release(); - } - this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId); - } + updateDevice() { + const { userStore, sprinklersRpc } = this.props.appState; + const id = this.props.deviceId; + // tslint:disable-next-line:prefer-conditional-expression + if (this.deviceInfo == null || this.deviceInfo.id !== id) { + this.deviceInfo = userStore.findDevice(id); + } + if (!this.deviceInfo || !this.deviceInfo.deviceId) { + if (this.device) { + this.device.release(); + this.device = null; + } + } else { + if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { + if (this.device) { + this.device.release(); } + this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId); + } } + } - render() { - this.updateDevice(); - const { inList } = this.props; - let itemContent: React.ReactNode; - if (!this.deviceInfo || !this.device) { - // TODO: better and link back to devices list - itemContent = You do not have access to this device; - } else { - const { connectionState } = this.device; - let header: React.ReactNode; - let image: React.ReactNode; - if (inList) { // tslint:disable-line:prefer-conditional-expression - const devicePath = route.device(this.deviceInfo.id); - header = Device {this.deviceInfo.name}; - image = ; - } else { - header = Device {this.deviceInfo.name}; - image = ; - } - itemContent = ( - - {image} - -
- {header} - -
- - Raspberry Pi Grinklers Device - - {this.renderBody()} -
-
- ); - } - return {itemContent}; + render() { + this.updateDevice(); + const { inList } = this.props; + let itemContent: React.ReactNode; + if (!this.deviceInfo || !this.device) { + // TODO: better and link back to devices list + itemContent = You do not have access to this device; + } else { + const { connectionState } = this.device; + let header: React.ReactNode; + let image: React.ReactNode; + if (inList) { + // tslint:disable-line:prefer-conditional-expression + const devicePath = route.device(this.deviceInfo.id); + header = ( + + Device {this.deviceInfo.name} + + ); + image = ; + } else { + header = ( + + Device {this.deviceInfo.name} + + ); + image = ; + } + itemContent = ( + + {image} + +
+ {header} + +
+ Raspberry Pi Grinklers Device + {this.renderBody()} +
+
+ ); } + return {itemContent}; + } } export default injectState(observer(DeviceView)); diff --git a/client/components/DurationView.tsx b/client/components/DurationView.tsx index 16e1aaf..0a16170 100644 --- a/client/components/DurationView.tsx +++ b/client/components/DurationView.tsx @@ -7,70 +7,71 @@ import { Duration } from "@common/Duration"; import "@client/styles/DurationView"; export default class DurationView extends React.Component<{ - label?: string, - inline?: boolean, - duration: Duration, - onDurationChange?: (newDuration: Duration) => void, - className?: string, + label?: string; + inline?: boolean; + duration: Duration; + onDurationChange?: (newDuration: Duration) => void; + className?: string; }> { - render() { - const { duration, label, inline, onDurationChange, className } = this.props; - const inputsClassName = classNames("durationInputs", { inline }); - if (onDurationChange) { - return ( - - - {label && } -
- - -
-
-
- ); - } else { - return ( - - {label && } {duration.minutes}M {duration.seconds}S - - ); - } + render() { + const { duration, label, inline, onDurationChange, className } = this.props; + const inputsClassName = classNames("durationInputs", { inline }); + if (onDurationChange) { + return ( + + + {label && } +
+ + +
+
+
+ ); + } else { + return ( + + {label && } {duration.minutes}M{" "} + {duration.seconds}S + + ); } + } - private onMinutesChange: InputProps["onChange"] = (e, { value }) => { - if (!this.props.onDurationChange || isNaN(Number(value))) { - return; - } - const newMinutes = Number(value); - this.props.onDurationChange(this.props.duration.withMinutes(newMinutes)); + private onMinutesChange: InputProps["onChange"] = (e, { value }) => { + if (!this.props.onDurationChange || isNaN(Number(value))) { + return; } + const newMinutes = Number(value); + this.props.onDurationChange(this.props.duration.withMinutes(newMinutes)); + }; - private onSecondsChange: InputProps["onChange"] = (e, { value }) => { - if (!this.props.onDurationChange || isNaN(Number(value))) { - return; - } - const newSeconds = Number(value); - this.props.onDurationChange(this.props.duration.withSeconds(newSeconds)); + private onSecondsChange: InputProps["onChange"] = (e, { value }) => { + if (!this.props.onDurationChange || isNaN(Number(value))) { + return; } + const newSeconds = Number(value); + this.props.onDurationChange(this.props.duration.withSeconds(newSeconds)); + }; - private onWheel = () => { - // do nothing - } + private onWheel = () => { + // do nothing + }; } diff --git a/client/components/MessagesView.tsx b/client/components/MessagesView.tsx index 888d35e..b023ba5 100644 --- a/client/components/MessagesView.tsx +++ b/client/components/MessagesView.tsx @@ -9,45 +9,49 @@ import "@client/styles/MessagesView"; @observer class MessageView extends React.Component<{ - uiStore: UiStore, - message: UiMessage, - className?: string, + uiStore: UiStore; + message: UiMessage; + className?: string; }> { + render() { + const { id, ...messageProps } = this.props.message; + const className = classNames(messageProps.className, this.props.className); + return ( + + ); + } - render() { - const { id, ...messageProps } = this.props.message; - const className = classNames(messageProps.className, this.props.className); - return ( - - ); - } - - private dismiss: MessageProps["onDismiss"] = (event, data) => { - const { uiStore, message } = this.props; - if (message.onDismiss) { - message.onDismiss(event, data); - } - uiStore.messages.remove(message); + private dismiss: MessageProps["onDismiss"] = (event, data) => { + const { uiStore, message } = this.props; + if (message.onDismiss) { + message.onDismiss(event, data); } + uiStore.messages.remove(message); + }; } class MessagesView extends React.Component<{ appState: AppState }> { - render() { - const { uiStore } = this.props.appState; - const messages = uiStore.messages.map((message) => ( - - )); - messages.reverse(); - return ( - - {messages} - - ); - } + render() { + const { uiStore } = this.props.appState; + const messages = uiStore.messages.map(message => ( + + )); + messages.reverse(); + return ( + + {messages} + + ); + } } export default injectState(observer(MessagesView)); diff --git a/client/components/NavBar.tsx b/client/components/NavBar.tsx index 64bf494..975e068 100644 --- a/client/components/NavBar.tsx +++ b/client/components/NavBar.tsx @@ -7,41 +7,38 @@ import * as route from "@client/routePaths"; import { AppState, ConsumeState, injectState } from "@client/state"; interface NavItemProps { - to: string; - children: React.ReactNode; + to: string; + children: React.ReactNode; } const NavItem = observer(({ to, children }: NavItemProps) => { - function consumeState(appState: AppState) { - const { location } = appState.routerStore; - return ( - {children} - ); - } + function consumeState(appState: AppState) { + const { location } = appState.routerStore; + return ( + + {children} + + ); + } - return ({consumeState}); + return {consumeState}; }); function NavBar({ appState }: { appState: AppState }) { - let loginMenu; - if (appState.isLoggedIn) { - loginMenu = ( - Logout - ); - } else { - loginMenu = ( - Login - ); - } - return ( - - Devices - Messages test - - {loginMenu} - - - ); + let loginMenu; + // tslint:disable-next-line:prefer-conditional-expression + if (appState.isLoggedIn) { + loginMenu = Logout; + } else { + loginMenu = Login; + } + return ( + + Devices + Messages test + {loginMenu} + + ); } export default observer(injectState(NavBar)); diff --git a/client/components/ProgramSequenceView.tsx b/client/components/ProgramSequenceView.tsx index 18b1b90..28bd9d0 100644 --- a/client/components/ProgramSequenceView.tsx +++ b/client/components/ProgramSequenceView.tsx @@ -1,7 +1,12 @@ import classNames = require("classnames"); import { observer } from "mobx-react"; import * as React from "react"; -import { SortableContainer, SortableElement, SortableHandle, SortEnd } from "react-sortable-hoc"; +import { + SortableContainer, + SortableElement, + SortableHandle, + SortEnd +} from "react-sortable-hoc"; import { Button, Form, Icon, List } from "semantic-ui-react"; import { DurationView, SectionChooser } from "@client/components/index"; @@ -14,177 +19,196 @@ import { action } from "mobx"; type ItemChangeHandler = (index: number, newItem: ProgramItem) => void; type ItemRemoveHandler = (index: number) => void; -const Handle = SortableHandle(() => ); +const Handle = SortableHandle(() => ( + +)); @observer class ProgramSequenceItem extends React.Component<{ - sequenceItem: ProgramItem, - idx: number, - sections: Section[], - editing: boolean, - onChange: ItemChangeHandler, - onRemove: ItemRemoveHandler, + sequenceItem: ProgramItem; + idx: number; + sections: Section[]; + editing: boolean; + onChange: ItemChangeHandler; + onRemove: ItemRemoveHandler; }> { - renderContent() { - const { editing, sequenceItem, sections } = this.props; - const section = sections[sequenceItem.section]; - const duration = Duration.fromSeconds(sequenceItem.duration); - - if (editing) { - return ( - - - - - - ); - } else { - return ( - - {section.toString()} - for {duration.toString()} - - ); - } - } - - render() { - const { editing } = this.props; - return ( -
  • - {editing ? : } - {this.renderContent()} -
  • - ); - } - - private onSectionChange = (newSectionId: number) => { - this.props.onChange(this.props.idx, new ProgramItem({ - ...this.props.sequenceItem, section: newSectionId, - })); - } - - private onDurationChange = (newDuration: Duration) => { - this.props.onChange(this.props.idx, new ProgramItem({ - ...this.props.sequenceItem, duration: newDuration.toSeconds(), - })); - } - - private onRemove = () => { - this.props.onRemove(this.props.idx); + renderContent() { + const { editing, sequenceItem, sections } = this.props; + const section = sections[sequenceItem.section]; + const duration = Duration.fromSeconds(sequenceItem.duration); + + if (editing) { + return ( + + + + + + ); + } else { + return ( + + {section.toString()} + for {duration.toString()} + + ); } + } + + render() { + const { editing } = this.props; + return ( +
  • + {editing ? : } + {this.renderContent()} +
  • + ); + } + + private onSectionChange = (newSectionId: number) => { + this.props.onChange( + this.props.idx, + new ProgramItem({ + ...this.props.sequenceItem, + section: newSectionId + }) + ); + }; + + private onDurationChange = (newDuration: Duration) => { + this.props.onChange( + this.props.idx, + new ProgramItem({ + ...this.props.sequenceItem, + duration: newDuration.toSeconds() + }) + ); + }; + + private onRemove = () => { + this.props.onRemove(this.props.idx); + }; } const ProgramSequenceItemD = SortableElement(ProgramSequenceItem); -const ProgramSequenceList = SortableContainer(observer((props: { - className: string, - list: ProgramItem[], - sections: Section[], - editing: boolean, - onChange: ItemChangeHandler, - onRemove: ItemRemoveHandler, -}) => { - const { className, list, sections, ...rest } = props; - const listItems = list.map((item, index) => { +const ProgramSequenceList = SortableContainer( + observer( + (props: { + className: string; + list: ProgramItem[]; + sections: Section[]; + editing: boolean; + onChange: ItemChangeHandler; + onRemove: ItemRemoveHandler; + }) => { + const { className, list, sections, ...rest } = props; + const listItems = list.map((item, index) => { const key = `item-${index}`; return ( - + ); - }); - return
      {listItems}
    ; -}), { withRef: true }); + }); + return
      {listItems}
    ; + } + ), + { withRef: true } +); @observer class ProgramSequenceView extends React.Component<{ - sequence: ProgramItem[], sections: Section[], editing?: boolean, + sequence: ProgramItem[]; + sections: Section[]; + editing?: boolean; }> { - render() { - const { sequence, sections } = this.props; - const editing = this.props.editing || false; - const className = classNames("programSequence", { editing }); - let addButton: React.ReactNode = null; - if (editing) { - addButton = ( - - ); - } - return ( -
    - - {addButton} -
    - ); - } - - @action.bound - private changeItem: ItemChangeHandler = (index, newItem) => { - this.props.sequence[index] = newItem; + render() { + const { sequence, sections } = this.props; + const editing = this.props.editing || false; + const className = classNames("programSequence", { editing }); + let addButton: React.ReactNode = null; + if (editing) { + addButton = ( + + ); } - - @action.bound - private removeItem: ItemRemoveHandler = (index) => { - this.props.sequence.splice(index, 1); - } - - @action.bound - private addItem() { - let sectionId = 0; - for (const section of this.props.sections) { - const sectionNotIncluded = this.props.sequence - .every((sequenceItem) => - sequenceItem.section !== section.id); - if (sectionNotIncluded) { - sectionId = section.id; - break; - } - } - const item = new ProgramItem({ - section: sectionId, - duration: new Duration(5, 0).toSeconds(), - }); - this.props.sequence.push(item); + return ( +
    + + {addButton} +
    + ); + } + + @action.bound + private changeItem: ItemChangeHandler = (index, newItem) => { + this.props.sequence[index] = newItem; + }; + + @action.bound + private removeItem: ItemRemoveHandler = index => { + this.props.sequence.splice(index, 1); + }; + + @action.bound + private addItem() { + let sectionId = 0; + for (const section of this.props.sections) { + const sectionNotIncluded = this.props.sequence.every( + sequenceItem => sequenceItem.section !== section.id + ); + if (sectionNotIncluded) { + sectionId = section.id; + break; + } } - - @action.bound - private onSortEnd({oldIndex, newIndex}: SortEnd) { - const { sequence: array } = this.props; - if (newIndex >= array.length) { - return; - } - array.splice(newIndex, 0, array.splice(oldIndex, 1)[0]); + const item = new ProgramItem({ + section: sectionId, + duration: new Duration(5, 0).toSeconds() + }); + this.props.sequence.push(item); + } + + @action.bound + private onSortEnd({ oldIndex, newIndex }: SortEnd) { + const { sequence: array } = this.props; + if (newIndex >= array.length) { + return; } + array.splice(newIndex, 0, array.splice(oldIndex, 1)[0]); + } } const ProgramSequenceViewD = SortableContainer(ProgramSequenceView); diff --git a/client/components/ProgramTable.tsx b/client/components/ProgramTable.tsx index 632e24c..4acac9e 100644 --- a/client/components/ProgramTable.tsx +++ b/client/components/ProgramTable.tsx @@ -11,143 +11,161 @@ import { Program, SprinklersDevice } from "@common/sprinklersRpc"; @observer class ProgramRows extends React.Component<{ - program: Program, - iDevice: ISprinklersDevice, - device: SprinklersDevice, - routerStore: RouterStore, - expanded: boolean, toggleExpanded: (program: Program) => void, + program: Program; + iDevice: ISprinklersDevice; + device: SprinklersDevice; + routerStore: RouterStore; + expanded: boolean; + toggleExpanded: (program: Program) => void; }> { - render() { - const { program, iDevice, device, expanded } = this.props; - const { sections } = device; + render() { + const { program, iDevice, device, expanded } = this.props; + const { sections } = device; - const { name, running, enabled, schedule, sequence } = program; + const { name, running, enabled, schedule, sequence } = program; - const buttonStyle: ButtonProps = { size: "small", compact: false }; - const detailUrl = route.program(iDevice.id, program.id); + const buttonStyle: ButtonProps = { size: "small", compact: false }; + const detailUrl = route.program(iDevice.id, program.id); - const stopStartButton = ( - - ); + const stopStartButton = ( + + ); - const mainRow = ( - - {"" + program.id} - {name} - {enabled ? "Enabled" : "Not enabled"} - - {running ? "Running" : "Not running"} - - - {stopStartButton} - - - - - ); - const detailRow = expanded && ( - - -
    -

    Sequence:

    - Schedule: } /> - -
    -
    - ); - return ( - - {mainRow} - {detailRow} - - ); - } + const mainRow = ( + + {"" + program.id} + {name} + + {enabled ? "Enabled" : "Not enabled"} + + + {running ? "Running" : "Not running"} + + + {stopStartButton} + + + + + ); + const detailRow = expanded && ( + + +
    +

    Sequence:

    {" "} + + Schedule: } /> + +
    +
    + ); + return ( + + {mainRow} + {detailRow} + + ); + } - private cancelOrRun = () => { - const { program } = this.props; - program.running ? program.cancel() : program.run(); - } + private cancelOrRun = () => { + const { program } = this.props; + program.running ? program.cancel() : program.run(); + }; - private toggleExpanded = () => { - this.props.toggleExpanded(this.props.program); - } + private toggleExpanded = () => { + this.props.toggleExpanded(this.props.program); + }; } type ProgramId = Program["id"]; @observer -export default class ProgramTable extends React.Component<{ - iDevice: ISprinklersDevice, device: SprinklersDevice, routerStore: RouterStore, -}, { - expandedPrograms: ProgramId[], -}> { - constructor(p: any) { - super(p); - this.state = { expandedPrograms: [] }; - } +export default class ProgramTable extends React.Component< + { + iDevice: ISprinklersDevice; + device: SprinklersDevice; + routerStore: RouterStore; + }, + { + expandedPrograms: ProgramId[]; + } +> { + constructor(p: any) { + super(p); + this.state = { expandedPrograms: [] }; + } - render() { - const { programs } = this.props.device; - const programRows = programs.map(this.renderRows); + render() { + const { programs } = this.props.device; + const programRows = programs.map(this.renderRows); - return ( - - - - Programs - - - # - Name - Enabled? - Running? - Actions - - - - {programRows} - -
    - ); - } + return ( + + + + Programs + + + # + Name + + Enabled? + + + Running? + + + Actions + + + + {programRows} +
    + ); + } - private renderRows = (program: Program, i: number): JSX.Element | null => { - if (!program) { - return null; - } - const expanded = this.state.expandedPrograms.indexOf(program.id) !== -1; - return ( - - ); + private renderRows = (program: Program, i: number): JSX.Element | null => { + if (!program) { + return null; } + const expanded = this.state.expandedPrograms.indexOf(program.id) !== -1; + return ( + + ); + }; - private toggleExpanded = (program: Program) => { - const { expandedPrograms } = this.state; - const idx = expandedPrograms.indexOf(program.id); - if (idx !== -1) { - expandedPrograms.splice(idx, 1); - } else { - expandedPrograms.push(program.id); - } - this.setState({ - expandedPrograms, - }); + private toggleExpanded = (program: Program) => { + const { expandedPrograms } = this.state; + const idx = expandedPrograms.indexOf(program.id); + if (idx !== -1) { + expandedPrograms.splice(idx, 1); + } else { + expandedPrograms.push(program.id); } + this.setState({ + expandedPrograms + }); + }; } diff --git a/client/components/RunSectionForm.tsx b/client/components/RunSectionForm.tsx index 93391bd..fe73005 100644 --- a/client/components/RunSectionForm.tsx +++ b/client/components/RunSectionForm.tsx @@ -10,88 +10,91 @@ import { Section, SprinklersDevice } from "@common/sprinklersRpc"; import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests"; @observer -export default class RunSectionForm extends React.Component<{ - device: SprinklersDevice, - uiStore: UiStore, -}, { - duration: Duration, - sectionId: number | undefined, -}> { - constructor(props: any, context?: any) { - super(props, context); - this.state = { - duration: new Duration(0, 0), - sectionId: undefined, - }; - } +export default class RunSectionForm extends React.Component< + { + device: SprinklersDevice; + uiStore: UiStore; + }, + { + duration: Duration; + sectionId: number | undefined; + } +> { + constructor(props: any, context?: any) { + super(props, context); + this.state = { + duration: new Duration(0, 0), + sectionId: undefined + }; + } - render() { - const { sectionId, duration } = this.state; - return ( - -
    Run Section
    -
    - - - - - Run - - -
    - ); - } + render() { + const { sectionId, duration } = this.state; + return ( + +
    Run Section
    +
    + + + + + Run + + +
    + ); + } - private onSectionChange = (newSectionId: number) => { - this.setState({ sectionId: newSectionId }); - } + private onSectionChange = (newSectionId: number) => { + this.setState({ sectionId: newSectionId }); + }; - private onDurationChange = (newDuration: Duration) => { - this.setState({ duration: newDuration }); - } + private onDurationChange = (newDuration: Duration) => { + this.setState({ duration: newDuration }); + }; - private run = (e: React.SyntheticEvent) => { - e.preventDefault(); - const { sectionId, duration } = this.state; - if (sectionId == null) { - return; - } - const section = this.props.device.sections[sectionId]; - section.run(duration.toSeconds()) - .then(this.onRunSuccess) - .catch(this.onRunError); + private run = (e: React.SyntheticEvent) => { + e.preventDefault(); + const { sectionId, duration } = this.state; + if (sectionId == null) { + return; } + const section = this.props.device.sections[sectionId]; + section + .run(duration.toSeconds()) + .then(this.onRunSuccess) + .catch(this.onRunError); + }; - private onRunSuccess = (result: RunSectionResponse) => { - log.debug({ result }, "requested section run"); - this.props.uiStore.addMessage({ - success: true, header: "Section running", - content: result.message, timeout: 2000, - }); - } + private onRunSuccess = (result: RunSectionResponse) => { + log.debug({ result }, "requested section run"); + this.props.uiStore.addMessage({ + success: true, + header: "Section running", + content: result.message, + timeout: 2000 + }); + }; - private onRunError = (err: RunSectionResponse) => { - log.error(err, "error running section"); - this.props.uiStore.addMessage({ - error: true, header: "Error running section", - content: err.message, - }); - } + private onRunError = (err: RunSectionResponse) => { + log.error(err, "error running section"); + this.props.uiStore.addMessage({ + error: true, + header: "Error running section", + content: err.message + }); + }; - private get isValid(): boolean { - return this.state.sectionId != null && this.state.duration.toSeconds() > 0; - } + private get isValid(): boolean { + return this.state.sectionId != null && this.state.duration.toSeconds() > 0; + } } diff --git a/client/components/ScheduleView/ScheduleDate.tsx b/client/components/ScheduleView/ScheduleDate.tsx index 25b06ca..fc1beb3 100644 --- a/client/components/ScheduleView/ScheduleDate.tsx +++ b/client/components/ScheduleView/ScheduleDate.tsx @@ -7,75 +7,101 @@ import { DateOfYear } from "@common/sprinklersRpc"; const HTML_DATE_INPUT_FORMAT = "YYYY-MM-DD"; export interface ScheduleDateProps { - date: DateOfYear | null | undefined; - label: string | React.ReactNode | undefined; - editing: boolean | undefined; - onChange: (newDate: DateOfYear | null) => void; + date: DateOfYear | null | undefined; + label: string | React.ReactNode | undefined; + editing: boolean | undefined; + onChange: (newDate: DateOfYear | null) => void; } interface ScheduleDateState { - rawValue: string | ""; - lastDate: DateOfYear | null | undefined; + rawValue: string | ""; + lastDate: DateOfYear | null | undefined; } -export default class ScheduleDate extends React.Component { - static getDerivedStateFromProps(props: ScheduleDateProps, state: ScheduleDateState): Partial { - if (!DateOfYear.equals(props.date, state.lastDate)) { - const thisYear = moment().year(); - const rawValue = props.date == null ? "" : - moment(props.date).year(thisYear).format(HTML_DATE_INPUT_FORMAT); - return { lastDate: props.date, rawValue }; - } - return {}; +export default class ScheduleDate extends React.Component< + ScheduleDateProps, + ScheduleDateState +> { + static getDerivedStateFromProps( + props: ScheduleDateProps, + state: ScheduleDateState + ): Partial { + if (!DateOfYear.equals(props.date, state.lastDate)) { + const thisYear = moment().year(); + const rawValue = + props.date == null + ? "" + : moment(props.date) + .year(thisYear) + .format(HTML_DATE_INPUT_FORMAT); + return { lastDate: props.date, rawValue }; } + return {}; + } - constructor(p: ScheduleDateProps) { - super(p); - this.state = { rawValue: "", lastDate: undefined }; - } - - render() { - const { date, label, editing } = this.props; - - let dayNode: React.ReactNode; - if (editing) { // tslint:disable-line:prefer-conditional-expression - let clearIcon: React.ReactNode | undefined; - if (date) { - clearIcon = ; - } - dayNode = ; - } else { - const m = moment(date || ""); - let dayString: string; - if (m.isValid()) { - const format = (m.year() === 0) ? "M/D" : "l"; - dayString = m.format(format); - } else { - dayString = "N/A"; - } - dayNode = {dayString}; - } + constructor(p: ScheduleDateProps) { + super(p); + this.state = { rawValue: "", lastDate: undefined }; + } - let labelNode: React.ReactNode = null; - if (typeof label === "string") { - labelNode = ; - } else if (label != null) { - labelNode = label; - } + render() { + const { date, label, editing } = this.props; - return {labelNode}{dayNode}; + let dayNode: React.ReactNode; + if (editing) { + // tslint:disable-line:prefer-conditional-expression + let clearIcon: React.ReactNode | undefined; + if (date) { + clearIcon = ; + } + dayNode = ( + + ); + } else { + const m = moment(date || ""); + let dayString: string; + if (m.isValid()) { + const format = m.year() === 0 ? "M/D" : "l"; + dayString = m.format(format); + } else { + dayString = "N/A"; + } + dayNode = {dayString}; } - private onChange = (e: React.SyntheticEvent, data: InputOnChangeData) => { - const { onChange } = this.props; - if (!onChange) return; - const m = moment(data.value, HTML_DATE_INPUT_FORMAT); - onChange(DateOfYear.fromMoment(m).with({ year: 0 })); + let labelNode: React.ReactNode = null; + if (typeof label === "string") { + labelNode = ; + } else if (label != null) { + labelNode = label; } - private onClear = () => { - const { onChange } = this.props; - if (!onChange) return; - onChange(null); - } + return ( + + {labelNode} + {dayNode} + + ); + } + + private onChange = ( + e: React.SyntheticEvent, + data: InputOnChangeData + ) => { + const { onChange } = this.props; + if (!onChange) return; + const m = moment(data.value, HTML_DATE_INPUT_FORMAT); + onChange(DateOfYear.fromMoment(m).with({ year: 0 })); + }; + + private onClear = () => { + const { onChange } = this.props; + if (!onChange) return; + onChange(null); + }; } diff --git a/client/components/ScheduleView/ScheduleTimes.tsx b/client/components/ScheduleView/ScheduleTimes.tsx index 6cac876..1f5561b 100644 --- a/client/components/ScheduleView/ScheduleTimes.tsx +++ b/client/components/ScheduleView/ScheduleTimes.tsx @@ -6,37 +6,41 @@ import { TimeOfDay } from "@common/sprinklersRpc"; import TimeInput from "./TimeInput"; function timeToString(time: TimeOfDay) { - return moment(time).format("LTS"); + return moment(time).format("LTS"); } export default class ScheduleTimes extends React.Component<{ - times: TimeOfDay[]; - onChange: (newTimes: TimeOfDay[]) => void; - editing: boolean; + times: TimeOfDay[]; + onChange: (newTimes: TimeOfDay[]) => void; + editing: boolean; }> { - render() { - const { times, editing } = this.props; - let timesNode: React.ReactNode; - if (editing) { - timesNode = times - .map((time, i) => ); - } else { - timesNode = ( - - {times.map((time) => timeToString(time)).join(", ")} - - ); - } - return ( - - {timesNode} - - ); - } - private onTimeChange = (newTime: TimeOfDay, index: number) => { - const { times, onChange } = this.props; - const newTimes = times.slice(); - newTimes[index] = newTime; - onChange(newTimes); + render() { + const { times, editing } = this.props; + let timesNode: React.ReactNode; + if (editing) { + timesNode = times.map((time, i) => ( + + )); + } else { + timesNode = ( + {times.map(time => timeToString(time)).join(", ")} + ); } + return ( + + {timesNode} + + ); + } + private onTimeChange = (newTime: TimeOfDay, index: number) => { + const { times, onChange } = this.props; + const newTimes = times.slice(); + newTimes[index] = newTime; + onChange(newTimes); + }; } diff --git a/client/components/ScheduleView/TimeInput.tsx b/client/components/ScheduleView/TimeInput.tsx index 43e4373..5cf06f5 100644 --- a/client/components/ScheduleView/TimeInput.tsx +++ b/client/components/ScheduleView/TimeInput.tsx @@ -7,49 +7,68 @@ import { TimeOfDay } from "@common/sprinklersRpc"; const HTML_TIME_INPUT_FORMAT = "HH:mm"; function timeOfDayToHtmlDateInput(tod: TimeOfDay): string { - return moment(tod).format(HTML_TIME_INPUT_FORMAT); + return moment(tod).format(HTML_TIME_INPUT_FORMAT); } export interface TimeInputProps { - value: TimeOfDay; - index: number; - onChange: (newValue: TimeOfDay, index: number) => void; + value: TimeOfDay; + index: number; + onChange: (newValue: TimeOfDay, index: number) => void; } export interface TimeInputState { - rawValue: string; - lastTime: TimeOfDay | null; + rawValue: string; + lastTime: TimeOfDay | null; } -export default class TimeInput extends React.Component { - static getDerivedStateFromProps(props: TimeInputProps, state: TimeInputState): Partial { - if (!TimeOfDay.equals(props.value, state.lastTime)) { - return { lastTime: props.value, rawValue: timeOfDayToHtmlDateInput(props.value) }; - } - return {}; +export default class TimeInput extends React.Component< + TimeInputProps, + TimeInputState +> { + static getDerivedStateFromProps( + props: TimeInputProps, + state: TimeInputState + ): Partial { + if (!TimeOfDay.equals(props.value, state.lastTime)) { + return { + lastTime: props.value, + rawValue: timeOfDayToHtmlDateInput(props.value) + }; } + return {}; + } - constructor(p: any) { - super(p); - this.state = { rawValue: "", lastTime: null }; - } + constructor(p: any) { + super(p); + this.state = { rawValue: "", lastTime: null }; + } - render() { - return ; - } + render() { + return ( + + ); + } - private onChange = (e: React.SyntheticEvent, data: InputOnChangeData) => { - this.setState({ - rawValue: data.value, - }); - } + private onChange = ( + e: React.SyntheticEvent, + data: InputOnChangeData + ) => { + this.setState({ + rawValue: data.value + }); + }; - private onBlur: React.FocusEventHandler = (e) => { - const m = moment(this.state.rawValue, HTML_TIME_INPUT_FORMAT); - if (m.isValid()) { - this.props.onChange(TimeOfDay.fromMoment(m), this.props.index); - } else { - this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) }); - } + private onBlur: React.FocusEventHandler = e => { + const m = moment(this.state.rawValue, HTML_TIME_INPUT_FORMAT); + if (m.isValid()) { + this.props.onChange(TimeOfDay.fromMoment(m), this.props.index); + } else { + this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) }); } + }; } diff --git a/client/components/ScheduleView/WeekdaysView.tsx b/client/components/ScheduleView/WeekdaysView.tsx index 1ceb00c..c468349 100644 --- a/client/components/ScheduleView/WeekdaysView.tsx +++ b/client/components/ScheduleView/WeekdaysView.tsx @@ -4,51 +4,56 @@ import { Checkbox, CheckboxProps, Form } from "semantic-ui-react"; import { Weekday, WEEKDAYS } from "@common/sprinklersRpc"; export interface WeekdaysViewProps { - weekdays: Weekday[]; - editing: boolean; - onChange?: (newWeekdays: Weekday[]) => void; + weekdays: Weekday[]; + editing: boolean; + onChange?: (newWeekdays: Weekday[]) => void; } export default class WeekdaysView extends React.Component { - render() { - const { weekdays, editing } = this.props; - let node: React.ReactNode; - if (editing) { - node = WEEKDAYS.map((weekday) => { - const checked = weekdays.find((wd) => wd === weekday) != null; - const name = Weekday[weekday]; - return ( - - ); - }); - } else { - node = weekdays.map((weekday) => Weekday[weekday]).join(", "); - } + render() { + const { weekdays, editing } = this.props; + let node: React.ReactNode; + if (editing) { + node = WEEKDAYS.map(weekday => { + const checked = weekdays.find(wd => wd === weekday) != null; + const name = Weekday[weekday]; return ( - - {node} - + ); + }); + } else { + node = weekdays.map(weekday => Weekday[weekday]).join(", "); } - private toggleWeekday = (event: React.FormEvent, data: CheckboxProps) => { - const { weekdays, onChange } = this.props; - if (!onChange) { - return; - } - const weekday: Weekday = Number(event.currentTarget.getAttribute("x-weekday")); - if (data.checked) { - const newWeekdays = weekdays.concat([weekday]); - newWeekdays.sort(); - onChange(newWeekdays); - } else { - onChange(weekdays.filter((wd) => wd !== weekday)); - } + return ( + + {node} + + ); + } + private toggleWeekday = ( + event: React.FormEvent, + data: CheckboxProps + ) => { + const { weekdays, onChange } = this.props; + if (!onChange) { + return; } + const weekday: Weekday = Number( + event.currentTarget.getAttribute("x-weekday") + ); + if (data.checked) { + const newWeekdays = weekdays.concat([weekday]); + newWeekdays.sort(); + onChange(newWeekdays); + } else { + onChange(weekdays.filter(wd => wd !== weekday)); + } + }; } diff --git a/client/components/ScheduleView/index.tsx b/client/components/ScheduleView/index.tsx index 8265538..92abf07 100644 --- a/client/components/ScheduleView/index.tsx +++ b/client/components/ScheduleView/index.tsx @@ -2,7 +2,12 @@ import { observer } from "mobx-react"; import * as React from "react"; import { Form } from "semantic-ui-react"; -import { DateOfYear, Schedule, TimeOfDay, Weekday } from "@common/sprinklersRpc"; +import { + DateOfYear, + Schedule, + TimeOfDay, + Weekday +} from "@common/sprinklersRpc"; import ScheduleDate from "./ScheduleDate"; import ScheduleTimes from "./ScheduleTimes"; import WeekdaysView from "./WeekdaysView"; @@ -11,52 +16,70 @@ import "@client/styles/ScheduleView"; import { action } from "mobx"; export interface ScheduleViewProps { - label?: string | React.ReactNode | undefined; - schedule: Schedule; - editing?: boolean; + label?: string | React.ReactNode | undefined; + schedule: Schedule; + editing?: boolean; } @observer export default class ScheduleView extends React.Component { - render() { - const { schedule, label } = this.props; - const editing = this.props.editing || false; - - let labelNode: React.ReactNode; - if (typeof label === "string") { - labelNode = ; - } else if (label != null) { - labelNode = label; - } - - return ( - - {labelNode} - - - - - - ); - } + render() { + const { schedule, label } = this.props; + const editing = this.props.editing || false; - @action.bound - private updateTimes(newTimes: TimeOfDay[]) { - this.props.schedule.times = newTimes; + let labelNode: React.ReactNode; + if (typeof label === "string") { + labelNode = ; + } else if (label != null) { + labelNode = label; } - @action.bound - private updateWeekdays(newWeekdays: Weekday[]) { - this.props.schedule.weekdays = newWeekdays; - } + return ( + + {labelNode} + + + + + + ); + } - @action.bound - private updateFromDate(newFromDate: DateOfYear | null) { - this.props.schedule.from = newFromDate; - } + @action.bound + private updateTimes(newTimes: TimeOfDay[]) { + this.props.schedule.times = newTimes; + } - @action.bound - private updateToDate(newToDate: DateOfYear | null) { - this.props.schedule.to = newToDate; - } + @action.bound + private updateWeekdays(newWeekdays: Weekday[]) { + this.props.schedule.weekdays = newWeekdays; + } + + @action.bound + private updateFromDate(newFromDate: DateOfYear | null) { + this.props.schedule.from = newFromDate; + } + + @action.bound + private updateToDate(newToDate: DateOfYear | null) { + this.props.schedule.to = newToDate; + } } diff --git a/client/components/SectionChooser.tsx b/client/components/SectionChooser.tsx index 18463d2..b01aa24 100644 --- a/client/components/SectionChooser.tsx +++ b/client/components/SectionChooser.tsx @@ -9,41 +9,49 @@ import "@client/styles/SectionChooser"; @observer export default class SectionChooser extends React.Component<{ - label?: string, - inline?: boolean, - sections: Section[], - sectionId?: number, - onChange?: (sectionId: number) => void, + label?: string; + inline?: boolean; + sections: Section[]; + sectionId?: number; + onChange?: (sectionId: number) => void; }> { - render() { - const { label, inline, sections, sectionId, onChange } = this.props; - if (onChange == null) { - const sectionStr = sectionId != null ? sections[sectionId].toString() : ""; - return {label || ""} '{sectionStr}'; - } - const section = (sectionId == null) ? "" : sectionId; - return ( - - ); + render() { + const { label, inline, sections, sectionId, onChange } = this.props; + if (onChange == null) { + const sectionStr = + sectionId != null ? sections[sectionId].toString() : ""; + return ( + + {label || ""} '{sectionStr}' + + ); } + const section = sectionId == null ? "" : sectionId; + return ( + + ); + } - private onSectionChange = (e: React.SyntheticEvent, v: DropdownProps) => { - this.props.onChange!(this.props.sections[v.value as number].id); - } + private onSectionChange = ( + e: React.SyntheticEvent, + v: DropdownProps + ) => { + this.props.onChange!(this.props.sections[v.value as number].id); + }; - @computed - private get sectionOptions(): DropdownItemProps[] { - return this.props.sections.map((s, i) => ({ - text: s ? `${s.id}: ${s.name}` : null, - value: i, - })); - } + @computed + private get sectionOptions(): DropdownItemProps[] { + return this.props.sections.map((s, i) => ({ + text: s ? `${s.id}: ${s.name}` : null, + value: i + })); + } } diff --git a/client/components/SectionRunnerView.tsx b/client/components/SectionRunnerView.tsx index 9ee592f..56a4329 100644 --- a/client/components/SectionRunnerView.tsx +++ b/client/components/SectionRunnerView.tsx @@ -10,149 +10,168 @@ import { Section, SectionRun, SectionRunner } from "@common/sprinklersRpc"; import "@client/styles/SectionRunnerView"; interface PausedStateProps { - paused: boolean; - togglePaused: () => void; + paused: boolean; + togglePaused: () => void; } function PausedState({ paused, togglePaused }: PausedStateProps) { - const classes = classNames({ - "sectionRunner--pausedState": true, - "sectionRunner--pausedState-paused": paused, - "sectionRunner--pausedState-unpaused": !paused, - }); - return ( - - ); + const classes = classNames({ + "sectionRunner--pausedState": true, + "sectionRunner--pausedState-paused": paused, + "sectionRunner--pausedState-unpaused": !paused + }); + return ( + + ); } -class SectionRunView extends React.Component<{ +class SectionRunView extends React.Component< + { run: SectionRun; sections: Section[]; -}, { + }, + { now: number; -}> { - animationFrameHandle: number | null = null; - startTime: number; + } +> { + animationFrameHandle: number | null = null; + startTime: number; - constructor(p: any) { - super(p); - const now = performance.now(); - this.state = { now }; - this.startTime = Date.now() - now; - } + constructor(p: any) { + super(p); + const now = performance.now(); + this.state = { now }; + this.startTime = Date.now() - now; + } - componentDidMount() { - this.requestAnimationFrame(); - } + componentDidMount() { + this.requestAnimationFrame(); + } - componentDidUpdate() { - this.requestAnimationFrame(); - } + componentDidUpdate() { + this.requestAnimationFrame(); + } - componentWillUnmount() { - this.cancelAnimationFrame(); - } + componentWillUnmount() { + this.cancelAnimationFrame(); + } - cancelAnimationFrame = () => { - if (this.animationFrameHandle != null) { - cancelAnimationFrame(this.animationFrameHandle); - this.animationFrameHandle = null; - } + cancelAnimationFrame = () => { + if (this.animationFrameHandle != null) { + cancelAnimationFrame(this.animationFrameHandle); + this.animationFrameHandle = null; } + }; - requestAnimationFrame = () => { - const startTime = this.props.run.startTime; - if (startTime != null) { - if (this.animationFrameHandle == null) { - this.animationFrameHandle = requestAnimationFrame(this.updateNow); - } - } else { - this.cancelAnimationFrame(); - } + requestAnimationFrame = () => { + const startTime = this.props.run.startTime; + if (startTime != null) { + if (this.animationFrameHandle == null) { + this.animationFrameHandle = requestAnimationFrame(this.updateNow); + } + } else { + this.cancelAnimationFrame(); } + }; - updateNow = (now: number) => { - this.animationFrameHandle = null; - this.setState({ - now: this.startTime + now, - }); - this.requestAnimationFrame(); - } + updateNow = (now: number) => { + this.animationFrameHandle = null; + this.setState({ + now: this.startTime + now + }); + this.requestAnimationFrame(); + }; - render() { - const { run, sections } = this.props; - const startTime = run.unpauseTime ? run.unpauseTime : run.startTime; - const now = this.state.now; - const section = sections[run.section]; - const duration = Duration.fromSeconds(run.duration); - const cancel = run.cancel; - let running: boolean = false; // tslint:disable-line:no-unused-variable - let paused: boolean = false; - let progressBar: React.ReactNode | undefined; - if (startTime != null) { - let elapsed = (run.totalDuration - run.duration); - if (run.pauseTime) { - paused = true; - } else { - running = true; - elapsed += (now - startTime.valueOf()) / 1000; - } - const percentage = elapsed / run.totalDuration; - progressBar = - ; - } - const description = `'${section.name}' for ${duration.toString()}` + - (paused ? " (paused)" : "") + - (running ? " (running)" : ""); - return ( - -
    - {description} - -
    - {progressBar} -
    - ); + render() { + const { run, sections } = this.props; + const startTime = run.unpauseTime ? run.unpauseTime : run.startTime; + const now = this.state.now; + const section = sections[run.section]; + const duration = Duration.fromSeconds(run.duration); + const cancel = run.cancel; + let running: boolean = false; // tslint:disable-line:no-unused-variable + let paused: boolean = false; + let progressBar: React.ReactNode | undefined; + if (startTime != null) { + let elapsed = run.totalDuration - run.duration; + if (run.pauseTime) { + paused = true; + } else { + running = true; + elapsed += (now - startTime.valueOf()) / 1000; + } + const percentage = elapsed / run.totalDuration; + progressBar = ( + + ); } + const description = + `'${section.name}' for ${duration.toString()}` + + (paused ? " (paused)" : "") + + (running ? " (running)" : ""); + return ( + +
    + {description} + +
    + {progressBar} +
    + ); + } } @observer -export default class SectionRunnerView extends React.Component<{ - sectionRunner: SectionRunner, sections: Section[], -}, {}> { - render() { - const { current, queue, paused } = this.props.sectionRunner; - const { sections } = this.props; - const queueView = queue.map((run) => - ); - if (current) { - queueView.unshift(); - } - if (queueView.length === 0) { - queueView.push(No items in queue); - } - return ( - -
    -

    Section Runner Queue

    -
    - -
    - - {queueView} - - - ); +export default class SectionRunnerView extends React.Component< + { + sectionRunner: SectionRunner; + sections: Section[]; + }, + {} +> { + render() { + const { current, queue, paused } = this.props.sectionRunner; + const { sections } = this.props; + const queueView = queue.map(run => ( + + )); + if (current) { + queueView.unshift( + + ); } - - togglePaused = () => { - const { sectionRunner } = this.props; - const paused = !sectionRunner.paused; - sectionRunner.setPaused(paused) - .then((res) => log.info(res, "set section runner paused to " + paused)) - .catch((err) => log.info({ err }, "error setting section runner paused status")); + if (queueView.length === 0) { + queueView.push(No items in queue); } + return ( + +
    +

    Section Runner Queue

    +
    + +
    + {queueView} + + ); + } + + togglePaused = () => { + const { sectionRunner } = this.props; + const paused = !sectionRunner.paused; + sectionRunner + .setPaused(paused) + .then(res => log.info(res, "set section runner paused to " + paused)) + .catch(err => + log.info({ err }, "error setting section runner paused status") + ); + }; } diff --git a/client/components/SectionTable.tsx b/client/components/SectionTable.tsx index 9618c8b..e2bd8aa 100644 --- a/client/components/SectionTable.tsx +++ b/client/components/SectionTable.tsx @@ -8,46 +8,52 @@ import { Section } from "@common/sprinklersRpc"; /* tslint:disable:object-literal-sort-keys */ @observer -export default class SectionTable extends React.Component<{ sections: Section[] }> { - private static renderRow(section: Section, index: number) { - if (!section) { - return null; - } - const { name, state } = section; - const sectionStateClass = classNames({ - "section-state": true, - "running": state, - }); - const sectionState = state ? - ( Irrigating) - : "Not irrigating"; - return ( - - {"" + (index + 1)} - {name} - {sectionState} - - ); +export default class SectionTable extends React.Component<{ + sections: Section[]; +}> { + private static renderRow(section: Section, index: number) { + if (!section) { + return null; } + const { name, state } = section; + const sectionStateClass = classNames({ + "section-state": true, + running: state + }); + const sectionState = state ? ( + + Irrigating + + ) : ( + "Not irrigating" + ); + return ( + + {"" + (index + 1)} + {name} + {sectionState} + + ); + } - render() { - const rows = this.props.sections.map(SectionTable.renderRow); - return ( - - - - Sections - - - # - Name - State - - - - {rows} - -
    - ); - } + render() { + const rows = this.props.sections.map(SectionTable.renderRow); + return ( + + + + Sections + + + # + Name + + State + + + + {rows} +
    + ); + } } diff --git a/client/env.js b/client/env.js index e2ee4ae..4559427 100644 --- a/client/env.js +++ b/client/env.js @@ -59,8 +59,7 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) { (env, key) => { env[key] = process.env[key]; return env; - }, - { + }, { // Useful for determining whether we’re running in production mode. // Most importantly, it switches React into the correct mode. NODE_ENV: process.env.NODE_ENV || "development", @@ -79,5 +78,8 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) { }, {}), }; - return { raw, stringified }; -}; + return { + raw, + stringified + }; +}; \ No newline at end of file diff --git a/client/index.html b/client/index.html index 06c2c9f..996060d 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,6 @@ + @@ -7,7 +8,9 @@ Sprinklers3 +
    + \ No newline at end of file diff --git a/client/index.tsx b/client/index.tsx index 693b2a8..6bf6be8 100644 --- a/client/index.tsx +++ b/client/index.tsx @@ -8,30 +8,30 @@ import { AppState, ProvideState } from "@client/state"; import logger from "@common/logger"; const state = new AppState(); -state.start() - .catch((err: any) => { - logger.error({ err }, "error starting state"); - }); +state.start().catch((err: any) => { + logger.error({ err }, "error starting state"); +}); const rootElem = document.getElementById("app"); const doRender = (Component: React.ComponentType) => { - ReactDOM.render(( - - - - - - - - ), rootElem); + ReactDOM.render( + + + + + + + , + rootElem + ); }; doRender(App); if (module.hot) { - module.hot.accept("@client/App", () => { - const NextApp = require("@client/App").default as typeof App; - doRender(NextApp); - }); + module.hot.accept("@client/App", () => { + const NextApp = require("@client/App").default as typeof App; + doRender(NextApp); + }); } diff --git a/client/pages/DevicePage.tsx b/client/pages/DevicePage.tsx index 52b902a..cd9a95d 100644 --- a/client/pages/DevicePage.tsx +++ b/client/pages/DevicePage.tsx @@ -5,16 +5,22 @@ import { Item } from "semantic-ui-react"; import DeviceView from "@client/components/DeviceView"; import { RouteComponentProps, withRouter } from "react-router"; -class DevicePage extends React.Component> { - render() { - const { match: { params: { deviceId } } } = this.props; - const devId = Number(deviceId); - return ( - - - - ); - } +class DevicePage extends React.Component< + RouteComponentProps<{ deviceId: string }> +> { + render() { + const { + match: { + params: { deviceId } + } + } = this.props; + const devId = Number(deviceId); + return ( + + + + ); + } } export default withRouter(observer(DevicePage)); diff --git a/client/pages/DevicesPage.tsx b/client/pages/DevicesPage.tsx index 3770a7a..38edeb7 100644 --- a/client/pages/DevicesPage.tsx +++ b/client/pages/DevicesPage.tsx @@ -6,28 +6,26 @@ import { DeviceView } from "@client/components"; import { AppState, injectState } from "@client/state"; class DevicesPage extends React.Component<{ appState: AppState }> { - render() { - const { appState } = this.props; - const { userData } = appState.userStore; - let deviceNodes: React.ReactNode; - if (!userData) { - deviceNodes = Not logged in; - } else if (!userData.devices || !userData.devices.length) { - deviceNodes = You have no devices; - } else { - deviceNodes = userData.devices.map((device) => ( - - )); - } - return ( - -

    Devices

    - - {deviceNodes} - -
    - ); + render() { + const { appState } = this.props; + const { userData } = appState.userStore; + let deviceNodes: React.ReactNode; + if (!userData) { + deviceNodes = Not logged in; + } else if (!userData.devices || !userData.devices.length) { + deviceNodes = You have no devices; + } else { + deviceNodes = userData.devices.map(device => ( + + )); } + return ( + +

    Devices

    + {deviceNodes} +
    + ); + } } export default injectState(observer(DevicesPage)); diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx index 2f3cfc1..5e536e3 100644 --- a/client/pages/LoginPage.tsx +++ b/client/pages/LoginPage.tsx @@ -1,7 +1,16 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; import * as React from "react"; -import { Container, Dimmer, Form, Header, InputOnChangeData, Loader, Message, Segment } from "semantic-ui-react"; +import { + Container, + Dimmer, + Form, + Header, + InputOnChangeData, + Loader, + Message, + Segment +} from "semantic-ui-react"; import { AppState, injectState } from "@client/state"; import log from "@common/logger"; @@ -9,76 +18,94 @@ import log from "@common/logger"; import "@client/styles/LoginPage"; class LoginPageState { - @observable username = ""; - @observable password = ""; + @observable + username = ""; + @observable + password = ""; - @observable loading: boolean = false; - @observable error: string | null = null; + @observable + loading: boolean = false; + @observable + error: string | null = null; - @computed get canLogin() { - return this.username.length > 0 && this.password.length > 0; - } + @computed + get canLogin() { + return this.username.length > 0 && this.password.length > 0; + } - @action.bound - onUsernameChange(e: any, data: InputOnChangeData) { - this.username = data.value; - } + @action.bound + onUsernameChange(e: any, data: InputOnChangeData) { + this.username = data.value; + } - @action.bound - onPasswordChange(e: any, data: InputOnChangeData) { - this.password = data.value; - } + @action.bound + onPasswordChange(e: any, data: InputOnChangeData) { + this.password = data.value; + } - @action.bound - login(appState: AppState) { - this.loading = true; - this.error = null; - appState.httpApi.grantPassword(this.username, this.password) - .then(action("loginSuccess", () => { - this.loading = false; - log.info("logged in"); - appState.history.push("/"); - })) - .catch(action("loginError", (err: any) => { - this.loading = false; - this.error = err.message; - log.error({ err }, "login error"); - })); - } + @action.bound + login(appState: AppState) { + this.loading = true; + this.error = null; + appState.httpApi + .grantPassword(this.username, this.password) + .then( + action("loginSuccess", () => { + this.loading = false; + log.info("logged in"); + appState.history.push("/"); + }) + ) + .catch( + action("loginError", (err: any) => { + this.loading = false; + this.error = err.message; + log.error({ err }, "login error"); + }) + ); + } } class LoginPage extends React.Component<{ appState: AppState }> { - pageState = new LoginPageState(); + pageState = new LoginPageState(); - render() { - const { username, password, canLogin, loading, error } = this.pageState; - return ( - - - - - + render() { + const { username, password, canLogin, loading, error } = this.pageState; + return ( + + + + + -
    Login
    -
    - - - {error} - Login - -
    -
    - ); - } +
    Login
    +
    + + + + {error} + + + Login + + +
    +
    + ); + } - login = () => { - this.pageState.login(this.props.appState); - } + login = () => { + this.pageState.login(this.props.appState); + }; } export default injectState(observer(LoginPage)); diff --git a/client/pages/LogoutPage.tsx b/client/pages/LogoutPage.tsx index 6d592b4..6da93cb 100644 --- a/client/pages/LogoutPage.tsx +++ b/client/pages/LogoutPage.tsx @@ -4,14 +4,10 @@ import { Redirect } from "react-router"; import { AppState, ConsumeState } from "@client/state"; export default function LogoutPage() { - function consumeState(appState: AppState) { - appState.tokenStore.clearAll(); - return ( - - ); - } + function consumeState(appState: AppState) { + appState.tokenStore.clearAll(); + return ; + } - return ( - {consumeState} - ); + return {consumeState}; } diff --git a/client/pages/MessageTest.tsx b/client/pages/MessageTest.tsx index 0884233..1390020 100644 --- a/client/pages/MessageTest.tsx +++ b/client/pages/MessageTest.tsx @@ -5,36 +5,42 @@ import { AppState, injectState } from "@client/state"; import { getRandomId } from "@common/utils"; class MessageTest extends React.Component<{ appState: AppState }> { - render() { - return ( - -

    Message Test

    - - - -
    - ); - } + render() { + return ( + +

    Message Test

    + + + +
    + ); + } - private test1 = () => { - this.props.appState.uiStore.addMessage({ - info: true, content: "Test Message! " + getRandomId(), header: "Header to test message", - }); - } + private test1 = () => { + this.props.appState.uiStore.addMessage({ + info: true, + content: "Test Message! " + getRandomId(), + header: "Header to test message" + }); + }; - private test2 = () => { - this.props.appState.uiStore.addMessage({ - warning: true, content: "Im gonna dissapear in 5 seconds " + getRandomId(), - header: "Header to test message", timeout: 5000, - }); - } + private test2 = () => { + this.props.appState.uiStore.addMessage({ + warning: true, + content: "Im gonna dissapear in 5 seconds " + getRandomId(), + header: "Header to test message", + timeout: 5000 + }); + }; - private test3 = () => { - this.props.appState.uiStore.addMessage({ - color: "brown", content:
    I Have crazy content!
    , - header: "Header to test message", timeout: 5000, - }); - } + private test3 = () => { + this.props.appState.uiStore.addMessage({ + color: "brown", + content:
    I Have crazy content!
    , + header: "Header to test message", + timeout: 5000 + }); + }; } export default injectState(MessageTest); diff --git a/client/pages/ProgramPage.tsx b/client/pages/ProgramPage.tsx index 759beae..eb68da7 100644 --- a/client/pages/ProgramPage.tsx +++ b/client/pages/ProgramPage.tsx @@ -3,7 +3,16 @@ import { observer } from "mobx-react"; import * as qs from "query-string"; import * as React from "react"; import { RouteComponentProps } from "react-router"; -import { Button, CheckboxProps, Form, Icon, Input, InputOnChangeData, Menu, Modal } from "semantic-ui-react"; +import { + Button, + CheckboxProps, + Form, + Icon, + Input, + InputOnChangeData, + Menu, + Modal +} from "semantic-ui-react"; import { ProgramSequenceView, ScheduleView } from "@client/components"; import * as route from "@client/routePaths"; @@ -13,233 +22,253 @@ import log from "@common/logger"; import { Program, SprinklersDevice } from "@common/sprinklersRpc"; import { action } from "mobx"; -interface ProgramPageProps extends RouteComponentProps<{ deviceId: string, programId: string }> { - appState: AppState; +interface ProgramPageProps + extends RouteComponentProps<{ deviceId: string; programId: string }> { + appState: AppState; } @observer class ProgramPage extends React.Component { - get isEditing(): boolean { - return qs.parse(this.props.location.search).editing != null; - } + get isEditing(): boolean { + return qs.parse(this.props.location.search).editing != null; + } - deviceInfo: ISprinklersDevice | null = null; - device: SprinklersDevice | null = null; - program: Program | null = null; - programView: Program | null = null; + deviceInfo: ISprinklersDevice | null = null; + device: SprinklersDevice | null = null; + program: Program | null = null; + programView: Program | null = null; - componentWillUnmount() { - if (this.device) { - this.device.release(); - } + componentWillUnmount() { + if (this.device) { + this.device.release(); } + } - updateProgram() { - const { userStore, sprinklersRpc } = this.props.appState; - const devId = Number(this.props.match.params.deviceId); - const programId = Number(this.props.match.params.programId); - // tslint:disable-next-line:prefer-conditional-expression - if (this.deviceInfo == null || this.deviceInfo.id !== devId) { - this.deviceInfo = userStore.findDevice(devId); - } - if (!this.deviceInfo || !this.deviceInfo.deviceId) { - if (this.device) { - this.device.release(); - this.device = null; - } - return; - } else { - if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { - if (this.device) { - this.device.release(); - } - this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId); - } - } - if (!this.program || this.program.id !== programId) { - if (this.device.programs.length > programId && programId >= 0) { - this.program = this.device.programs[programId]; - } else { - return; - } - } - if (this.isEditing) { - if (this.programView == null && this.program) { - // this.programView = createViewModel(this.program); - // this.programView = observable(toJS(this.program)); - this.programView = this.program.clone(); - } - } else { - if (this.programView != null) { - // this.programView.reset(); - this.programView = null; - } + updateProgram() { + const { userStore, sprinklersRpc } = this.props.appState; + const devId = Number(this.props.match.params.deviceId); + const programId = Number(this.props.match.params.programId); + // tslint:disable-next-line:prefer-conditional-expression + if (this.deviceInfo == null || this.deviceInfo.id !== devId) { + this.deviceInfo = userStore.findDevice(devId); + } + if (!this.deviceInfo || !this.deviceInfo.deviceId) { + if (this.device) { + this.device.release(); + this.device = null; + } + return; + } else { + if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { + if (this.device) { + this.device.release(); } + this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId); + } + } + if (!this.program || this.program.id !== programId) { + if (this.device.programs.length > programId && programId >= 0) { + this.program = this.device.programs[programId]; + } else { + return; + } } + if (this.isEditing) { + if (this.programView == null && this.program) { + // this.programView = createViewModel(this.program); + // this.programView = observable(toJS(this.program)); + this.programView = this.program.clone(); + } + } else { + if (this.programView != null) { + // this.programView.reset(); + this.programView = null; + } + } + } - renderName(program: Program) { - const { name } = program; - if (this.isEditing) { - return ( - -
    - - - - - - ({program.id}) - -
    -
    - ); - } else { - return Program {name} ({program.id}); - } + renderName(program: Program) { + const { name } = program; + if (this.isEditing) { + return ( + +
    + + + + + + ({program.id}) + +
    +
    + ); + } else { + return ( + + Program {name} ({program.id}) + + ); } + } - renderActions(program: Program) { - const { running } = program; - const editing = this.isEditing; - let editButtons; - if (editing) { - editButtons = ( - - - - - ); - } else { - editButtons = ( - - ); - } - const stopStartButton = ( - - ); - return ( - - {stopStartButton} - {editButtons} - - - ); + renderActions(program: Program) { + const { running } = program; + const editing = this.isEditing; + let editButtons; + if (editing) { + editButtons = ( + + + + + ); + } else { + editButtons = ( + + ); } + const stopStartButton = ( + + ); + return ( + + {stopStartButton} + {editButtons} + + + ); + } - render() { - this.updateProgram(); + render() { + this.updateProgram(); - const program = this.programView || this.program; - if (!this.device || !program) { - return null; - } - const editing = this.isEditing; - - const { running, enabled, schedule, sequence } = program; - - return ( - - {this.renderName(program)} - -
    - - - - - - - - - Schedule} /> - -
    - {this.renderActions(program)} -
    - ); + const program = this.programView || this.program; + if (!this.device || !program) { + return null; } + const editing = this.isEditing; - @action.bound - private cancelOrRun() { - if (!this.program) { - return; - } - this.program.running ? this.program.cancel() : this.program.run(); - } + const { running, enabled, schedule, sequence } = program; - @action.bound - private startEditing() { - this.props.history.push({ search: qs.stringify({ editing: true }) }); - } + return ( + + {this.renderName(program)} + +
    + + + + + + + + + Schedule} + /> + +
    + {this.renderActions(program)} +
    + ); + } - @action.bound - private save() { - if (!this.programView || !this.program) { - return; - } - assign(this.program, this.programView); - this.program.update() - .then((data) => { - log.info({ data }, "Program updated"); - }, (err) => { - log.error({ err }, "error updating Program"); - }); - this.stopEditing(); + @action.bound + private cancelOrRun() { + if (!this.program) { + return; } + this.program.running ? this.program.cancel() : this.program.run(); + } - @action.bound - private stopEditing() { - this.props.history.push({ search: "" }); - } + @action.bound + private startEditing() { + this.props.history.push({ search: qs.stringify({ editing: true }) }); + } - @action.bound - private close() { - const { deviceId } = this.props.match.params; - this.props.history.push({ pathname: route.device(deviceId), search: "" }); + @action.bound + private save() { + if (!this.programView || !this.program) { + return; } + assign(this.program, this.programView); + this.program.update().then( + data => { + log.info({ data }, "Program updated"); + }, + err => { + log.error({ err }, "error updating Program"); + } + ); + this.stopEditing(); + } - @action.bound - private onNameChange(e: any, p: InputOnChangeData) { - if (this.programView) { - this.programView.name = p.value; - } + @action.bound + private stopEditing() { + this.props.history.push({ search: "" }); + } + + @action.bound + private close() { + const { deviceId } = this.props.match.params; + this.props.history.push({ pathname: route.device(deviceId), search: "" }); + } + + @action.bound + private onNameChange(e: any, p: InputOnChangeData) { + if (this.programView) { + this.programView.name = p.value; } + } - @action.bound - private onEnabledChange(e: any, p: CheckboxProps) { - if (this.programView) { - this.programView.enabled = p.checked!; - } + @action.bound + private onEnabledChange(e: any, p: CheckboxProps) { + if (this.programView) { + this.programView.enabled = p.checked!; } + } } const DecoratedProgramPage = injectState(observer(ProgramPage)); diff --git a/client/polyfills.js b/client/polyfills.js index 49df7d6..87a0cc6 100644 --- a/client/polyfills.js +++ b/client/polyfills.js @@ -13,4 +13,4 @@ if (typeof Promise === 'undefined') { // Object.assign() is commonly used with React. // It will use the native implementation if it's present and isn't buggy. -Object.assign = require('object-assign'); +Object.assign = require('object-assign'); \ No newline at end of file diff --git a/client/routePaths.ts b/client/routePaths.ts index ee50b39..1cc9c71 100644 --- a/client/routePaths.ts +++ b/client/routePaths.ts @@ -1,11 +1,11 @@ export interface RouteParams { - deviceId: string; - programId: string; + deviceId: string; + programId: string; } export const routerRouteParams: RouteParams = { - deviceId: ":deviceId", - programId: ":programId", + deviceId: ":deviceId", + programId: ":programId" }; export const home = "/"; @@ -15,9 +15,12 @@ export const login = "/login"; export const logout = "/logout"; export function device(deviceId?: string | number): string { - return `/devices/${deviceId || ""}`; + return `/devices/${deviceId || ""}`; } -export function program(deviceId: string | number, programId?: string | number): string { - return `${device(deviceId)}/programs/${programId}`; +export function program( + deviceId: string | number, + programId?: string | number +): string { + return `${device(deviceId)}/programs/${programId}`; } diff --git a/client/sprinklersRpc/WSSprinklersDevice.ts b/client/sprinklersRpc/WSSprinklersDevice.ts index 97b032d..2ec1633 100644 --- a/client/sprinklersRpc/WSSprinklersDevice.ts +++ b/client/sprinklersRpc/WSSprinklersDevice.ts @@ -8,66 +8,71 @@ import { log, WebSocketRpcClient } from "./WebSocketRpcClient"; // tslint:disable:member-ordering export class WSSprinklersDevice extends s.SprinklersDevice { - readonly api: WebSocketRpcClient; + readonly api: WebSocketRpcClient; - constructor(api: WebSocketRpcClient, id: string) { - super(api, id); - this.api = api; + constructor(api: WebSocketRpcClient, id: string) { + super(api, id); + this.api = api; - autorun(this.updateConnectionState); - this.waitSubscribe(); - } + autorun(this.updateConnectionState); + this.waitSubscribe(); + } - private updateConnectionState = () => { - const { clientToServer, serverToBroker } = this.api.connectionState; - runInAction("updateConnectionState", () => { - Object.assign(this.connectionState, { clientToServer, serverToBroker }); - }); - } + private updateConnectionState = () => { + const { clientToServer, serverToBroker } = this.api.connectionState; + runInAction("updateConnectionState", () => { + Object.assign(this.connectionState, { clientToServer, serverToBroker }); + }); + }; - async subscribe() { - const subscribeRequest: ws.IDeviceSubscribeRequest = { - deviceId: this.id, - }; - try { - await this.api.makeRequest("deviceSubscribe", subscribeRequest); - runInAction("deviceSubscribeSuccess", () => { - this.connectionState.hasPermission = true; - }); - } catch (err) { - runInAction("deviceSubscribeError", () => { - this.connectionState.brokerToDevice = false; - if ((err as ws.IError).code === ErrorCode.NoPermission) { - this.connectionState.hasPermission = false; - } else { - log.error({ err }); - } - }); + async subscribe() { + const subscribeRequest: ws.IDeviceSubscribeRequest = { + deviceId: this.id + }; + try { + await this.api.makeRequest("deviceSubscribe", subscribeRequest); + runInAction("deviceSubscribeSuccess", () => { + this.connectionState.hasPermission = true; + }); + } catch (err) { + runInAction("deviceSubscribeError", () => { + this.connectionState.brokerToDevice = false; + if ((err as ws.IError).code === ErrorCode.NoPermission) { + this.connectionState.hasPermission = false; + } else { + log.error({ err }); } + }); } + } - async unsubscribe() { - const unsubscribeRequest: ws.IDeviceSubscribeRequest = { - deviceId: this.id, - }; - try { - await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest); - runInAction("deviceUnsubscribeSuccess", () => { - this.connectionState.brokerToDevice = false; - }); - } catch (err) { - log.error({ err }, "error unsubscribing from device"); - } + async unsubscribe() { + const unsubscribeRequest: ws.IDeviceSubscribeRequest = { + deviceId: this.id + }; + try { + await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest); + runInAction("deviceUnsubscribeSuccess", () => { + this.connectionState.brokerToDevice = false; + }); + } catch (err) { + log.error({ err }, "error unsubscribing from device"); } + } - makeRequest(request: deviceRequests.Request): Promise { - return this.api.makeDeviceCall(this.id, request); - } + makeRequest( + request: deviceRequests.Request + ): Promise { + return this.api.makeDeviceCall(this.id, request); + } - waitSubscribe = () => { - when(() => this.api.authenticated, () => { - this.subscribe(); - when(() => !this.api.authenticated, this.waitSubscribe); - }); - } + waitSubscribe = () => { + when( + () => this.api.authenticated, + () => { + this.subscribe(); + when(() => !this.api.authenticated, this.waitSubscribe); + } + ); + }; } diff --git a/client/sprinklersRpc/WebSocketRpcClient.ts b/client/sprinklersRpc/WebSocketRpcClient.ts index 9e98840..2723f86 100644 --- a/client/sprinklersRpc/WebSocketRpcClient.ts +++ b/client/sprinklersRpc/WebSocketRpcClient.ts @@ -11,7 +11,11 @@ import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; import * as schema from "@common/sprinklersRpc/schema/"; import { seralizeRequest } from "@common/sprinklersRpc/schema/requests"; import * as ws from "@common/sprinklersRpc/websocketData"; -import { DefaultEvents, TypedEventEmitter, typedEventEmitter } from "@common/TypedEventEmitter"; +import { + DefaultEvents, + TypedEventEmitter, + typedEventEmitter +} from "@common/TypedEventEmitter"; import { WSSprinklersDevice } from "./WSSprinklersDevice"; export const log = logger.child({ source: "websocket" }); @@ -20,288 +24,310 @@ const TIMEOUT_MS = 5000; const RECONNECT_TIMEOUT_MS = 5000; const isDev = process.env.NODE_ENV === "development"; -const websocketProtocol = (location.protocol === "https:") ? "wss:" : "ws:"; +const websocketProtocol = location.protocol === "https:" ? "wss:" : "ws:"; const websocketPort = isDev ? 8080 : location.port; -const DEFAULT_URL = `${websocketProtocol}//${location.hostname}:${websocketPort}`; +const DEFAULT_URL = `${websocketProtocol}//${ + location.hostname +}:${websocketPort}`; export interface WebSocketRpcClientEvents extends DefaultEvents { - newUserData(userData: IUser): void; - rpcError(error: s.RpcError): void; - tokenError(error: s.RpcError): void; + newUserData(userData: IUser): void; + rpcError(error: s.RpcError): void; + tokenError(error: s.RpcError): void; } // tslint:disable:member-ordering -export interface WebSocketRpcClient extends TypedEventEmitter { -} +export interface WebSocketRpcClient + extends TypedEventEmitter {} @typedEventEmitter export class WebSocketRpcClient extends s.SprinklersRPC { - @computed - get connected(): boolean { - return this.connectionState.isServerConnected || false; - } - - readonly webSocketUrl: string; - - devices: Map = new Map(); - @observable connectionState: s.ConnectionState = new s.ConnectionState(); - socket: WebSocket | null = null; - - @observable - authenticated: boolean = false; - - tokenStore: TokenStore; - - private nextRequestId = Math.round(Math.random() * 1000000); - private responseCallbacks: ws.ServerResponseHandlers = {}; - private reconnectTimer: number | null = null; - - @action - private onDisconnect = action(() => { - this.connectionState.serverToBroker = null; - this.connectionState.clientToServer = false; - this.authenticated = false; + @computed + get connected(): boolean { + return this.connectionState.isServerConnected || false; + } + + readonly webSocketUrl: string; + + devices: Map = new Map(); + @observable + connectionState: s.ConnectionState = new s.ConnectionState(); + socket: WebSocket | null = null; + + @observable + authenticated: boolean = false; + + tokenStore: TokenStore; + + private nextRequestId = Math.round(Math.random() * 1000000); + private responseCallbacks: ws.ServerResponseHandlers = {}; + private reconnectTimer: number | null = null; + + @action + private onDisconnect = action(() => { + this.connectionState.serverToBroker = null; + this.connectionState.clientToServer = false; + this.authenticated = false; + }); + + private notificationHandlers = new WSClientNotificationHandlers(this); + + constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) { + super(); + this.webSocketUrl = webSocketUrl; + this.tokenStore = tokenStore; + this.connectionState.clientToServer = false; + this.connectionState.serverToBroker = false; + + this.on("rpcError", (err: s.RpcError) => { + if (err.code === ErrorCode.BadToken) { + this.emit("tokenError", err); + } }); + } - private notificationHandlers = new WSClientNotificationHandlers(this); - - constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) { - super(); - this.webSocketUrl = webSocketUrl; - this.tokenStore = tokenStore; - this.connectionState.clientToServer = false; - this.connectionState.serverToBroker = false; - - this.on("rpcError", (err: s.RpcError) => { - if (err.code === ErrorCode.BadToken) { - this.emit("tokenError", err); - } - }); - } - - start() { - this._connect(); - } - - stop() { - if (this.reconnectTimer != null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - if (this.socket != null) { - this.socket.close(); - this.socket = null; - } - } - - acquireDevice = s.SprinklersRPC.prototype.acquireDevice; - - protected getDevice(id: string): s.SprinklersDevice { - let device = this.devices.get(id); - if (!device) { - device = new WSSprinklersDevice(this, id); - this.devices.set(id, device); - } - return device; - } + start() { + this._connect(); + } - releaseDevice(id: string): void { - const device = this.devices.get(id); - if (!device) return; - device.unsubscribe() - .then(() => { - log.debug({ id }, "released device"); - this.devices.delete(id); - }); + stop() { + if (this.reconnectTimer != null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; } - - async authenticate(accessToken: string): Promise { - return this.makeRequest("authenticate", { accessToken }); - } - - async tryAuthenticate() { - when(() => this.connectionState.clientToServer === true - && this.tokenStore.accessToken.isValid, async () => { - try { - const res = await this.authenticate(this.tokenStore.accessToken.token!); - runInAction("authenticateSuccess", () => { - this.authenticated = res.authenticated; - }); - logger.info({ user: res.user }, "authenticated websocket connection"); - this.emit("newUserData", res.user); - } catch (err) { - logger.error({ err }, "error authenticating websocket connection"); - // TODO message? - runInAction("authenticateError", () => { - this.authenticated = false; - }); - } - }); + if (this.socket != null) { + this.socket.close(); + this.socket = null; } + } - // args must all be JSON serializable - async makeDeviceCall(deviceId: string, request: deviceRequests.Request): Promise { - if (this.socket == null) { - const error: ws.IError = { - code: ErrorCode.ServerDisconnected, - message: "the server is not connected", - }; - throw new s.RpcError("the server is not connected", ErrorCode.ServerDisconnected); - } - const requestData = seralizeRequest(request); - const data: ws.IDeviceCallRequest = { deviceId, data: requestData }; - const resData = await this.makeRequest("deviceCall", data); - if (resData.data.result === "error") { - throw new s.RpcError(resData.data.message, resData.data.code, resData.data); - } else { - return resData.data; - } - } + acquireDevice = s.SprinklersRPC.prototype.acquireDevice; - makeRequest(method: Method, params: ws.IClientRequestTypes[Method]): - Promise { - const id = this.nextRequestId++; - return new Promise((resolve, reject) => { - let timeoutHandle: number; - this.responseCallbacks[id] = (response) => { - clearTimeout(timeoutHandle); - delete this.responseCallbacks[id]; - if (response.result === "success") { - resolve(response.data); - } else { - const { error } = response; - reject(new s.RpcError(error.message, error.code, error.data)); - } - }; - timeoutHandle = window.setTimeout(() => { - delete this.responseCallbacks[id]; - reject(new s.RpcError("the request timed out", ErrorCode.Timeout)); - }, TIMEOUT_MS); - this.sendRequest(id, method, params); - }) - .catch((err) => { - if (err instanceof s.RpcError) { - this.emit("rpcError", err); - } - throw err; - }); + protected getDevice(id: string): s.SprinklersDevice { + let device = this.devices.get(id); + if (!device) { + device = new WSSprinklersDevice(this, id); + this.devices.set(id, device); } - - private sendMessage(data: ws.ClientMessage) { - if (!this.socket) { - throw new Error("WebSocketApiClient is not connected"); + return device; + } + + releaseDevice(id: string): void { + const device = this.devices.get(id); + if (!device) return; + device.unsubscribe().then(() => { + log.debug({ id }, "released device"); + this.devices.delete(id); + }); + } + + async authenticate(accessToken: string): Promise { + return this.makeRequest("authenticate", { accessToken }); + } + + async tryAuthenticate() { + when( + () => + this.connectionState.clientToServer === true && + this.tokenStore.accessToken.isValid, + async () => { + try { + const res = await this.authenticate( + this.tokenStore.accessToken.token! + ); + runInAction("authenticateSuccess", () => { + this.authenticated = res.authenticated; + }); + logger.info({ user: res.user }, "authenticated websocket connection"); + this.emit("newUserData", res.user); + } catch (err) { + logger.error({ err }, "error authenticating websocket connection"); + // TODO message? + runInAction("authenticateError", () => { + this.authenticated = false; + }); } - this.socket.send(JSON.stringify(data)); + } + ); + } + + // args must all be JSON serializable + async makeDeviceCall( + deviceId: string, + request: deviceRequests.Request + ): Promise { + if (this.socket == null) { + const error: ws.IError = { + code: ErrorCode.ServerDisconnected, + message: "the server is not connected" + }; + throw new s.RpcError( + "the server is not connected", + ErrorCode.ServerDisconnected + ); } - - private sendRequest( - id: number, method: Method, params: ws.IClientRequestTypes[Method], - ) { - this.sendMessage({ type: "request", id, method, params }); - } - - private _reconnect = () => { - this._connect(); + const requestData = seralizeRequest(request); + const data: ws.IDeviceCallRequest = { deviceId, data: requestData }; + const resData = await this.makeRequest("deviceCall", data); + if (resData.data.result === "error") { + throw new s.RpcError( + resData.data.message, + resData.data.code, + resData.data + ); + } else { + return resData.data; } - - private _connect() { - if (this.socket != null && - (this.socket.readyState === WebSocket.OPEN)) { - this.tryAuthenticate(); - return; + } + + makeRequest( + method: Method, + params: ws.IClientRequestTypes[Method] + ): Promise { + const id = this.nextRequestId++; + return new Promise((resolve, reject) => { + let timeoutHandle: number; + this.responseCallbacks[id] = response => { + clearTimeout(timeoutHandle); + delete this.responseCallbacks[id]; + if (response.result === "success") { + resolve(response.data); + } else { + const { error } = response; + reject(new s.RpcError(error.message, error.code, error.data)); } - log.debug({ url: this.webSocketUrl }, "connecting to websocket"); - this.socket = new WebSocket(this.webSocketUrl); - this.socket.onopen = this.onOpen.bind(this); - this.socket.onclose = this.onClose.bind(this); - this.socket.onerror = this.onError.bind(this); - this.socket.onmessage = this.onMessage.bind(this); - } + }; + timeoutHandle = window.setTimeout(() => { + delete this.responseCallbacks[id]; + reject(new s.RpcError("the request timed out", ErrorCode.Timeout)); + }, TIMEOUT_MS); + this.sendRequest(id, method, params); + }).catch(err => { + if (err instanceof s.RpcError) { + this.emit("rpcError", err); + } + throw err; + }); + } - @action - private onOpen() { - log.info("established websocket connection"); - this.connectionState.clientToServer = true; - this.authenticated = false; - this.tryAuthenticate(); + private sendMessage(data: ws.ClientMessage) { + if (!this.socket) { + throw new Error("WebSocketApiClient is not connected"); } - - private onClose(event: CloseEvent) { - log.info({ event }, - "disconnected from websocket"); - this.onDisconnect(); - this.reconnectTimer = window.setTimeout(this._reconnect, RECONNECT_TIMEOUT_MS); + this.socket.send(JSON.stringify(data)); + } + + private sendRequest( + id: number, + method: Method, + params: ws.IClientRequestTypes[Method] + ) { + this.sendMessage({ type: "request", id, method, params }); + } + + private _reconnect = () => { + this._connect(); + }; + + private _connect() { + if (this.socket != null && this.socket.readyState === WebSocket.OPEN) { + this.tryAuthenticate(); + return; } - - @action - private onError(event: Event) { - log.error({ event }, "websocket error"); - this.connectionState.serverToBroker = null; - this.connectionState.clientToServer = false; - this.onDisconnect(); + log.debug({ url: this.webSocketUrl }, "connecting to websocket"); + this.socket = new WebSocket(this.webSocketUrl); + this.socket.onopen = this.onOpen.bind(this); + this.socket.onclose = this.onClose.bind(this); + this.socket.onerror = this.onError.bind(this); + this.socket.onmessage = this.onMessage.bind(this); + } + + @action + private onOpen() { + log.info("established websocket connection"); + this.connectionState.clientToServer = true; + this.authenticated = false; + this.tryAuthenticate(); + } + + private onClose(event: CloseEvent) { + log.info({ event }, "disconnected from websocket"); + this.onDisconnect(); + this.reconnectTimer = window.setTimeout( + this._reconnect, + RECONNECT_TIMEOUT_MS + ); + } + + @action + private onError(event: Event) { + log.error({ event }, "websocket error"); + this.connectionState.serverToBroker = null; + this.connectionState.clientToServer = false; + this.onDisconnect(); + } + + private onMessage(event: MessageEvent) { + let data: ws.ServerMessage; + try { + data = JSON.parse(event.data); + } catch (err) { + return log.error({ event, err }, "received invalid websocket message"); } - - private onMessage(event: MessageEvent) { - let data: ws.ServerMessage; - try { - data = JSON.parse(event.data); - } catch (err) { - return log.error({ event, err }, "received invalid websocket message"); - } - log.trace({ data }, "websocket message"); - switch (data.type) { - case "notification": - this.onNotification(data); - break; - case "response": - this.onResponse(data); - break; - default: - log.warn({ data }, "unsupported event type received"); - } + log.trace({ data }, "websocket message"); + switch (data.type) { + case "notification": + this.onNotification(data); + break; + case "response": + this.onResponse(data); + break; + default: + log.warn({ data }, "unsupported event type received"); } + } - private onNotification(data: ws.ServerNotification) { - try { - rpc.handleNotification(this.notificationHandlers, data); - } catch (err) { - logger.error(err, "error handling server notification"); - } + private onNotification(data: ws.ServerNotification) { + try { + rpc.handleNotification(this.notificationHandlers, data); + } catch (err) { + logger.error(err, "error handling server notification"); } + } - private onResponse(data: ws.ServerResponse) { - try { - rpc.handleResponse(this.responseCallbacks, data); - } catch (err) { - log.error({ err }, "error handling server response"); - } + private onResponse(data: ws.ServerResponse) { + try { + rpc.handleResponse(this.responseCallbacks, data); + } catch (err) { + log.error({ err }, "error handling server response"); } + } } -class WSClientNotificationHandlers implements ws.ServerNotificationHandlers { - client: WebSocketRpcClient; +class WSClientNotificationHandlers implements ws.ServerNotificationHandlers { + client: WebSocketRpcClient; - constructor(client: WebSocketRpcClient) { - this.client = client; - } + constructor(client: WebSocketRpcClient) { + this.client = client; + } - @action.bound - brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) { - this.client.connectionState.serverToBroker = data.brokerConnected; - } + @action.bound + brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) { + this.client.connectionState.serverToBroker = data.brokerConnected; + } - @action.bound - deviceUpdate(data: ws.IDeviceUpdate) { - const device = this.client.devices.get(data.deviceId); - if (!device) { - return log.warn({ data }, "invalid deviceUpdate received"); - } - update(schema.sprinklersDevice, device, data.data); + @action.bound + deviceUpdate(data: ws.IDeviceUpdate) { + const device = this.client.devices.get(data.deviceId); + if (!device) { + return log.warn({ data }, "invalid deviceUpdate received"); } + update(schema.sprinklersDevice, device, data.data); + } - error(data: ws.IError) { - log.warn({ err: data }, "server error"); - } + error(data: ws.IError) { + log.warn({ err: data }, "server error"); + } } diff --git a/client/state/AppState.ts b/client/state/AppState.ts index 4de305a..5b3e6e8 100644 --- a/client/state/AppState.ts +++ b/client/state/AppState.ts @@ -12,81 +12,84 @@ import log from "@common/logger"; import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter"; interface AppEvents extends DefaultEvents { - checkToken(): void; - hasToken(): void; + checkToken(): void; + hasToken(): void; } export default class AppState extends TypedEventEmitter { - history: History = createBrowserHistory(); - routerStore = new RouterStore(); - uiStore = new UiStore(); - userStore = new UserStore(); - httpApi = new HttpApi(); - tokenStore = this.httpApi.tokenStore; - sprinklersRpc = new WebSocketRpcClient(this.tokenStore); + history: History = createBrowserHistory(); + routerStore = new RouterStore(); + uiStore = new UiStore(); + userStore = new UserStore(); + httpApi = new HttpApi(); + tokenStore = this.httpApi.tokenStore; + sprinklersRpc = new WebSocketRpcClient(this.tokenStore); - constructor() { - super(); - this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData); - this.sprinklersRpc.on("tokenError", this.clearToken); - this.httpApi.on("tokenGranted", () => this.emit("hasToken")); - this.httpApi.on("tokenError", this.clearToken); + constructor() { + super(); + this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData); + this.sprinklersRpc.on("tokenError", this.clearToken); + this.httpApi.on("tokenGranted", () => this.emit("hasToken")); + this.httpApi.on("tokenError", this.clearToken); - this.on("checkToken", this.doCheckToken); + this.on("checkToken", this.doCheckToken); - this.on("hasToken", () => { - when(() => !this.tokenStore.accessToken.isValid, this.checkToken); - this.sprinklersRpc.start(); - }); - } + this.on("hasToken", () => { + when(() => !this.tokenStore.accessToken.isValid, this.checkToken); + this.sprinklersRpc.start(); + }); + } - @computed get isLoggedIn() { - return this.tokenStore.accessToken.isValid; - } + @computed + get isLoggedIn() { + return this.tokenStore.accessToken.isValid; + } - async start() { - configure({ - enforceActions: true, - }); + async start() { + configure({ + enforceActions: true + }); - syncHistoryWithStore(this.history, this.routerStore); - await this.tokenStore.loadLocalStorage(); + syncHistoryWithStore(this.history, this.routerStore); + await this.tokenStore.loadLocalStorage(); - await this.checkToken(); - } + await this.checkToken(); + } - clearToken = (err?: any) => { - this.tokenStore.clearAccessToken(); - this.checkToken(); - } + clearToken = (err?: any) => { + this.tokenStore.clearAccessToken(); + this.checkToken(); + }; - checkToken = () => { - this.emit("checkToken"); - } + checkToken = () => { + this.emit("checkToken"); + }; - private doCheckToken = async () => { - const { accessToken, refreshToken } = this.tokenStore; - accessToken.updateCurrentTime(); - if (accessToken.isValid) { // if the access token is valid, we are good - this.emit("hasToken"); - return; - } - if (!refreshToken.isValid) { // if the refresh token is not valid, need to login again - this.history.push("/login"); - return; - } - try { - await this.httpApi.grantRefresh(); - this.emit("hasToken"); - } catch (err) { - if (err instanceof ApiError && err.code === ErrorCode.BadToken) { - log.warn({ err }, "refresh is bad for some reason, erasing"); - this.tokenStore.clearAll(); - this.history.push("/login"); - } else { - log.error({ err }, "could not refresh access token"); - // TODO: some kind of error page? - } - } + private doCheckToken = async () => { + const { accessToken, refreshToken } = this.tokenStore; + accessToken.updateCurrentTime(); + if (accessToken.isValid) { + // if the access token is valid, we are good + this.emit("hasToken"); + return; + } + if (!refreshToken.isValid) { + // if the refresh token is not valid, need to login again + this.history.push("/login"); + return; + } + try { + await this.httpApi.grantRefresh(); + this.emit("hasToken"); + } catch (err) { + if (err instanceof ApiError && err.code === ErrorCode.BadToken) { + log.warn({ err }, "refresh is bad for some reason, erasing"); + this.tokenStore.clearAll(); + this.history.push("/login"); + } else { + log.error({ err }, "could not refresh access token"); + // TODO: some kind of error page? + } } + }; } diff --git a/client/state/HttpApi.ts b/client/state/HttpApi.ts index b70aa4f..c58ecaa 100644 --- a/client/state/HttpApi.ts +++ b/client/state/HttpApi.ts @@ -3,114 +3,145 @@ import { action } from "mobx"; import { TokenStore } from "@client/state/TokenStore"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; -import { TokenGrantPasswordRequest, TokenGrantRefreshRequest, TokenGrantResponse } from "@common/httpApi"; +import { + TokenGrantPasswordRequest, + TokenGrantRefreshRequest, + TokenGrantResponse +} from "@common/httpApi"; import log from "@common/logger"; import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter"; export { ApiError }; interface HttpApiEvents extends DefaultEvents { - tokenGranted(response: TokenGrantResponse): void; - error(err: ApiError): void; - tokenError(err: ApiError): void; + tokenGranted(response: TokenGrantResponse): void; + error(err: ApiError): void; + tokenError(err: ApiError): void; } export default class HttpApi extends TypedEventEmitter { - baseUrl: string; + baseUrl: string; - tokenStore: TokenStore; + tokenStore: TokenStore; - private get authorizationHeader(): {} | { "Authorization": string } { - if (!this.tokenStore.accessToken) { - return {}; - } - return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` }; + private get authorizationHeader(): {} | { Authorization: string } { + if (!this.tokenStore.accessToken) { + return {}; } + return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` }; + } - constructor(baseUrl: string = `${location.protocol}//${location.hostname}:${location.port}/api`) { - super(); - while (baseUrl.charAt(baseUrl.length - 1) === "/") { - baseUrl = baseUrl.substring(0, baseUrl.length - 1); - } - this.baseUrl = baseUrl; + constructor( + baseUrl: string = `${location.protocol}//${location.hostname}:${ + location.port + }/api` + ) { + super(); + while (baseUrl.charAt(baseUrl.length - 1) === "/") { + baseUrl = baseUrl.substring(0, baseUrl.length - 1); + } + this.baseUrl = baseUrl; - this.tokenStore = new TokenStore(); + this.tokenStore = new TokenStore(); - this.on("error", (err: ApiError) => { - if (err.code === ErrorCode.BadToken) { - this.emit("tokenError", err); - } - }); + this.on("error", (err: ApiError) => { + if (err.code === ErrorCode.BadToken) { + this.emit("tokenError", err); + } + }); - this.on("tokenGranted", this.onTokenGranted); - } + this.on("tokenGranted", this.onTokenGranted); + } - async makeRequest(url: string, options?: RequestInit, body?: any): Promise { - try { - options = options || {}; - options = { - headers: { - "Content-Type": "application/json", - ...this.authorizationHeader, - ...options.headers || {}, - }, - body: JSON.stringify(body), - ...options, - }; - let response: Response; - try { - response = await fetch(this.baseUrl + url, options); - } catch (err) { - throw new ApiError("Http request error", ErrorCode.Internal, err); - } - let responseBody: any; - try { - responseBody = await response.json() || {}; - } catch (e) { - throw new ApiError("Invalid JSON response", ErrorCode.Internal, e); - } - if (!response.ok) { - throw new ApiError(responseBody.message || response.statusText, responseBody.code, responseBody.data); - } - return responseBody; - } catch (err) { - this.emit("error", err); - throw err; - } + async makeRequest( + url: string, + options?: RequestInit, + body?: any + ): Promise { + try { + options = options || {}; + options = { + headers: { + "Content-Type": "application/json", + ...this.authorizationHeader, + ...(options.headers || {}) + }, + body: JSON.stringify(body), + ...options + }; + let response: Response; + try { + response = await fetch(this.baseUrl + url, options); + } catch (err) { + throw new ApiError("Http request error", ErrorCode.Internal, err); + } + let responseBody: any; + try { + responseBody = (await response.json()) || {}; + } catch (e) { + throw new ApiError("Invalid JSON response", ErrorCode.Internal, e); + } + if (!response.ok) { + throw new ApiError( + responseBody.message || response.statusText, + responseBody.code, + responseBody.data + ); + } + return responseBody; + } catch (err) { + this.emit("error", err); + throw err; } + } - async grantPassword(username: string, password: string) { - const request: TokenGrantPasswordRequest = { - grant_type: "password", username, password, - }; - const response: TokenGrantResponse = await this.makeRequest("/token/grant", { - method: "POST", - }, request); - this.emit("tokenGranted", response); - } + async grantPassword(username: string, password: string) { + const request: TokenGrantPasswordRequest = { + grant_type: "password", + username, + password + }; + const response: TokenGrantResponse = await this.makeRequest( + "/token/grant", + { + method: "POST" + }, + request + ); + this.emit("tokenGranted", response); + } - async grantRefresh() { - const { refreshToken } = this.tokenStore; - if (!refreshToken.isValid) { - throw new ApiError("can not grant refresh with invalid refresh_token"); - } - const request: TokenGrantRefreshRequest = { - grant_type: "refresh", refresh_token: refreshToken.token!, - }; - const response: TokenGrantResponse = await this.makeRequest("/token/grant", { - method: "POST", - }, request); - this.emit("tokenGranted", response); + async grantRefresh() { + const { refreshToken } = this.tokenStore; + if (!refreshToken.isValid) { + throw new ApiError("can not grant refresh with invalid refresh_token"); } + const request: TokenGrantRefreshRequest = { + grant_type: "refresh", + refresh_token: refreshToken.token! + }; + const response: TokenGrantResponse = await this.makeRequest( + "/token/grant", + { + method: "POST" + }, + request + ); + this.emit("tokenGranted", response); + } - @action.bound - private onTokenGranted(response: TokenGrantResponse) { - this.tokenStore.accessToken.token = response.access_token; - this.tokenStore.refreshToken.token = response.refresh_token; - this.tokenStore.saveLocalStorage(); - const { accessToken, refreshToken } = this.tokenStore; - log.debug({ - accessToken: accessToken.claims, refreshToken: refreshToken.claims, - }, "got new tokens"); - } + @action.bound + private onTokenGranted(response: TokenGrantResponse) { + this.tokenStore.accessToken.token = response.access_token; + this.tokenStore.refreshToken.token = response.refresh_token; + this.tokenStore.saveLocalStorage(); + const { accessToken, refreshToken } = this.tokenStore; + log.debug( + { + accessToken: accessToken.claims, + refreshToken: refreshToken.claims + }, + "got new tokens" + ); + } } diff --git a/client/state/Token.ts b/client/state/Token.ts index 3c04932..92997c6 100644 --- a/client/state/Token.ts +++ b/client/state/Token.ts @@ -3,67 +3,76 @@ import * as jwt from "jsonwebtoken"; import { computed, createAtom, IAtom, observable } from "mobx"; export class Token { - @observable token: string | null; + @observable + token: string | null; - @computed get claims(): TClaims | null { - if (this.token == null) { - return null; - } - return jwt.decode(this.token) as any; + @computed + get claims(): TClaims | null { + if (this.token == null) { + return null; } + return jwt.decode(this.token) as any; + } - private isExpiredAtom: IAtom; - private currentTime!: number; - private expirationTimer: number | undefined; + private isExpiredAtom: IAtom; + private currentTime!: number; + private expirationTimer: number | undefined; - constructor(token: string | null = null) { - this.token = token; - this.isExpiredAtom = createAtom("Token.isExpired", - this.startUpdating, this.stopUpdating); - this.updateCurrentTime(); - } + constructor(token: string | null = null) { + this.token = token; + this.isExpiredAtom = createAtom( + "Token.isExpired", + this.startUpdating, + this.stopUpdating + ); + this.updateCurrentTime(); + } - toJSON() { - return this.token; - } + toJSON() { + return this.token; + } - updateCurrentTime = (reportChanged: boolean = true) => { - if (reportChanged) { - this.isExpiredAtom.reportChanged(); - } - this.currentTime = Date.now() / 1000; + updateCurrentTime = (reportChanged: boolean = true) => { + if (reportChanged) { + this.isExpiredAtom.reportChanged(); } + this.currentTime = Date.now() / 1000; + }; - get remainingTime(): number { - if (!this.isExpiredAtom.reportObserved()) { - this.updateCurrentTime(false); - } - if (this.claims == null || this.claims.exp == null) { - return Number.NEGATIVE_INFINITY; - } - return this.claims.exp - this.currentTime; + get remainingTime(): number { + if (!this.isExpiredAtom.reportObserved()) { + this.updateCurrentTime(false); } - - private startUpdating = () => { - this.stopUpdating(); - const remaining = this.remainingTime; - if (remaining > 0) { - this.expirationTimer = setTimeout(this.updateCurrentTime, this.remainingTime); - } + if (this.claims == null || this.claims.exp == null) { + return Number.NEGATIVE_INFINITY; } + return this.claims.exp - this.currentTime; + } - private stopUpdating = () => { - if (this.expirationTimer != null) { - clearTimeout(this.expirationTimer); - this.expirationTimer = undefined; - } + private startUpdating = () => { + this.stopUpdating(); + const remaining = this.remainingTime; + if (remaining > 0) { + this.expirationTimer = setTimeout( + this.updateCurrentTime, + this.remainingTime + ); } + }; - get isExpired() { - return this.remainingTime <= 0; + private stopUpdating = () => { + if (this.expirationTimer != null) { + clearTimeout(this.expirationTimer); + this.expirationTimer = undefined; } + }; - @computed get isValid() { - return this.token != null && !this.isExpired; - } + get isExpired() { + return this.remainingTime <= 0; + } + + @computed + get isValid() { + return this.token != null && !this.isExpired; + } } diff --git a/client/state/TokenStore.ts b/client/state/TokenStore.ts index ff64232..55bc1f0 100644 --- a/client/state/TokenStore.ts +++ b/client/state/TokenStore.ts @@ -6,43 +6,51 @@ import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims"; const LOCAL_STORAGE_KEY = "TokenStore"; export class TokenStore { - @observable accessToken: Token = new Token(); - @observable refreshToken: Token = new Token(); - - @action - clearAccessToken() { - this.accessToken.token = null; - this.saveLocalStorage(); - } - - @action - clearAll() { - this.accessToken.token = null; - this.refreshToken.token = null; - this.saveLocalStorage(); - } - - @action - saveLocalStorage() { - window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.toJSON())); - } - - @action - loadLocalStorage() { - const data = window.localStorage.getItem(LOCAL_STORAGE_KEY); - if (data) { - const data2 = JSON.parse(data); - this.updateFromJson(data2); - } - } - - toJSON() { - return { accessToken: this.accessToken.toJSON(), refreshToken: this.refreshToken.toJSON() }; - } - - @action - updateFromJson(json: any) { - this.accessToken.token = json.accessToken; - this.refreshToken.token = json.refreshToken; + @observable + accessToken: Token = new Token(); + @observable + refreshToken: Token = new Token(); + + @action + clearAccessToken() { + this.accessToken.token = null; + this.saveLocalStorage(); + } + + @action + clearAll() { + this.accessToken.token = null; + this.refreshToken.token = null; + this.saveLocalStorage(); + } + + @action + saveLocalStorage() { + window.localStorage.setItem( + LOCAL_STORAGE_KEY, + JSON.stringify(this.toJSON()) + ); + } + + @action + loadLocalStorage() { + const data = window.localStorage.getItem(LOCAL_STORAGE_KEY); + if (data) { + const data2 = JSON.parse(data); + this.updateFromJson(data2); } + } + + toJSON() { + return { + accessToken: this.accessToken.toJSON(), + refreshToken: this.refreshToken.toJSON() + }; + } + + @action + updateFromJson(json: any) { + this.accessToken.token = json.accessToken; + this.refreshToken.token = json.refreshToken; + } } diff --git a/client/state/UiStore.ts b/client/state/UiStore.ts index 0815018..714ffdd 100644 --- a/client/state/UiStore.ts +++ b/client/state/UiStore.ts @@ -4,34 +4,34 @@ import { MessageProps } from "semantic-ui-react"; import { getRandomId } from "@common/utils"; export interface UiMessage extends MessageProps { - id: number; + id: number; } export interface UiMessageProps extends MessageProps { - timeout?: number; + timeout?: number; } export class UiStore { - messages: IObservableArray = observable.array(); + messages: IObservableArray = observable.array(); - @action - addMessage(message: UiMessageProps): UiMessage { - const { timeout, ...otherProps } = message; - const msg = observable({ - ...otherProps, - id: getRandomId(), - }); - this.messages.push(msg); - if (timeout) { - setTimeout(() => { - this.removeMessage(msg); - }, timeout); - } - return msg; + @action + addMessage(message: UiMessageProps): UiMessage { + const { timeout, ...otherProps } = message; + const msg = observable({ + ...otherProps, + id: getRandomId() + }); + this.messages.push(msg); + if (timeout) { + setTimeout(() => { + this.removeMessage(msg); + }, timeout); } + return msg; + } - @action - removeMessage(message: UiMessage) { - return this.messages.remove(message); - } + @action + removeMessage(message: UiMessage) { + return this.messages.remove(message); + } } diff --git a/client/state/UserStore.ts b/client/state/UserStore.ts index 4f835cd..7b02cc5 100644 --- a/client/state/UserStore.ts +++ b/client/state/UserStore.ts @@ -2,16 +2,20 @@ import { ISprinklersDevice, IUser } from "@common/httpApi"; import { action, observable } from "mobx"; export class UserStore { - @observable userData: IUser | null = null; + @observable + userData: IUser | null = null; - @action.bound - receiveUserData(userData: IUser) { - this.userData = userData; - } + @action.bound + receiveUserData(userData: IUser) { + this.userData = userData; + } - findDevice(id: number): ISprinklersDevice | null { - return this.userData && - this.userData.devices && - this.userData.devices.find((dev) => dev.id === id) || null; - } + findDevice(id: number): ISprinklersDevice | null { + return ( + (this.userData && + this.userData.devices && + this.userData.devices.find(dev => dev.id === id)) || + null + ); + } } diff --git a/client/state/reactContext.tsx b/client/state/reactContext.tsx index 14c7ccb..77ecb6a 100644 --- a/client/state/reactContext.tsx +++ b/client/state/reactContext.tsx @@ -5,47 +5,52 @@ import { AppState } from "@client/state"; const StateContext = React.createContext(null); export interface ProvideStateProps { - state: AppState; - children: React.ReactNode; + state: AppState; + children: React.ReactNode; } export function ProvideState({ state, children }: ProvideStateProps) { - return ( - - {children} - - ); + return ( + {children} + ); } export interface ConsumeStateProps { - children: (state: AppState) => React.ReactNode; + children: (state: AppState) => React.ReactNode; } export function ConsumeState({ children }: ConsumeStateProps) { - const consumeState = (state: AppState | null) => { - if (state == null) { - throw new Error("Component with ConsumeState must be mounted inside ProvideState"); - } - return children(state); - }; - return {consumeState}; + const consumeState = (state: AppState | null) => { + if (state == null) { + throw new Error( + "Component with ConsumeState must be mounted inside ProvideState" + ); + } + return children(state); + }; + return {consumeState}; } -type Diff = - ({[P in T]: P } & {[P in U]: never } & { [x: string]: never })[T]; -type Omit = {[P in Diff]: T[P]}; - -export function injectState

    (Component: React.ComponentType

    ): - React.ComponentClass> { - return class extends React.Component> { - render() { - const consumeState = (state: AppState | null) => { - if (state == null) { - throw new Error("Component with injectState must be mounted inside ProvideState"); - } - return ; - }; - return {consumeState}; +type Diff< + T extends string | number | symbol, + U extends string | number | symbol +> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]; +type Omit = { [P in Diff]: T[P] }; + +export function injectState

    ( + Component: React.ComponentType

    +): React.ComponentClass> { + return class extends React.Component> { + render() { + const consumeState = (state: AppState | null) => { + if (state == null) { + throw new Error( + "Component with injectState must be mounted inside ProvideState" + ); } - }; + return ; + }; + return {consumeState}; + } + }; } diff --git a/client/styles/DeviceView.scss b/client/styles/DeviceView.scss index a04286c..4b5fc26 100644 --- a/client/styles/DeviceView.scss +++ b/client/styles/DeviceView.scss @@ -11,35 +11,34 @@ } .connectionState { @media only screen and (min-width: 768px) { - margin-left: .75em; + margin-left: 0.75em; } - font-size: .75em; + font-size: 0.75em; font-weight: lighter; &.connected { - color: #13D213; + color: #13d213; } &.disconnected { - color: #D20000; + color: #d20000; } } } .section--number, .program--number { - width: 2em + width: 2em; } .section--name /*, .program--name*/ -{ + { width: 10em; white-space: nowrap; } .section--state { - } .section-state.running { diff --git a/client/styles/DurationView.scss b/client/styles/DurationView.scss index b204e80..1dc7db9 100644 --- a/client/styles/DurationView.scss +++ b/client/styles/DurationView.scss @@ -1,4 +1,4 @@ -$durationInput-spacing: 1.0em; +$durationInput-spacing: 1em; $durationInput-inputWidth: 4em; $durationInput-labelWidth: 2.5em; @@ -10,7 +10,7 @@ $durationInput-labelWidth: 2.5em; width: auto; - .ui.input.durationInput>input { + .ui.input.durationInput > input { width: 0 !important; } @@ -22,7 +22,7 @@ $durationInput-labelWidth: 2.5em; flex-shrink: 0; flex-grow: 1; - >input { + > input { min-width: $durationInput-inputWidth; width: auto; flex-basis: $durationInput-inputWidth; @@ -30,7 +30,7 @@ $durationInput-labelWidth: 2.5em; flex-shrink: 0; } - >.label { + > .label { min-width: $durationInput-labelWidth; width: $durationInput-labelWidth; flex: $durationInput-labelWidth; @@ -39,4 +39,4 @@ $durationInput-labelWidth: 2.5em; text-align: center; } } -} \ No newline at end of file +} diff --git a/client/styles/ProgramSequenceView.scss b/client/styles/ProgramSequenceView.scss index 8108fd2..16c8120 100644 --- a/client/styles/ProgramSequenceView.scss +++ b/client/styles/ProgramSequenceView.scss @@ -8,7 +8,7 @@ .programSequence-item { list-style-type: none; display: flex; - margin-bottom: .5em; + margin-bottom: 0.5em; &.dragging { z-index: 1010; } diff --git a/client/styles/ScheduleView.scss b/client/styles/ScheduleView.scss index 01f8e29..5600002 100644 --- a/client/styles/ScheduleView.scss +++ b/client/styles/ScheduleView.scss @@ -1,13 +1,14 @@ .scheduleView { - >.field, >.fields { - >label { - width: 2rem !important; - } + > .field, + > .fields { + > label { + width: 2rem !important; } + } } .scheduleTimes { - input { - margin: 0 .5rem; - } + input { + margin: 0 0.5rem; + } } diff --git a/client/styles/SectionRunnerView.scss b/client/styles/SectionRunnerView.scss index 8b9bd4d..c84d0dc 100644 --- a/client/styles/SectionRunnerView.scss +++ b/client/styles/SectionRunnerView.scss @@ -5,13 +5,13 @@ } .sectionRunner--pausedState { - padding-left: .75em; - font-size: .75em; + padding-left: 0.75em; + font-size: 0.75em; font-weight: lighter; } .sectionRunner--pausedState > .fa { - padding-right: .2em; + padding-right: 0.2em; } .sectionRunner--pausedState-unpaused { diff --git a/client/tsconfig.json b/client/tsconfig.json index 60bc6b8..4f9d006 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -26,9 +26,7 @@ ] } }, - "references": [ - { - "path": "../common" - } - ] + "references": [{ + "path": "../common" + }] } \ No newline at end of file diff --git a/client/webpack.config.js b/client/webpack.config.js index 033b6d1..e08abc4 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -11,7 +11,9 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const HappyPack = require("happypack"); const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); -const {getClientEnvironment} = require("./env"); +const { + getClientEnvironment +} = require("./env"); const paths = require("../paths"); // Webpack uses `publicPath` to determine where the app is being served from. @@ -83,49 +85,48 @@ const rules = (env) => { sassConfig, ], }; - return [ - { - // "oneOf" will traverse all following loaders until one will - // match the requirements. when no loader matches it will fall - // back to the "file" loader at the end of the loader list. - oneOf: [ - // "url" loader works like "file" loader except that it embeds assets - // smaller than specified limit in bytes as data urls to avoid requests. - // a missing `test` is equivalent to a match. - { - test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], - loader: require.resolve("url-loader"), - options: { - limit: (env === "prod") ? 10000 : 0, - name: "static/media/[name].[hash:8].[ext]", - }, - }, - cssRule, - sassRule, - // Process TypeScript with TSC through HappyPack. - { - test: /\.tsx?$/, use: "happypack/loader?id=ts", - include: [ paths.clientDir, paths.commonDir ], + return [{ + // "oneOf" will traverse all following loaders until one will + // match the requirements. when no loader matches it will fall + // back to the "file" loader at the end of the loader list. + oneOf: [ + // "url" loader works like "file" loader except that it embeds assets + // smaller than specified limit in bytes as data urls to avoid requests. + // a missing `test` is equivalent to a match. + { + test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], + loader: require.resolve("url-loader"), + options: { + limit: (env === "prod") ? 10000 : 0, + name: "static/media/[name].[hash:8].[ext]", }, - // "file" loader makes sure those assets get served by WebpackDevServer. - // When you `import` an asset, you get its (virtual) filename. - // In production, they would get copied to the `build` folder. - // This loader doesn"t use a "test" so it will catch all modules - // that fall through the other loaders. - { - // Exclude `js` files to keep "css" loader working as it injects - // it"s runtime that would otherwise processed through "file" loader. - // Also exclude `html` and `json` extensions so they get processed - // by webpacks internal loaders. - exclude: [/\.js$/, /\.html$/, /\.json$/], - loader: require.resolve("file-loader"), - options: { - name: "static/media/[name].[hash:8].[ext]", - }, + }, + cssRule, + sassRule, + // Process TypeScript with TSC through HappyPack. + { + test: /\.tsx?$/, + use: "happypack/loader?id=ts", + include: [paths.clientDir, paths.commonDir], + }, + // "file" loader makes sure those assets get served by WebpackDevServer. + // When you `import` an asset, you get its (virtual) filename. + // In production, they would get copied to the `build` folder. + // This loader doesn"t use a "test" so it will catch all modules + // that fall through the other loaders. + { + // Exclude `js` files to keep "css" loader working as it injects + // it"s runtime that would otherwise processed through "file" loader. + // Also exclude `html` and `json` extensions so they get processed + // by webpacks internal loaders. + exclude: [/\.js$/, /\.html$/, /\.json$/], + loader: require.resolve("file-loader"), + options: { + name: "static/media/[name].[hash:8].[ext]", }, - ], - }, - ]; + }, + ], + }, ]; } @@ -200,8 +201,7 @@ const getConfig = module.exports = (env) => { mode: isProd ? "production" : "development", bail: isProd, devtool: shouldUseSourceMap ? - isProd ? "source-map" : "inline-source-map" : - false, + isProd ? "source-map" : "inline-source-map" : false, entry: [ isDev && require.resolve("react-hot-loader/patch"), isDev && require.resolve("react-dev-utils/webpackHotDevClient"), @@ -212,15 +212,13 @@ const getConfig = module.exports = (env) => { path: paths.clientBuildDir, pathinfo: isDev, filename: isProd ? - 'static/js/[name].[chunkhash:8].js' : - "static/js/bundle.js", + 'static/js/[name].[chunkhash:8].js' : "static/js/bundle.js", chunkFilename: isProd ? - 'static/js/[name].[chunkhash:8].chunk.js' : - "static/js/[name].chunk.js", + 'static/js/[name].[chunkhash:8].chunk.js' : "static/js/[name].chunk.js", publicPath: publicPath, devtoolModuleFilenameTemplate: isDev ? (info) => - "webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined, + "webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined, }, resolve: { extensions: [".ts", ".tsx", ".js", ".json", ".scss"], @@ -247,4 +245,4 @@ const getConfig = module.exports = (env) => { }], }, } -}; +}; \ No newline at end of file diff --git a/common/ApiError.ts b/common/ApiError.ts index 2803420..972ee11 100644 --- a/common/ApiError.ts +++ b/common/ApiError.ts @@ -1,26 +1,32 @@ import { ErrorCode, toHttpStatus } from "@common/ErrorCode"; export default class ApiError extends Error { - name = "ApiError"; - statusCode: number; - code: ErrorCode; - data: any; + name = "ApiError"; + statusCode: number; + code: ErrorCode; + data: any; - constructor(message: string, code: ErrorCode = ErrorCode.BadRequest, data: any = {}) { - super(message); - this.statusCode = toHttpStatus(code); - this.code = code; - // tslint:disable-next-line:prefer-conditional-expression - if (data instanceof Error) { - this.data = data.toString(); - } else { - this.data = data; - } + constructor( + message: string, + code: ErrorCode = ErrorCode.BadRequest, + data: any = {} + ) { + super(message); + this.statusCode = toHttpStatus(code); + this.code = code; + // tslint:disable-next-line:prefer-conditional-expression + if (data instanceof Error) { + this.data = data.toString(); + } else { + this.data = data; } + } - toJSON() { - return { - message: this.message, code: this.code, data: this.data, - }; - } + toJSON() { + return { + message: this.message, + code: this.code, + data: this.data + }; + } } diff --git a/common/Duration.ts b/common/Duration.ts index 1f86fd4..ee7c460 100644 --- a/common/Duration.ts +++ b/common/Duration.ts @@ -1,41 +1,41 @@ export class Duration { - static fromSeconds(seconds: number): Duration { - return new Duration(Math.floor(seconds / 60), seconds % 60); - } + static fromSeconds(seconds: number): Duration { + return new Duration(Math.floor(seconds / 60), seconds % 60); + } - minutes: number = 0; - seconds: number = 0; + minutes: number = 0; + seconds: number = 0; - constructor(minutes: number = 0, seconds: number = 0) { - this.minutes = minutes; - this.seconds = seconds; - } + constructor(minutes: number = 0, seconds: number = 0) { + this.minutes = minutes; + this.seconds = seconds; + } - toSeconds(): number { - return this.minutes * 60 + this.seconds; - } + toSeconds(): number { + return this.minutes * 60 + this.seconds; + } - withSeconds(newSeconds: number): Duration { - let newMinutes = this.minutes; - if (newSeconds >= 60) { - newMinutes++; - newSeconds = 0; - } - if (newSeconds < 0) { - newMinutes = Math.max(0, newMinutes - 1); - newSeconds = 59; - } - return new Duration(newMinutes, newSeconds); + withSeconds(newSeconds: number): Duration { + let newMinutes = this.minutes; + if (newSeconds >= 60) { + newMinutes++; + newSeconds = 0; } - - withMinutes(newMinutes: number): Duration { - if (newMinutes < 0) { - newMinutes = 0; - } - return new Duration(newMinutes, this.seconds); + if (newSeconds < 0) { + newMinutes = Math.max(0, newMinutes - 1); + newSeconds = 59; } + return new Duration(newMinutes, newSeconds); + } - toString(): string { - return `${this.minutes}M ${this.seconds.toFixed(1)}S`; + withMinutes(newMinutes: number): Duration { + if (newMinutes < 0) { + newMinutes = 0; } + return new Duration(newMinutes, this.seconds); + } + + toString(): string { + return `${this.minutes}M ${this.seconds.toFixed(1)}S`; + } } diff --git a/common/ErrorCode.ts b/common/ErrorCode.ts index addfb3e..a564c56 100644 --- a/common/ErrorCode.ts +++ b/common/ErrorCode.ts @@ -1,41 +1,41 @@ export enum ErrorCode { - BadRequest = 100, - NotSpecified = 101, - Parse = 102, - Range = 103, - InvalidData = 104, - BadToken = 105, - Unauthorized = 106, - NoPermission = 107, - NotImplemented = 108, - NotFound = 109, - Internal = 200, - Timeout = 300, - ServerDisconnected = 301, - BrokerDisconnected = 302, + BadRequest = 100, + NotSpecified = 101, + Parse = 102, + Range = 103, + InvalidData = 104, + BadToken = 105, + Unauthorized = 106, + NoPermission = 107, + NotImplemented = 108, + NotFound = 109, + Internal = 200, + Timeout = 300, + ServerDisconnected = 301, + BrokerDisconnected = 302 } export function toHttpStatus(errorCode: ErrorCode): number { - switch (errorCode) { - case ErrorCode.BadRequest: - case ErrorCode.NotSpecified: - case ErrorCode.Parse: - case ErrorCode.Range: - case ErrorCode.InvalidData: - return 400; // Bad request - case ErrorCode.Unauthorized: - case ErrorCode.BadToken: - return 401; // Unauthorized - case ErrorCode.NoPermission: - return 403; // Forbidden - case ErrorCode.NotFound: - return 404; - case ErrorCode.NotImplemented: - return 501; - case ErrorCode.Internal: - case ErrorCode.ServerDisconnected: - case ErrorCode.BrokerDisconnected: - default: - return 500; - } + switch (errorCode) { + case ErrorCode.BadRequest: + case ErrorCode.NotSpecified: + case ErrorCode.Parse: + case ErrorCode.Range: + case ErrorCode.InvalidData: + return 400; // Bad request + case ErrorCode.Unauthorized: + case ErrorCode.BadToken: + return 401; // Unauthorized + case ErrorCode.NoPermission: + return 403; // Forbidden + case ErrorCode.NotFound: + return 404; + case ErrorCode.NotImplemented: + return 501; + case ErrorCode.Internal: + case ErrorCode.ServerDisconnected: + case ErrorCode.BrokerDisconnected: + default: + return 500; + } } diff --git a/common/TokenClaims.ts b/common/TokenClaims.ts index fb805c5..d9c6995 100644 --- a/common/TokenClaims.ts +++ b/common/TokenClaims.ts @@ -1,34 +1,39 @@ export interface BaseClaims { - iss: string; - exp?: number; + iss: string; + exp?: number; } export interface AccessToken { - type: "access"; - aud: number; - name: string; + type: "access"; + aud: number; + name: string; } export interface RefreshToken { - type: "refresh"; - aud: number; - name: string; + type: "refresh"; + aud: number; + name: string; } export interface DeviceRegistrationToken { - type: "device_reg"; + type: "device_reg"; } export interface DeviceToken { - type: "device"; - aud: string; - id: number; + type: "device"; + aud: string; + id: number; } export interface SuperuserToken { - type: "superuser"; + type: "superuser"; } -export type TokenClaimTypes = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken; +export type TokenClaimTypes = + | AccessToken + | RefreshToken + | DeviceRegistrationToken + | DeviceToken + | SuperuserToken; export type TokenClaims = TokenClaimTypes & BaseClaims; diff --git a/common/TypedEventEmitter.ts b/common/TypedEventEmitter.ts index 9156fd9..46a49f6 100644 --- a/common/TypedEventEmitter.ts +++ b/common/TypedEventEmitter.ts @@ -4,61 +4,81 @@ type TEventName = string | symbol; type AnyListener = (...args: any[]) => void; -type Arguments = TListener extends (...args: infer TArgs) => any ? TArgs : any[]; -type Listener = TEvents[TEvent] extends (...args: infer TArgs) => any ? - (...args: TArgs) => void : AnyListener; +type Arguments = TListener extends (...args: infer TArgs) => any + ? TArgs + : any[]; +type Listener = TEvents[TEvent] extends ( + ...args: infer TArgs +) => any + ? (...args: TArgs) => void + : AnyListener; export interface DefaultEvents { - newListener: (event: TEventName, listener: AnyListener) => void; - removeListener: (event: TEventName, listener: AnyListener) => void; + newListener: (event: TEventName, listener: AnyListener) => void; + removeListener: (event: TEventName, listener: AnyListener) => void; } -export type AnyEvents = DefaultEvents & { - [event in TEventName]: any[]; -}; +export type AnyEvents = DefaultEvents & { [event in TEventName]: any[] }; -type IEventSubscriber = - (event: TEvent, listener: Listener) => This; +type IEventSubscriber = < + TEvent extends keyof TEvents & TEventName +>( + event: TEvent, + listener: Listener +) => This; // tslint:disable:ban-types interface ITypedEventEmitter { - on: IEventSubscriber; - off: IEventSubscriber; - once: IEventSubscriber; - addListener: IEventSubscriber; - removeListener: IEventSubscriber; - prependListener: IEventSubscriber; - prependOnceListener: IEventSubscriber; + on: IEventSubscriber; + off: IEventSubscriber; + once: IEventSubscriber; + addListener: IEventSubscriber; + removeListener: IEventSubscriber; + prependListener: IEventSubscriber; + prependOnceListener: IEventSubscriber; - emit(event: TEvent, ...args: Arguments): boolean; - listeners(event: TEvent): Function[]; - rawListeners(event: TEvent): Function[]; - eventNames(): Array; - setMaxListeners(maxListeners: number): this; - getMaxListeners(): number; - listenerCount(event: TEvent): number; + emit( + event: TEvent, + ...args: Arguments + ): boolean; + listeners( + event: TEvent + ): Function[]; + rawListeners( + event: TEvent + ): Function[]; + eventNames(): Array; + setMaxListeners(maxListeners: number): this; + getMaxListeners(): number; + listenerCount( + event: TEvent + ): number; } const TypedEventEmitter = EventEmitter as { - new(): TypedEventEmitter, + new (): TypedEventEmitter; }; -type TypedEventEmitter = ITypedEventEmitter; +type TypedEventEmitter< + TEvents extends DefaultEvents = AnyEvents +> = ITypedEventEmitter; type Constructable = new (...args: any[]) => any; -export function typedEventEmitter(Base: TBase): - TBase & TypedEventEmitter { - const NewClass = class extends Base { - constructor(...args: any[]) { - super(...args); - EventEmitter.call(this); - } - }; - Object.getOwnPropertyNames(EventEmitter.prototype).forEach((name) => { - NewClass.prototype[name] = (EventEmitter.prototype as any)[name]; - }); - return NewClass as any; +export function typedEventEmitter< + TBase extends Constructable, + TEvents extends DefaultEvents = AnyEvents +>(Base: TBase): TBase & TypedEventEmitter { + const NewClass = class extends Base { + constructor(...args: any[]) { + super(...args); + EventEmitter.call(this); + } + }; + Object.getOwnPropertyNames(EventEmitter.prototype).forEach(name => { + NewClass.prototype[name] = (EventEmitter.prototype as any)[name]; + }); + return NewClass as any; } export { TypedEventEmitter }; diff --git a/common/httpApi/index.ts b/common/httpApi/index.ts index 7f3803d..1f01a71 100644 --- a/common/httpApi/index.ts +++ b/common/httpApi/index.ts @@ -1,31 +1,33 @@ export interface TokenGrantPasswordRequest { - grant_type: "password"; - username: string; - password: string; + grant_type: "password"; + username: string; + password: string; } export interface TokenGrantRefreshRequest { - grant_type: "refresh"; - refresh_token: string; + grant_type: "refresh"; + refresh_token: string; } -export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshRequest; +export type TokenGrantRequest = + | TokenGrantPasswordRequest + | TokenGrantRefreshRequest; export interface TokenGrantResponse { - access_token: string; - refresh_token: string; + access_token: string; + refresh_token: string; } export interface IUser { - id: number; - username: string; - name: string; - devices: ISprinklersDevice[] | undefined; + id: number; + username: string; + name: string; + devices: ISprinklersDevice[] | undefined; } export interface ISprinklersDevice { - id: number; - deviceId: string | null; - name: string; - users: IUser[] | undefined; + id: number; + deviceId: string | null; + name: string; + users: IUser[] | undefined; } diff --git a/common/jsonRpc/index.ts b/common/jsonRpc/index.ts index 85eee17..5dd80c5 100644 --- a/common/jsonRpc/index.ts +++ b/common/jsonRpc/index.ts @@ -2,9 +2,9 @@ export type DefaultRequestTypes = {}; export type DefaultResponseTypes = {}; export type DefaultErrorType = { - code: number; - message: string; - data?: any; + code: number; + message: string; + data?: any; }; export type DefaultNotificationTypes = {}; // tslint:enable:interface-over-type-literal @@ -16,145 +16,196 @@ export type DefaultNotificationTypes = {}; // ErrorType: DefaultErrorType; // } -export interface Request { - type: "request"; - id: number; - method: Method; - params: RequestTypes[Method]; +export interface Request< + RequestTypes = DefaultRequestTypes, + Method extends keyof RequestTypes = keyof RequestTypes +> { + type: "request"; + id: number; + method: Method; + params: RequestTypes[Method]; } export interface ResponseBase { - type: "response"; - id: number; - method: Method; + type: "response"; + id: number; + method: Method; } export interface SuccessData { - result: "success"; - data: ResponseType; + result: "success"; + data: ResponseType; } export interface ErrorData { - result: "error"; - error: ErrorType; + result: "error"; + error: ErrorType; } -export type ResponseData = - SuccessData | ErrorData; - -export type Response = - ResponseBase & ResponseData; - -export interface Notification { - type: "notification"; - method: Method; - data: NotificationTypes[Method]; +export type ResponseData< + ResponseTypes, + ErrorType, + Method extends keyof ResponseTypes = keyof ResponseTypes +> = SuccessData | ErrorData; + +export type Response< + ResponseTypes, + ErrorType = DefaultErrorType, + Method extends keyof ResponseTypes = keyof ResponseTypes +> = ResponseBase & ResponseData; + +export interface Notification< + NotificationTypes = DefaultNotificationTypes, + Method extends keyof NotificationTypes = keyof NotificationTypes +> { + type: "notification"; + method: Method; + data: NotificationTypes[Method]; } -export type Message = - Request | - Response | - Notification; +export type Message< + RequestTypes = DefaultRequestTypes, + ResponseTypes = DefaultResponseTypes, + ErrorType = DefaultErrorType, + NotificationTypes = DefaultNotificationTypes +> = + | Request + | Response + | Notification; // export type TypesMessage = // Message; -export function isRequestMethod( - message: Request, method: Method, +export function isRequestMethod< + Method extends keyof RequestTypes, + RequestTypes +>( + message: Request, + method: Method ): message is Request { - return message.method === method; + return message.method === method; } -export function isResponseMethod( - message: Response, method: Method, +export function isResponseMethod< + Method extends keyof ResponseTypes, + ErrorType, + ResponseTypes +>( + message: Response, + method: Method ): message is Response { - return message.method === method; + return message.method === method; } -export function isNotificationMethod( - message: Notification, method: Method, +export function isNotificationMethod< + Method extends keyof NotificationTypes, + NotificationTypes = any +>( + message: Notification, + method: Method ): message is Notification { - return message.method === method; + return message.method === method; } -export type IRequestHandler = - (request: RequestTypes[Method]) => Promise>; - -export type RequestHandlers = { - [Method in keyof RequestTypes]: - IRequestHandler; +export type IRequestHandler< + RequestTypes, + ResponseTypes extends { [M in Method]: any }, + ErrorType, + Method extends keyof RequestTypes +> = ( + request: RequestTypes[Method] +) => Promise>; + +export type RequestHandlers< + RequestTypes, + ResponseTypes extends { [M in keyof RequestTypes]: any }, + ErrorType +> = { + [Method in keyof RequestTypes]: IRequestHandler< + RequestTypes, + ResponseTypes, + ErrorType, + Method + > }; -export type IResponseHandler = - (response: ResponseData) => void; - -export interface ResponseHandlers { - [id: number]: IResponseHandler; +export type IResponseHandler< + ResponseTypes, + ErrorType, + Method extends keyof ResponseTypes = keyof ResponseTypes +> = (response: ResponseData) => void; + +export interface ResponseHandlers< + ResponseTypes = DefaultResponseTypes, + ErrorType = DefaultErrorType +> { + [id: number]: IResponseHandler; } -export type NotificationHandler = - (notification: NotificationTypes[Method]) => void; +export type NotificationHandler< + NotificationTypes, + Method extends keyof NotificationTypes +> = (notification: NotificationTypes[Method]) => void; export type NotificationHandlers = { - [Method in keyof NotificationTypes]: NotificationHandler; + [Method in keyof NotificationTypes]: NotificationHandler< + NotificationTypes, + Method + > }; -export function listRequestHandlerMethods( - handlers: RequestHandlers, +export function listRequestHandlerMethods< + RequestTypes, + ResponseTypes extends { [Method in keyof RequestTypes]: any }, + ErrorType +>( + handlers: RequestHandlers ): Array { - return Object.keys(handlers) as any; + return Object.keys(handlers) as any; } export function listNotificationHandlerMethods( - handlers: NotificationHandlers, + handlers: NotificationHandlers ): Array { - return Object.keys(handlers) as any; + return Object.keys(handlers) as any; } -export async function handleRequest( - handlers: RequestHandlers, - message: Request, - thisParam?: any, +export async function handleRequest< + RequestTypes, + ResponseTypes extends { [Method in keyof RequestTypes]: any }, + ErrorType +>( + handlers: RequestHandlers, + message: Request, + thisParam?: any ): Promise> { - const handler = handlers[message.method]; - if (!handler) { - throw new Error("No handler for request method " + message.method); - } - return handler.call(thisParam, message.params); + const handler = handlers[message.method]; + if (!handler) { + throw new Error("No handler for request method " + message.method); + } + return handler.call(thisParam, message.params); } export function handleResponse( - handlers: ResponseHandlers, - message: Response, - thisParam?: any, + handlers: ResponseHandlers, + message: Response, + thisParam?: any ) { - const handler = handlers[message.id]; - if (!handler) { - return; - } - return handler.call(thisParam, message); + const handler = handlers[message.id]; + if (!handler) { + return; + } + return handler.call(thisParam, message); } export function handleNotification( - handlers: NotificationHandlers, - message: Notification, - thisParam?: any, + handlers: NotificationHandlers, + message: Notification, + thisParam?: any ) { - const handler = handlers[message.method]; - if (!handler) { - throw new Error("No handler for notification method " + message.method); - } - return handler.call(thisParam, message.data); + const handler = handlers[message.method]; + if (!handler) { + throw new Error("No handler for notification method " + message.method); + } + return handler.call(thisParam, message.data); } diff --git a/common/logger.ts b/common/logger.ts index fe6b296..a2d7c68 100644 --- a/common/logger.ts +++ b/common/logger.ts @@ -4,112 +4,124 @@ import * as pino from "pino"; type Level = "default" | "60" | "50" | "40" | "30" | "20" | "10"; -const levels: {[level in Level]: string } = { - default: "USERLVL", - 60: "FATAL", - 50: "ERROR", - 40: "WARN", - 30: "INFO", - 20: "DEBUG", - 10: "TRACE", +const levels: { [level in Level]: string } = { + default: "USERLVL", + 60: "FATAL", + 50: "ERROR", + 40: "WARN", + 30: "INFO", + 20: "DEBUG", + 10: "TRACE" }; -const levelColors: {[level in Level]: string } = { - default: "text-decoration: underline; color: #000000;", - 60: "text-decoration: underline; background-color: #FF0000;", - 50: "text-decoration: underline; color: #FF0000;", - 40: "text-decoration: underline; color: #FFFF00;", - 30: "text-decoration: underline; color: #00FF00;", - 20: "text-decoration: underline; color: #0000FF;", - 10: "text-decoration: underline; color: #AAAAAA;", +const levelColors: { [level in Level]: string } = { + default: "text-decoration: underline; color: #000000;", + 60: "text-decoration: underline; background-color: #FF0000;", + 50: "text-decoration: underline; color: #FF0000;", + 40: "text-decoration: underline; color: #FFFF00;", + 30: "text-decoration: underline; color: #00FF00;", + 20: "text-decoration: underline; color: #0000FF;", + 10: "text-decoration: underline; color: #AAAAAA;" }; interface ColoredString { - str: string; - args: any[]; + str: string; + args: any[]; } function makeColored(str: string = ""): ColoredString { - return { str, args: [] }; + return { str, args: [] }; } function concatColored(...coloredStrings: ColoredString[]): ColoredString { - return coloredStrings.reduce((prev, cur) => ({ - str: prev.str + cur.str, - args: prev.args.concat(cur.args), - }), makeColored()); + return coloredStrings.reduce( + (prev, cur) => ({ + str: prev.str + cur.str, + args: prev.args.concat(cur.args) + }), + makeColored() + ); } -const standardKeys = ["pid", "hostname", "name", "level", "time", "v", "source", "msg"]; +const standardKeys = [ + "pid", + "hostname", + "name", + "level", + "time", + "v", + "source", + "msg" +]; function write(value: any) { - let line = concatColored( - // makeColored(formatTime(value, " ")), - formatSource(value), - formatLevel(value), - makeColored(": "), - ); - - if (value.msg) { - line = concatColored(line, { - str: "%c" + value.msg, args: ["color: #00FFFF"], - }); - } - const args = [line.str].concat(line.args) - .concat([ - (value.type === "Error") ? value.stack : filter(value), - ]); - let fn; - if (value.level >= 50) { - fn = console.error; - } else if (value.level >= 40) { - fn = console.warn; - } else { - fn = console.log; - } - fn.apply(null, args); + let line = concatColored( + // makeColored(formatTime(value, " ")), + formatSource(value), + formatLevel(value), + makeColored(": ") + ); + + if (value.msg) { + line = concatColored(line, { + str: "%c" + value.msg, + args: ["color: #00FFFF"] + }); + } + const args = [line.str] + .concat(line.args) + .concat([value.type === "Error" ? value.stack : filter(value)]); + let fn; + if (value.level >= 50) { + fn = console.error; + } else if (value.level >= 40) { + fn = console.warn; + } else { + fn = console.log; + } + fn.apply(null, args); } function filter(value: any) { - const keys = Object.keys(value); - const result: any = {}; + const keys = Object.keys(value); + const result: any = {}; - for (const key of keys) { - if (standardKeys.indexOf(key) < 0) { - result[key] = value[key]; - } + for (const key of keys) { + if (standardKeys.indexOf(key) < 0) { + result[key] = value[key]; } + } - return result; + return result; } -function formatSource(value: any): { str: string, args: any[] } { - if (value.source) { - return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] }; - } else { - return { str: "", args: [] }; - } +function formatSource(value: any): { str: string; args: any[] } { + if (value.source) { + return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] }; + } else { + return { str: "", args: [] }; + } } function formatLevel(value: any): ColoredString { - const level = value.level as Level; - if (levelColors.hasOwnProperty(level)) { - return { - str: "%c" + levels[level] + "%c", - args: [levelColors[level], ""], - }; - } else { - return { - str: levels.default, - args: [levelColors.default], - }; - } + const level = value.level as Level; + if (levelColors.hasOwnProperty(level)) { + return { + str: "%c" + levels[level] + "%c", + args: [levelColors[level], ""] + }; + } else { + return { + str: levels.default, + args: [levelColors.default] + }; + } } const logger: pino.Logger = pino({ - serializers: pino.stdSerializers, - browser: { serialize: true, write }, - level: "trace", + serializers: pino.stdSerializers, + browser: { serialize: true, write }, + level: "trace" }); export default logger; diff --git a/common/sprinklersRpc/ConnectionState.ts b/common/sprinklersRpc/ConnectionState.ts index 76629b4..fb93139 100644 --- a/common/sprinklersRpc/ConnectionState.ts +++ b/common/sprinklersRpc/ConnectionState.ts @@ -1,73 +1,81 @@ import { computed, observable } from "mobx"; export class ConnectionState { - /** - * Represents if a client is connected to the sprinklers3 server (eg. via websocket) - * Can be null if there is no client involved - */ - @observable clientToServer: boolean | null = null; + /** + * Represents if a client is connected to the sprinklers3 server (eg. via websocket) + * Can be null if there is no client involved + */ + @observable + clientToServer: boolean | null = null; - /** - * Represents if the sprinklers3 server is connected to the broker (eg. via mqtt) - * Can be null if there is no broker involved - */ - @observable serverToBroker: boolean | null = null; + /** + * Represents if the sprinklers3 server is connected to the broker (eg. via mqtt) + * Can be null if there is no broker involved + */ + @observable + serverToBroker: boolean | null = null; - /** - * Represents if the device is connected to the broker and we can communicate with it (eg. via mqtt) - * Can be null if there is no device involved - */ - @observable brokerToDevice: boolean | null = null; + /** + * Represents if the device is connected to the broker and we can communicate with it (eg. via mqtt) + * Can be null if there is no device involved + */ + @observable + brokerToDevice: boolean | null = null; - /** - * Represents if whoever is trying to access this device has permission to access it. - * Is null if there is no concept of access involved. - */ - @observable hasPermission: boolean | null = null; + /** + * Represents if whoever is trying to access this device has permission to access it. + * Is null if there is no concept of access involved. + */ + @observable + hasPermission: boolean | null = null; - @computed get noPermission() { - return this.hasPermission === false; - } + @computed + get noPermission() { + return this.hasPermission === false; + } - @computed get isAvailable(): boolean { - if (this.hasPermission === false) { - return false; - } - if (this.brokerToDevice != null) { - return true; - } - if (this.serverToBroker != null) { - return this.serverToBroker; - } - if (this.clientToServer != null) { - return this.clientToServer; - } - return false; + @computed + get isAvailable(): boolean { + if (this.hasPermission === false) { + return false; + } + if (this.brokerToDevice != null) { + return true; + } + if (this.serverToBroker != null) { + return this.serverToBroker; + } + if (this.clientToServer != null) { + return this.clientToServer; } + return false; + } - @computed get isDeviceConnected(): boolean | null { - if (this.hasPermission === false) { - return false; - } - if (this.serverToBroker === false || this.clientToServer === false) { - return null; - } - if (this.brokerToDevice != null) { - return this.brokerToDevice; - } - return null; + @computed + get isDeviceConnected(): boolean | null { + if (this.hasPermission === false) { + return false; } + if (this.serverToBroker === false || this.clientToServer === false) { + return null; + } + if (this.brokerToDevice != null) { + return this.brokerToDevice; + } + return null; + } - @computed get isServerConnected(): boolean | null { - if (this.hasPermission === false) { - return false; - } - if (this.serverToBroker != null) { - return this.serverToBroker; - } - if (this.clientToServer != null) { - return this.brokerToDevice; - } - return null; + @computed + get isServerConnected(): boolean | null { + if (this.hasPermission === false) { + return false; + } + if (this.serverToBroker != null) { + return this.serverToBroker; + } + if (this.clientToServer != null) { + return this.brokerToDevice; } + return null; + } } diff --git a/common/sprinklersRpc/Program.ts b/common/sprinklersRpc/Program.ts index 1b32b69..7ab916d 100644 --- a/common/sprinklersRpc/Program.ts +++ b/common/sprinklersRpc/Program.ts @@ -6,59 +6,69 @@ import * as schema from "./schema"; import { SprinklersDevice } from "./SprinklersDevice"; export class ProgramItem { - // the section number - readonly section!: number; - // duration of the run, in seconds - readonly duration!: number; + // the section number + readonly section!: number; + // duration of the run, in seconds + readonly duration!: number; - constructor(data?: Partial) { - if (data) { - Object.assign(this, data); - } + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); } + } } export class Program { - readonly device: SprinklersDevice; - readonly id: number; + readonly device: SprinklersDevice; + readonly id: number; - @observable name: string = ""; - @observable enabled: boolean = false; - @observable schedule: Schedule = new Schedule(); - @observable.shallow sequence: ProgramItem[] = []; - @observable running: boolean = false; + @observable + name: string = ""; + @observable + enabled: boolean = false; + @observable + schedule: Schedule = new Schedule(); + @observable.shallow + sequence: ProgramItem[] = []; + @observable + running: boolean = false; - constructor(device: SprinklersDevice, id: number, data?: Partial) { - this.device = device; - this.id = id; - if (data) { - Object.assign(this, data); - } + constructor(device: SprinklersDevice, id: number, data?: Partial) { + this.device = device; + this.id = id; + if (data) { + Object.assign(this, data); } + } - run() { - return this.device.runProgram({ programId: this.id }); - } + run() { + return this.device.runProgram({ programId: this.id }); + } - cancel() { - return this.device.cancelProgram({ programId: this.id }); - } + cancel() { + return this.device.cancelProgram({ programId: this.id }); + } - update() { - const data = serialize(schema.program, this); - return this.device.updateProgram({ programId: this.id, data }); - } + update() { + const data = serialize(schema.program, this); + return this.device.updateProgram({ programId: this.id, data }); + } - clone(): Program { - return new Program(this.device, this.id, { - name: this.name, enabled: this.enabled, running: this.running, - schedule: this.schedule.clone(), - sequence: this.sequence.slice(), - }); - } + clone(): Program { + return new Program(this.device, this.id, { + name: this.name, + enabled: this.enabled, + running: this.running, + schedule: this.schedule.clone(), + sequence: this.sequence.slice() + }); + } - toString(): string { - return `Program{name="${this.name}", enabled=${this.enabled}, schedule=${this.schedule}, ` + - `sequence=${this.sequence}, running=${this.running}}`; - } + toString(): string { + return ( + `Program{name="${this.name}", enabled=${this.enabled}, schedule=${ + this.schedule + }, ` + `sequence=${this.sequence}, running=${this.running}}` + ); + } } diff --git a/common/sprinklersRpc/RpcError.ts b/common/sprinklersRpc/RpcError.ts index e2a15c4..6eb6e89 100644 --- a/common/sprinklersRpc/RpcError.ts +++ b/common/sprinklersRpc/RpcError.ts @@ -2,20 +2,24 @@ import { ErrorCode } from "@common/ErrorCode"; import { IError } from "./websocketData"; export class RpcError extends Error implements IError { - name = "RpcError"; - code: number; - data: any; + name = "RpcError"; + code: number; + data: any; - constructor(message: string, code: number = ErrorCode.BadRequest, data: any = {}) { - super(message); - this.code = code; - if (data instanceof Error) { - this.data = data.toString(); - } - this.data = data; + constructor( + message: string, + code: number = ErrorCode.BadRequest, + data: any = {} + ) { + super(message); + this.code = code; + if (data instanceof Error) { + this.data = data.toString(); } + this.data = data; + } - toJSON(): IError { - return { code: this.code, message: this.message, data: this.data }; - } + toJSON(): IError { + return { code: this.code, message: this.message, data: this.data }; + } } diff --git a/common/sprinklersRpc/Section.ts b/common/sprinklersRpc/Section.ts index 37bc699..f93ba2d 100644 --- a/common/sprinklersRpc/Section.ts +++ b/common/sprinklersRpc/Section.ts @@ -2,27 +2,29 @@ import { observable } from "mobx"; import { SprinklersDevice } from "./SprinklersDevice"; export class Section { - readonly device: SprinklersDevice; - readonly id: number; + readonly device: SprinklersDevice; + readonly id: number; - @observable name: string = ""; - @observable state: boolean = false; + @observable + name: string = ""; + @observable + state: boolean = false; - constructor(device: SprinklersDevice, id: number) { - this.device = device; - this.id = id; - } + constructor(device: SprinklersDevice, id: number) { + this.device = device; + this.id = id; + } - /** duration is in seconds */ - run(duration: number) { - return this.device.runSection({ sectionId: this.id, duration }); - } + /** duration is in seconds */ + run(duration: number) { + return this.device.runSection({ sectionId: this.id, duration }); + } - cancel() { - return this.device.cancelSection({ sectionId: this.id }); - } + cancel() { + return this.device.cancelSection({ sectionId: this.id }); + } - toString(): string { - return `Section ${this.id}: '${this.name}'`; - } + toString(): string { + return `Section ${this.id}: '${this.name}'`; + } } diff --git a/common/sprinklersRpc/SectionRunner.ts b/common/sprinklersRpc/SectionRunner.ts index 6f875ed..4154fac 100644 --- a/common/sprinklersRpc/SectionRunner.ts +++ b/common/sprinklersRpc/SectionRunner.ts @@ -2,57 +2,69 @@ import { observable } from "mobx"; import { SprinklersDevice } from "./SprinklersDevice"; export class SectionRun { - readonly sectionRunner: SectionRunner; - readonly id: number; - section: number; - totalDuration: number = 0; - duration: number = 0; - startTime: Date | null = null; - pauseTime: Date | null = null; - unpauseTime: Date | null = null; - - constructor(sectionRunner: SectionRunner, id: number = 0, section: number = 0) { - this.sectionRunner = sectionRunner; - this.id = id; - this.section = section; - } - - cancel = () => this.sectionRunner.cancelRunById(this.id); - - toString() { - return `SectionRun{id=${this.id}, section=${this.section}, duration=${this.duration},` + - ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`; - } + readonly sectionRunner: SectionRunner; + readonly id: number; + section: number; + totalDuration: number = 0; + duration: number = 0; + startTime: Date | null = null; + pauseTime: Date | null = null; + unpauseTime: Date | null = null; + + constructor( + sectionRunner: SectionRunner, + id: number = 0, + section: number = 0 + ) { + this.sectionRunner = sectionRunner; + this.id = id; + this.section = section; + } + + cancel = () => this.sectionRunner.cancelRunById(this.id); + + toString() { + return ( + `SectionRun{id=${this.id}, section=${this.section}, duration=${ + this.duration + },` + ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}` + ); + } } export class SectionRunner { - readonly device: SprinklersDevice; + readonly device: SprinklersDevice; - @observable queue: SectionRun[] = []; - @observable current: SectionRun | null = null; - @observable paused: boolean = false; + @observable + queue: SectionRun[] = []; + @observable + current: SectionRun | null = null; + @observable + paused: boolean = false; - constructor(device: SprinklersDevice) { - this.device = device; - } + constructor(device: SprinklersDevice) { + this.device = device; + } - cancelRunById(runId: number) { - return this.device.cancelSectionRunId({ runId }); - } + cancelRunById(runId: number) { + return this.device.cancelSectionRunId({ runId }); + } - setPaused(paused: boolean) { - return this.device.pauseSectionRunner({ paused }); - } + setPaused(paused: boolean) { + return this.device.pauseSectionRunner({ paused }); + } - pause() { - return this.setPaused(true); - } + pause() { + return this.setPaused(true); + } - unpause() { - return this.setPaused(false); - } + unpause() { + return this.setPaused(false); + } - toString(): string { - return `SectionRunner{queue="${this.queue}", current="${this.current}", paused=${this.paused}}`; - } + toString(): string { + return `SectionRunner{queue="${this.queue}", current="${ + this.current + }", paused=${this.paused}}`; + } } diff --git a/common/sprinklersRpc/SprinklersDevice.ts b/common/sprinklersRpc/SprinklersDevice.ts index c2436f9..190d160 100644 --- a/common/sprinklersRpc/SprinklersDevice.ts +++ b/common/sprinklersRpc/SprinklersDevice.ts @@ -7,85 +7,94 @@ import { SectionRunner } from "./SectionRunner"; import { SprinklersRPC } from "./SprinklersRPC"; export abstract class SprinklersDevice { - readonly rpc: SprinklersRPC; - readonly id: string; - - @observable connectionState: ConnectionState = new ConnectionState(); - @observable sections: Section[] = []; - @observable programs: Program[] = []; - @observable sectionRunner: SectionRunner; - - @computed get connected(): boolean { - return this.connectionState.isDeviceConnected || false; - } - - sectionConstructor: typeof Section = Section; - sectionRunnerConstructor: typeof SectionRunner = SectionRunner; - programConstructor: typeof Program = Program; - - private references: number = 0; - - protected constructor(rpc: SprinklersRPC, id: string) { - this.rpc = rpc; - this.id = id; - this.sectionRunner = new (this.sectionRunnerConstructor)(this); - } - - abstract makeRequest(request: req.Request): Promise; - - /** - * Increase the reference count for this sprinklers device - * @returns The new reference count - */ - acquire(): number { - return ++this.references; - } - - /** - * Releases one reference to this device. When the reference count reaches 0, the device - * will be released and no longer updated. - * @returns The reference count after being updated - */ - release(): number { - this.references--; - if (this.references <= 0) { - this.rpc.releaseDevice(this.id); - } - return this.references; - } - - runProgram(opts: req.WithProgram) { - return this.makeRequest({ ...opts, type: "runProgram" }); - } - - cancelProgram(opts: req.WithProgram) { - return this.makeRequest({ ...opts, type: "cancelProgram" }); - } - - updateProgram(opts: req.UpdateProgramData): Promise { - return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise; - } - - runSection(opts: req.RunSectionData): Promise { - return this.makeRequest({ ...opts, type: "runSection" }) as Promise; - } - - cancelSection(opts: req.WithSection) { - return this.makeRequest({ ...opts, type: "cancelSection" }); - } - - cancelSectionRunId(opts: req.CancelSectionRunIdData) { - return this.makeRequest({ ...opts, type: "cancelSectionRunId" }); - } - - pauseSectionRunner(opts: req.PauseSectionRunnerData) { - return this.makeRequest({ ...opts, type: "pauseSectionRunner" }); - } - - toString(): string { - return `SprinklersDevice{id="${this.id}", connected=${this.connected}, ` + - `sections=[${this.sections}], ` + - `programs=[${this.programs}], ` + - `sectionRunner=${this.sectionRunner} }`; + readonly rpc: SprinklersRPC; + readonly id: string; + + @observable + connectionState: ConnectionState = new ConnectionState(); + @observable + sections: Section[] = []; + @observable + programs: Program[] = []; + @observable + sectionRunner: SectionRunner; + + @computed + get connected(): boolean { + return this.connectionState.isDeviceConnected || false; + } + + sectionConstructor: typeof Section = Section; + sectionRunnerConstructor: typeof SectionRunner = SectionRunner; + programConstructor: typeof Program = Program; + + private references: number = 0; + + protected constructor(rpc: SprinklersRPC, id: string) { + this.rpc = rpc; + this.id = id; + this.sectionRunner = new this.sectionRunnerConstructor(this); + } + + abstract makeRequest(request: req.Request): Promise; + + /** + * Increase the reference count for this sprinklers device + * @returns The new reference count + */ + acquire(): number { + return ++this.references; + } + + /** + * Releases one reference to this device. When the reference count reaches 0, the device + * will be released and no longer updated. + * @returns The reference count after being updated + */ + release(): number { + this.references--; + if (this.references <= 0) { + this.rpc.releaseDevice(this.id); } + return this.references; + } + + runProgram(opts: req.WithProgram) { + return this.makeRequest({ ...opts, type: "runProgram" }); + } + + cancelProgram(opts: req.WithProgram) { + return this.makeRequest({ ...opts, type: "cancelProgram" }); + } + + updateProgram( + opts: req.UpdateProgramData + ): Promise { + return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise; + } + + runSection(opts: req.RunSectionData): Promise { + return this.makeRequest({ ...opts, type: "runSection" }) as Promise; + } + + cancelSection(opts: req.WithSection) { + return this.makeRequest({ ...opts, type: "cancelSection" }); + } + + cancelSectionRunId(opts: req.CancelSectionRunIdData) { + return this.makeRequest({ ...opts, type: "cancelSectionRunId" }); + } + + pauseSectionRunner(opts: req.PauseSectionRunnerData) { + return this.makeRequest({ ...opts, type: "pauseSectionRunner" }); + } + + toString(): string { + return ( + `SprinklersDevice{id="${this.id}", connected=${this.connected}, ` + + `sections=[${this.sections}], ` + + `programs=[${this.programs}], ` + + `sectionRunner=${this.sectionRunner} }` + ); + } } diff --git a/common/sprinklersRpc/SprinklersRPC.ts b/common/sprinklersRpc/SprinklersRPC.ts index fc17115..e383a2d 100644 --- a/common/sprinklersRpc/SprinklersRPC.ts +++ b/common/sprinklersRpc/SprinklersRPC.ts @@ -2,30 +2,30 @@ import { ConnectionState } from "./ConnectionState"; import { SprinklersDevice } from "./SprinklersDevice"; export abstract class SprinklersRPC { - abstract readonly connectionState: ConnectionState; - abstract readonly connected: boolean; + abstract readonly connectionState: ConnectionState; + abstract readonly connected: boolean; - abstract start(): void; + abstract start(): void; - /** - * Acquires a reference to a device. This reference must be released by calling - * SprinklersDevice#release for every time this method was called - * @param id The id of the device - */ - acquireDevice(id: string): SprinklersDevice { - const device = this.getDevice(id); - device.acquire(); - return device; - } + /** + * Acquires a reference to a device. This reference must be released by calling + * SprinklersDevice#release for every time this method was called + * @param id The id of the device + */ + acquireDevice(id: string): SprinklersDevice { + const device = this.getDevice(id); + device.acquire(); + return device; + } - /** - * Forces a device to be released. The device will no longer be updated. - * - * This should not be used normally, instead SprinklersDevice#release should be called to manage - * each reference to a device. - * @param id The id of the device to remove - */ - abstract releaseDevice(id: string): void; + /** + * Forces a device to be released. The device will no longer be updated. + * + * This should not be used normally, instead SprinklersDevice#release should be called to manage + * each reference to a device. + * @param id The id of the device to remove + */ + abstract releaseDevice(id: string): void; - protected abstract getDevice(id: string): SprinklersDevice; + protected abstract getDevice(id: string): SprinklersDevice; } diff --git a/common/sprinklersRpc/deviceRequests.ts b/common/sprinklersRpc/deviceRequests.ts index 143b159..389ee4e 100644 --- a/common/sprinklersRpc/deviceRequests.ts +++ b/common/sprinklersRpc/deviceRequests.ts @@ -1,17 +1,22 @@ export interface WithType { - type: Type; + type: Type; } -export interface WithProgram { programId: number; } +export interface WithProgram { + programId: number; +} export type RunProgramRequest = WithProgram & WithType<"runProgram">; export type CancelProgramRequest = WithProgram & WithType<"cancelProgram">; export type UpdateProgramData = WithProgram & { data: any }; -export type UpdateProgramRequest = UpdateProgramData & WithType<"updateProgram">; +export type UpdateProgramRequest = UpdateProgramData & + WithType<"updateProgram">; export type UpdateProgramResponse = Response<"updateProgram", { data: any }>; -export interface WithSection { sectionId: number; } +export interface WithSection { + sectionId: number; +} export type RunSectionData = WithSection & { duration: number }; export type RunSectionRequest = RunSectionData & WithType<"runSection">; @@ -19,30 +24,44 @@ export type RunSectionResponse = Response<"runSection", { runId: number }>; export type CancelSectionRequest = WithSection & WithType<"cancelSection">; -export interface CancelSectionRunIdData { runId: number; } -export type CancelSectionRunIdRequest = CancelSectionRunIdData & WithType<"cancelSectionRunId">; +export interface CancelSectionRunIdData { + runId: number; +} +export type CancelSectionRunIdRequest = CancelSectionRunIdData & + WithType<"cancelSectionRunId">; -export interface PauseSectionRunnerData { paused: boolean; } -export type PauseSectionRunnerRequest = PauseSectionRunnerData & WithType<"pauseSectionRunner">; +export interface PauseSectionRunnerData { + paused: boolean; +} +export type PauseSectionRunnerRequest = PauseSectionRunnerData & + WithType<"pauseSectionRunner">; -export type Request = RunProgramRequest | CancelProgramRequest | UpdateProgramRequest | - RunSectionRequest | CancelSectionRequest | CancelSectionRunIdRequest | PauseSectionRunnerRequest; +export type Request = + | RunProgramRequest + | CancelProgramRequest + | UpdateProgramRequest + | RunSectionRequest + | CancelSectionRequest + | CancelSectionRunIdRequest + | PauseSectionRunnerRequest; export type RequestType = Request["type"]; -export interface SuccessResponseData extends WithType { - result: "success"; - message: string; +export interface SuccessResponseData + extends WithType { + result: "success"; + message: string; } -export interface ErrorResponseData extends WithType { - result: "error"; - message: string; - code: number; - name?: string; - cause?: any; +export interface ErrorResponseData + extends WithType { + result: "error"; + message: string; + code: number; + name?: string; + cause?: any; } export type Response = - (SuccessResponseData & Res) | - (ErrorResponseData); + | (SuccessResponseData & Res) + | (ErrorResponseData); diff --git a/common/sprinklersRpc/mqtt/MqttProgram.ts b/common/sprinklersRpc/mqtt/MqttProgram.ts index 759254d..330f34e 100644 --- a/common/sprinklersRpc/mqtt/MqttProgram.ts +++ b/common/sprinklersRpc/mqtt/MqttProgram.ts @@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc"; import * as schema from "@common/sprinklersRpc/schema"; export class MqttProgram extends s.Program { - onMessage(payload: string, topic: string | undefined) { - if (topic === "running") { - this.running = (payload === "true"); - } else if (topic == null) { - this.updateFromJSON(JSON.parse(payload)); - } + onMessage(payload: string, topic: string | undefined) { + if (topic === "running") { + this.running = payload === "true"; + } else if (topic == null) { + this.updateFromJSON(JSON.parse(payload)); } + } - updateFromJSON(json: any) { - update(schema.program, this, json); - } + updateFromJSON(json: any) { + update(schema.program, this, json); + } } diff --git a/common/sprinklersRpc/mqtt/MqttSection.ts b/common/sprinklersRpc/mqtt/MqttSection.ts index a8eb071..42bf66f 100644 --- a/common/sprinklersRpc/mqtt/MqttSection.ts +++ b/common/sprinklersRpc/mqtt/MqttSection.ts @@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc"; import * as schema from "@common/sprinklersRpc/schema"; export class MqttSection extends s.Section { - onMessage(payload: string, topic: string | undefined) { - if (topic === "state") { - this.state = (payload === "true"); - } else if (topic == null) { - this.updateFromJSON(JSON.parse(payload)); - } + onMessage(payload: string, topic: string | undefined) { + if (topic === "state") { + this.state = payload === "true"; + } else if (topic == null) { + this.updateFromJSON(JSON.parse(payload)); } + } - updateFromJSON(json: any) { - update(schema.section, this, json); - } + updateFromJSON(json: any) { + update(schema.section, this, json); + } } diff --git a/common/sprinklersRpc/mqtt/MqttSectionRunner.ts b/common/sprinklersRpc/mqtt/MqttSectionRunner.ts index e6e2dd6..b5156d7 100644 --- a/common/sprinklersRpc/mqtt/MqttSectionRunner.ts +++ b/common/sprinklersRpc/mqtt/MqttSectionRunner.ts @@ -4,11 +4,11 @@ import * as s from "@common/sprinklersRpc"; import * as schema from "@common/sprinklersRpc/schema"; export class MqttSectionRunner extends s.SectionRunner { - onMessage(payload: string) { - this.updateFromJSON(JSON.parse(payload)); - } + onMessage(payload: string) { + this.updateFromJSON(JSON.parse(payload)); + } - updateFromJSON(json: any) { - update(schema.sectionRunner, this, json); - } + updateFromJSON(json: any) { + update(schema.sectionRunner, this, json); + } } diff --git a/common/sprinklersRpc/mqtt/index.ts b/common/sprinklersRpc/mqtt/index.ts index b28f606..18dc534 100644 --- a/common/sprinklersRpc/mqtt/index.ts +++ b/common/sprinklersRpc/mqtt/index.ts @@ -16,296 +16,337 @@ import { MqttSectionRunner } from "./MqttSectionRunner"; const log = logger.child({ source: "mqtt" }); interface WithRid { - rid: number; + rid: number; } export const DEVICE_PREFIX = "devices"; const REQUEST_TIMEOUT = 5000; export interface MqttRpcClientOptions { - mqttUri: string; - username?: string; - password?: string; + mqttUri: string; + username?: string; + password?: string; } -export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptions { - get connected(): boolean { - return this.connectionState.isServerConnected || false; +export class MqttRpcClient extends s.SprinklersRPC + implements MqttRpcClientOptions { + get connected(): boolean { + return this.connectionState.isServerConnected || false; + } + + private static newClientId() { + return "sprinklers3-MqttApiClient-" + getRandomId(); + } + + mqttUri!: string; + username?: string; + password?: string; + + client!: mqtt.Client; + @observable + connectionState: s.ConnectionState = new s.ConnectionState(); + devices: Map = new Map(); + + constructor(opts: MqttRpcClientOptions) { + super(); + Object.assign(this, opts); + this.connectionState.serverToBroker = false; + } + + start() { + const clientId = MqttRpcClient.newClientId(); + const mqttUri = this.mqttUri; + log.info({ mqttUri, clientId }, "connecting to mqtt broker with client id"); + this.client = mqtt.connect( + mqttUri, + { + clientId, + connectTimeout: 5000, + reconnectPeriod: 5000, + username: this.username, + password: this.password + } + ); + this.client.on("message", this.onMessageArrived.bind(this)); + this.client.on("close", () => { + logger.warn("mqtt disconnected"); + this.connectionState.serverToBroker = false; + }); + this.client.on("error", err => { + log.error({ err }, "mqtt error"); + }); + this.client.on("connect", () => { + log.info("mqtt connected"); + this.connectionState.serverToBroker = true; + }); + } + + releaseDevice(id: string) { + const device = this.devices.get(id); + if (!device) { + return; } + device.doUnsubscribe(); + this.devices.delete(id); + } - private static newClientId() { - return "sprinklers3-MqttApiClient-" + getRandomId(); + protected getDevice(id: string): s.SprinklersDevice { + if (/\//.test(id)) { + throw new Error("Device id cannot contain a /"); } - - mqttUri!: string; - username?: string; - password?: string; - - client!: mqtt.Client; - @observable connectionState: s.ConnectionState = new s.ConnectionState(); - devices: Map = new Map(); - - constructor(opts: MqttRpcClientOptions) { - super(); - Object.assign(this, opts); - this.connectionState.serverToBroker = false; - } - - start() { - const clientId = MqttRpcClient.newClientId(); - const mqttUri = this.mqttUri; - log.info({ mqttUri, clientId }, "connecting to mqtt broker with client id"); - this.client = mqtt.connect(mqttUri, { - clientId, connectTimeout: 5000, reconnectPeriod: 5000, - username: this.username, password: this.password, - }); - this.client.on("message", this.onMessageArrived.bind(this)); - this.client.on("close", () => { - logger.warn("mqtt disconnected"); - this.connectionState.serverToBroker = false; - }); - this.client.on("error", (err) => { - log.error({ err }, "mqtt error"); - }); - this.client.on("connect", () => { - log.info("mqtt connected"); - this.connectionState.serverToBroker = true; - }); + let device = this.devices.get(id); + if (!device) { + this.devices.set(id, (device = new MqttSprinklersDevice(this, id))); + if (this.connected) { + device.doSubscribe(); + } } - - releaseDevice(id: string) { - const device = this.devices.get(id); - if (!device) { - return; - } - device.doUnsubscribe(); - this.devices.delete(id); - } - - protected getDevice(id: string): s.SprinklersDevice { - if (/\//.test(id)) { - throw new Error("Device id cannot contain a /"); - } - let device = this.devices.get(id); - if (!device) { - this.devices.set(id, device = new MqttSprinklersDevice(this, id)); - if (this.connected) { - device.doSubscribe(); - } - } - return device; + return device; + } + + private onMessageArrived( + topic: string, + payload: Buffer, + packet: mqtt.Packet + ) { + try { + this.processMessage(topic, payload, packet); + } catch (err) { + log.error({ err }, "error while processing mqtt message"); } - - private onMessageArrived(topic: string, payload: Buffer, packet: mqtt.Packet) { - try { - this.processMessage(topic, payload, packet); - } catch (err) { - log.error({ err }, "error while processing mqtt message"); - } + } + + private processMessage( + topic: string, + payloadBuf: Buffer, + packet: mqtt.Packet + ) { + const payload = payloadBuf.toString("utf8"); + log.trace({ topic, payload }, "message arrived: "); + const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`); + const matches = regexp.exec(topic); + if (!matches) { + return log.warn({ topic }, "received message on invalid topic"); } - - private processMessage(topic: string, payloadBuf: Buffer, packet: mqtt.Packet) { - const payload = payloadBuf.toString("utf8"); - log.trace({ topic, payload }, "message arrived: "); - const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`); - const matches = regexp.exec(topic); - if (!matches) { - return log.warn({ topic }, "received message on invalid topic"); - } - const id = matches[1]; - const topicSuffix = matches[2]; - const device = this.devices.get(id); - if (!device) { - log.debug({ id }, "received message for unknown device"); - return; - } - device.onMessage(topicSuffix, payload); + const id = matches[1]; + const topicSuffix = matches[2]; + const device = this.devices.get(id); + if (!device) { + log.debug({ id }, "received message for unknown device"); + return; } + device.onMessage(topicSuffix, payload); + } } type ResponseCallback = (response: requests.Response) => void; const subscriptions = [ - "/connected", - "/sections", - "/sections/+/#", - "/programs", - "/programs/+/#", - "/responses", - "/section_runner", + "/connected", + "/sections", + "/sections/+/#", + "/programs", + "/programs/+/#", + "/responses", + "/section_runner" ]; type IHandler = (payload: any, ...matches: string[]) => void; interface IHandlerEntry { - test: RegExp; - handler: IHandler; + test: RegExp; + handler: IHandler; } -const handler = (test: RegExp) => - (target: MqttSprinklersDevice, propertyKey: string, descriptor: TypedPropertyDescriptor) => { - if (typeof descriptor.value === "function") { - const entry = { - test, handler: descriptor.value, - }; - (target.handlers || (target.handlers = [])).push(entry); - } +const handler = (test: RegExp) => ( + target: MqttSprinklersDevice, + propertyKey: string, + descriptor: TypedPropertyDescriptor +) => { + if (typeof descriptor.value === "function") { + const entry = { + test, + handler: descriptor.value }; + (target.handlers || (target.handlers = [])).push(entry); + } +}; class MqttSprinklersDevice extends s.SprinklersDevice { - readonly apiClient: MqttRpcClient; - - handlers!: IHandlerEntry[]; - private subscriptions: string[]; - private nextRequestId: number = Math.floor(Math.random() * 1000000000); - private responseCallbacks: Map = new Map(); - - constructor(apiClient: MqttRpcClient, id: string) { - super(apiClient, id); - this.sectionConstructor = MqttSection; - this.sectionRunnerConstructor = MqttSectionRunner; - this.programConstructor = MqttProgram; - this.apiClient = apiClient; - this.sectionRunner = new MqttSectionRunner(this); - this.subscriptions = subscriptions.map((filter) => this.prefix + filter); - - autorun(() => { - const brokerConnected = apiClient.connected; - this.connectionState.serverToBroker = brokerConnected; - if (brokerConnected) { - if (this.connectionState.brokerToDevice == null) { - this.connectionState.brokerToDevice = false; - } - this.doSubscribe(); - } else { - this.connectionState.brokerToDevice = false; - } - }); - } - - get prefix(): string { - return DEVICE_PREFIX + "/" + this.id; - } - - doSubscribe() { - this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, (err) => { - if (err) { - log.error({ err, id: this.id }, "error subscribing to device"); - } else { - log.debug({ id: this.id }, "subscribed to device"); - } - }); - } - - doUnsubscribe() { - this.apiClient.client.unsubscribe(this.subscriptions, (err) => { - if (err) { - log.error({ err, id: this.id }, "error unsubscribing to device"); - } else { - log.debug({ id: this.id }, "unsubscribed to device"); - } - }); - } - - onMessage(topic: string, payload: string) { - for (const { test, handler: hndlr } of this.handlers) { - const matches = topic.match(test); - if (!matches) { - continue; - } - matches.shift(); - hndlr.call(this, payload, ...matches); - return; + readonly apiClient: MqttRpcClient; + + handlers!: IHandlerEntry[]; + private subscriptions: string[]; + private nextRequestId: number = Math.floor(Math.random() * 1000000000); + private responseCallbacks: Map = new Map(); + + constructor(apiClient: MqttRpcClient, id: string) { + super(apiClient, id); + this.sectionConstructor = MqttSection; + this.sectionRunnerConstructor = MqttSectionRunner; + this.programConstructor = MqttProgram; + this.apiClient = apiClient; + this.sectionRunner = new MqttSectionRunner(this); + this.subscriptions = subscriptions.map(filter => this.prefix + filter); + + autorun(() => { + const brokerConnected = apiClient.connected; + this.connectionState.serverToBroker = brokerConnected; + if (brokerConnected) { + if (this.connectionState.brokerToDevice == null) { + this.connectionState.brokerToDevice = false; } - log.warn({ topic }, "MqttSprinklersDevice recieved message on invalid topic"); + this.doSubscribe(); + } else { + this.connectionState.brokerToDevice = false; + } + }); + } + + get prefix(): string { + return DEVICE_PREFIX + "/" + this.id; + } + + doSubscribe() { + this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, err => { + if (err) { + log.error({ err, id: this.id }, "error subscribing to device"); + } else { + log.debug({ id: this.id }, "subscribed to device"); + } + }); + } + + doUnsubscribe() { + this.apiClient.client.unsubscribe(this.subscriptions, err => { + if (err) { + log.error({ err, id: this.id }, "error unsubscribing to device"); + } else { + log.debug({ id: this.id }, "unsubscribed to device"); + } + }); + } + + onMessage(topic: string, payload: string) { + for (const { test, handler: hndlr } of this.handlers) { + const matches = topic.match(test); + if (!matches) { + continue; + } + matches.shift(); + hndlr.call(this, payload, ...matches); + return; } - - makeRequest(request: requests.Request): Promise { - return new Promise((resolve, reject) => { - const topic = this.prefix + "/requests"; - const json = seralizeRequest(request); - const requestId = json.rid = this.getRequestId(); - const payloadStr = JSON.stringify(json); - - let timeoutHandle: any; - const callback: ResponseCallback = (data) => { - if (data.result === "error") { - reject(new RpcError(data.message, data.code, data)); - } else { - resolve(data); - } - this.responseCallbacks.delete(requestId); - clearTimeout(timeoutHandle); - }; - - timeoutHandle = setTimeout(() => { - reject(new RpcError("the request has timed out", ErrorCode.Timeout)); - this.responseCallbacks.delete(requestId); - clearTimeout(timeoutHandle); - }, REQUEST_TIMEOUT); - - this.responseCallbacks.set(requestId, callback); - this.apiClient.client.publish(topic, payloadStr, { qos: 1 }); - }); - } - - private getRequestId(): number { - return this.nextRequestId++; - } - - /* tslint:disable:no-unused-variable */ - @handler(/^connected$/) - private handleConnected(payload: string) { - this.connectionState.brokerToDevice = (payload === "true"); - log.trace(`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`); - return; - } - - @handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/) - private handleSectionsUpdate(payload: string, secNumStr?: string, subTopic?: string) { - log.trace({ section: secNumStr, topic: subTopic, payload }, "handling section update"); - if (!secNumStr) { // new number of sections - this.sections.length = Number(payload); + log.warn( + { topic }, + "MqttSprinklersDevice recieved message on invalid topic" + ); + } + + makeRequest(request: requests.Request): Promise { + return new Promise((resolve, reject) => { + const topic = this.prefix + "/requests"; + const json = seralizeRequest(request); + const requestId = (json.rid = this.getRequestId()); + const payloadStr = JSON.stringify(json); + + let timeoutHandle: any; + const callback: ResponseCallback = data => { + if (data.result === "error") { + reject(new RpcError(data.message, data.code, data)); } else { - const secNum = Number(secNumStr); - let section = this.sections[secNum]; - if (!section) { - this.sections[secNum] = section = new MqttSection(this, secNum); - } - (section as MqttSection).onMessage(payload, subTopic); + resolve(data); } + this.responseCallbacks.delete(requestId); + clearTimeout(timeoutHandle); + }; + + timeoutHandle = setTimeout(() => { + reject(new RpcError("the request has timed out", ErrorCode.Timeout)); + this.responseCallbacks.delete(requestId); + clearTimeout(timeoutHandle); + }, REQUEST_TIMEOUT); + + this.responseCallbacks.set(requestId, callback); + this.apiClient.client.publish(topic, payloadStr, { qos: 1 }); + }); + } + + private getRequestId(): number { + return this.nextRequestId++; + } + + /* tslint:disable:no-unused-variable */ + @handler(/^connected$/) + private handleConnected(payload: string) { + this.connectionState.brokerToDevice = payload === "true"; + log.trace( + `MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}` + ); + return; + } + + @handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/) + private handleSectionsUpdate( + payload: string, + secNumStr?: string, + subTopic?: string + ) { + log.trace( + { section: secNumStr, topic: subTopic, payload }, + "handling section update" + ); + if (!secNumStr) { + // new number of sections + this.sections.length = Number(payload); + } else { + const secNum = Number(secNumStr); + let section = this.sections[secNum]; + if (!section) { + this.sections[secNum] = section = new MqttSection(this, secNum); + } + (section as MqttSection).onMessage(payload, subTopic); } - - @handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/) - private handleProgramsUpdate(payload: string, progNumStr?: string, subTopic?: string) { - log.trace({ program: progNumStr, topic: subTopic, payload }, "handling program update"); - if (!progNumStr) { // new number of programs - this.programs.length = Number(payload); - } else { - const progNum = Number(progNumStr); - let program = this.programs[progNum]; - if (!program) { - this.programs[progNum] = program = new MqttProgram(this, progNum); - } - (program as MqttProgram).onMessage(payload, subTopic); - } + } + + @handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/) + private handleProgramsUpdate( + payload: string, + progNumStr?: string, + subTopic?: string + ) { + log.trace( + { program: progNumStr, topic: subTopic, payload }, + "handling program update" + ); + if (!progNumStr) { + // new number of programs + this.programs.length = Number(payload); + } else { + const progNum = Number(progNumStr); + let program = this.programs[progNum]; + if (!program) { + this.programs[progNum] = program = new MqttProgram(this, progNum); + } + (program as MqttProgram).onMessage(payload, subTopic); } - - @handler(/^section_runner$/) - private handleSectionRunnerUpdate(payload: string) { - (this.sectionRunner as MqttSectionRunner).onMessage(payload); - } - - @handler(/^responses$/) - private handleResponse(payload: string) { - const data = JSON.parse(payload) as requests.Response & WithRid; - log.trace({ rid: data.rid }, "handling request response"); - const cb = this.responseCallbacks.get(data.rid); - if (typeof cb === "function") { - delete data.rid; - cb(data); - } + } + + @handler(/^section_runner$/) + private handleSectionRunnerUpdate(payload: string) { + (this.sectionRunner as MqttSectionRunner).onMessage(payload); + } + + @handler(/^responses$/) + private handleResponse(payload: string) { + const data = JSON.parse(payload) as requests.Response & WithRid; + log.trace({ rid: data.rid }, "handling request response"); + const cb = this.responseCallbacks.get(data.rid); + if (typeof cb === "function") { + delete data.rid; + cb(data); } + } - /* tslint:enable:no-unused-variable */ + /* tslint:enable:no-unused-variable */ } diff --git a/common/sprinklersRpc/schedule.ts b/common/sprinklersRpc/schedule.ts index 1bdc1a6..50e06e3 100644 --- a/common/sprinklersRpc/schedule.ts +++ b/common/sprinklersRpc/schedule.ts @@ -2,101 +2,140 @@ import { observable } from "mobx"; import { Moment } from "moment"; export class TimeOfDay { - static fromMoment(m: Moment): TimeOfDay { - return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond()); - } - - static fromDate(date: Date): TimeOfDay { - return new TimeOfDay(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); - } - - static equals(a: TimeOfDay | null | undefined, b: TimeOfDay | null | undefined): boolean { - return (a === b) || ((a != null && b != null) && a.hour === b.hour && - a.minute === b.minute && - a.second === b.second && - a.millisecond === b.millisecond); - } - - readonly hour: number; - readonly minute: number; - readonly second: number; - readonly millisecond: number; - - constructor(hour: number = 0, minute: number = 0, second: number = 0, millisecond: number = 0) { - this.hour = hour; - this.minute = minute; - this.second = second; - this.millisecond = millisecond; - } + static fromMoment(m: Moment): TimeOfDay { + return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond()); + } + + static fromDate(date: Date): TimeOfDay { + return new TimeOfDay( + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ); + } + + static equals( + a: TimeOfDay | null | undefined, + b: TimeOfDay | null | undefined + ): boolean { + return ( + a === b || + (a != null && + b != null && + a.hour === b.hour && + a.minute === b.minute && + a.second === b.second && + a.millisecond === b.millisecond) + ); + } + + readonly hour: number; + readonly minute: number; + readonly second: number; + readonly millisecond: number; + + constructor( + hour: number = 0, + minute: number = 0, + second: number = 0, + millisecond: number = 0 + ) { + this.hour = hour; + this.minute = minute; + this.second = second; + this.millisecond = millisecond; + } } export enum Weekday { - Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, + Sunday, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday } export const WEEKDAYS: Weekday[] = Object.keys(Weekday) - .map((weekday) => Number(weekday)) - .filter((weekday) => !isNaN(weekday)); + .map(weekday => Number(weekday)) + .filter(weekday => !isNaN(weekday)); export enum Month { - January = 1, - February = 2, - March = 3, - April = 4, - May = 5, - June = 6, - July = 7, - August = 8, - September = 9, - October = 10, - November = 11, - December = 12, + January = 1, + February = 2, + March = 3, + April = 4, + May = 5, + June = 6, + July = 7, + August = 8, + September = 9, + October = 10, + November = 11, + December = 12 } export class DateOfYear { - static readonly DEFAULT = new DateOfYear({ day: 1, month: Month.January, year: 0 }); - - static equals(a: DateOfYear | null | undefined, b: DateOfYear | null | undefined): boolean { - return (a === b) || ((a instanceof DateOfYear && b instanceof DateOfYear) && - a.day === b.day && - a.month === b.month && - a.year === b.year); - } - - static fromMoment(m: Moment): DateOfYear { - return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() }); - } - - readonly day!: number; - readonly month!: Month; - readonly year!: number; - - constructor(data?: Partial) { - Object.assign(this, DateOfYear.DEFAULT, data); - } - - with(data: Partial): DateOfYear { - return new DateOfYear(Object.assign({}, this, data)); - } - - toString() { - return `${Month[this.month]} ${this.day}, ${this.year}`; - } + static readonly DEFAULT = new DateOfYear({ + day: 1, + month: Month.January, + year: 0 + }); + + static equals( + a: DateOfYear | null | undefined, + b: DateOfYear | null | undefined + ): boolean { + return ( + a === b || + (a instanceof DateOfYear && + b instanceof DateOfYear && + a.day === b.day && + a.month === b.month && + a.year === b.year) + ); + } + + static fromMoment(m: Moment): DateOfYear { + return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() }); + } + + readonly day!: number; + readonly month!: Month; + readonly year!: number; + + constructor(data?: Partial) { + Object.assign(this, DateOfYear.DEFAULT, data); + } + + with(data: Partial): DateOfYear { + return new DateOfYear(Object.assign({}, this, data)); + } + + toString() { + return `${Month[this.month]} ${this.day}, ${this.year}`; + } } export class Schedule { - @observable times: TimeOfDay[] = []; - @observable weekdays: Weekday[] = []; - @observable from: DateOfYear | null = null; - @observable to: DateOfYear | null = null; - - constructor(data?: Partial) { - if (typeof data === "object") { - Object.assign(this, data); - } + @observable + times: TimeOfDay[] = []; + @observable + weekdays: Weekday[] = []; + @observable + from: DateOfYear | null = null; + @observable + to: DateOfYear | null = null; + + constructor(data?: Partial) { + if (typeof data === "object") { + Object.assign(this, data); } + } - clone(): Schedule { - return new Schedule(this); - } + clone(): Schedule { + return new Schedule(this); + } } diff --git a/common/sprinklersRpc/schema/common.ts b/common/sprinklersRpc/schema/common.ts index 82ca2cb..8851dec 100644 --- a/common/sprinklersRpc/schema/common.ts +++ b/common/sprinklersRpc/schema/common.ts @@ -1,40 +1,38 @@ -import { - ModelSchema, primitive, PropSchema, -} from "serializr"; +import { ModelSchema, primitive, PropSchema } from "serializr"; import * as s from ".."; export const duration: PropSchema = primitive(); export const date: PropSchema = { - serializer: (jsDate: Date | null) => jsDate != null ? - jsDate.toISOString() : null, - deserializer: (json: any, done) => { - if (json === null) { - return done(null, null); - } - try { - done(null, new Date(json)); - } catch (e) { - done(e, undefined); - } - }, + serializer: (jsDate: Date | null) => + jsDate != null ? jsDate.toISOString() : null, + deserializer: (json: any, done) => { + if (json === null) { + return done(null, null); + } + try { + done(null, new Date(json)); + } catch (e) { + done(e, undefined); + } + } }; export const dateOfYear: ModelSchema = { - factory: () => new s.DateOfYear(), - props: { - year: primitive(), - month: primitive(), // this only works if it is represented as a # from 0-12 - day: primitive(), - }, + factory: () => new s.DateOfYear(), + props: { + year: primitive(), + month: primitive(), // this only works if it is represented as a # from 0-12 + day: primitive() + } }; export const timeOfDay: ModelSchema = { - factory: () => new s.TimeOfDay(), - props: { - hour: primitive(), - minute: primitive(), - second: primitive(), - millisecond: primitive(), - }, + factory: () => new s.TimeOfDay(), + props: { + hour: primitive(), + minute: primitive(), + second: primitive(), + millisecond: primitive() + } }; diff --git a/common/sprinklersRpc/schema/index.ts b/common/sprinklersRpc/schema/index.ts index 32a1a40..86fd9c3 100644 --- a/common/sprinklersRpc/schema/index.ts +++ b/common/sprinklersRpc/schema/index.ts @@ -1,6 +1,4 @@ -import { - createSimpleSchema, ModelSchema, object, primitive, -} from "serializr"; +import { createSimpleSchema, ModelSchema, object, primitive } from "serializr"; import * as s from ".."; import list from "./list"; @@ -11,81 +9,89 @@ import * as common from "./common"; export * from "./common"; export const connectionState: ModelSchema = { - factory: (c) => new s.ConnectionState(), - props: { - clientToServer: primitive(), - serverToBroker: primitive(), - brokerToDevice: primitive(), - }, + factory: c => new s.ConnectionState(), + props: { + clientToServer: primitive(), + serverToBroker: primitive(), + brokerToDevice: primitive() + } }; export const section: ModelSchema = { - factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionConstructor( - c.parentContext.target, c.json.id), - props: { - id: primitive(), - name: primitive(), - state: primitive(), - }, + factory: c => + new (c.parentContext.target as s.SprinklersDevice).sectionConstructor( + c.parentContext.target, + c.json.id + ), + props: { + id: primitive(), + name: primitive(), + state: primitive() + } }; export const sectionRun: ModelSchema = { - factory: (c) => new s.SectionRun(c.parentContext.target, c.json.id), - props: { - id: primitive(), - section: primitive(), - totalDuration: common.duration, - duration: common.duration, - startTime: common.date, - pauseTime: common.date, - unpauseTime: common.date, - }, + factory: c => new s.SectionRun(c.parentContext.target, c.json.id), + props: { + id: primitive(), + section: primitive(), + totalDuration: common.duration, + duration: common.duration, + startTime: common.date, + pauseTime: common.date, + unpauseTime: common.date + } }; export const sectionRunner: ModelSchema = { - factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor( - c.parentContext.target), - props: { - queue: list(object(sectionRun)), - current: object(sectionRun), - paused: primitive(), - }, + factory: c => + new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor( + c.parentContext.target + ), + props: { + queue: list(object(sectionRun)), + current: object(sectionRun), + paused: primitive() + } }; export const schedule: ModelSchema = { - factory: () => new s.Schedule(), - props: { - times: list(object(common.timeOfDay)), - weekdays: list(primitive()), - from: object(common.dateOfYear), - to: object(common.dateOfYear), - }, + factory: () => new s.Schedule(), + props: { + times: list(object(common.timeOfDay)), + weekdays: list(primitive()), + from: object(common.dateOfYear), + to: object(common.dateOfYear) + } }; export const programItem: ModelSchema = { - factory: () => new s.ProgramItem(), - props: { - section: primitive(), - duration: common.duration, - }, + factory: () => new s.ProgramItem(), + props: { + section: primitive(), + duration: common.duration + } }; export const program: ModelSchema = { - factory: (c) => new (c.parentContext.target as s.SprinklersDevice).programConstructor( - c.parentContext.target, c.json.id), - props: { - id: primitive(), - name: primitive(), - enabled: primitive(), - schedule: object(schedule), - sequence: list(object(programItem)), - running: primitive(), - }, + factory: c => + new (c.parentContext.target as s.SprinklersDevice).programConstructor( + c.parentContext.target, + c.json.id + ), + props: { + id: primitive(), + name: primitive(), + enabled: primitive(), + schedule: object(schedule), + sequence: list(object(programItem)), + running: primitive() + } }; export const sprinklersDevice = createSimpleSchema({ - connectionState: object(connectionState), - sections: list(object(section)), - sectionRunner: object(sectionRunner), - programs: list(object(program)), + connectionState: object(connectionState), + sections: list(object(section)), + sectionRunner: object(sectionRunner), + programs: list(object(program)) }); diff --git a/common/sprinklersRpc/schema/list.ts b/common/sprinklersRpc/schema/list.ts index 6fde8a9..079dec5 100644 --- a/common/sprinklersRpc/schema/list.ts +++ b/common/sprinklersRpc/schema/list.ts @@ -1,65 +1,75 @@ import { primitive, PropSchema } from "serializr"; function invariant(cond: boolean, message?: string) { - if (!cond) { - throw new Error("[serializr] " + (message || "Illegal ServerState")); - } + if (!cond) { + throw new Error("[serializr] " + (message || "Illegal ServerState")); + } } function isPropSchema(thing: any) { - return thing && thing.serializer && thing.deserializer; + return thing && thing.serializer && thing.deserializer; } function isAliasedPropSchema(propSchema: any) { - return typeof propSchema === "object" && !!propSchema.jsonname; + return typeof propSchema === "object" && !!propSchema.jsonname; } -function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any) { - if (ar.length === 0) { - return void cb(null, []); +function parallel( + ar: any[], + processor: (item: any, done: any) => void, + cb: any +) { + if (ar.length === 0) { + return void cb(null, []); + } + let left = ar.length; + const resultArray: any[] = []; + let failed = false; + const processorCb = (idx: number, err: any, result: any) => { + if (err) { + if (!failed) { + failed = true; + cb(err); + } + } else if (!failed) { + resultArray[idx] = result; + if (--left === 0) { + cb(null, resultArray); + } } - let left = ar.length; - const resultArray: any[] = []; - let failed = false; - const processorCb = (idx: number, err: any, result: any) => { - if (err) { - if (!failed) { - failed = true; - cb(err); - } - } else if (!failed) { - resultArray[idx] = result; - if (--left === 0) { - cb(null, resultArray); - } - } - }; - ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx))); + }; + ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx))); } export default function list(propSchema: PropSchema): PropSchema { - propSchema = propSchema || primitive(); - invariant(isPropSchema(propSchema), "expected prop schema as first argument"); - invariant(!isAliasedPropSchema(propSchema), "provided prop is aliased, please put aliases first"); - return { - serializer(ar) { - invariant(ar && typeof ar.length === "number" && typeof ar.map === "function", - "expected array (like) object"); - return ar.map(propSchema.serializer); - }, - deserializer(jsonArray, done, context) { - if (jsonArray === null) { // sometimes go will return null in place of empty array - return void done(null, []); - } - if (!Array.isArray(jsonArray)) { - return void done("[serializr] expected JSON array", null); - } - parallel( - jsonArray, - (item: any, itemDone: (err: any, targetPropertyValue: any) => void) => - propSchema.deserializer(item, itemDone, context, undefined), - done, - ); - }, - }; + propSchema = propSchema || primitive(); + invariant(isPropSchema(propSchema), "expected prop schema as first argument"); + invariant( + !isAliasedPropSchema(propSchema), + "provided prop is aliased, please put aliases first" + ); + return { + serializer(ar) { + invariant( + ar && typeof ar.length === "number" && typeof ar.map === "function", + "expected array (like) object" + ); + return ar.map(propSchema.serializer); + }, + deserializer(jsonArray, done, context) { + if (jsonArray === null) { + // sometimes go will return null in place of empty array + return void done(null, []); + } + if (!Array.isArray(jsonArray)) { + return void done("[serializr] expected JSON array", null); + } + parallel( + jsonArray, + (item: any, itemDone: (err: any, targetPropertyValue: any) => void) => + propSchema.deserializer(item, itemDone, context, undefined), + done + ); + } + }; } diff --git a/common/sprinklersRpc/schema/requests.ts b/common/sprinklersRpc/schema/requests.ts index 42bcc56..f8d7d73 100644 --- a/common/sprinklersRpc/schema/requests.ts +++ b/common/sprinklersRpc/schema/requests.ts @@ -1,69 +1,89 @@ -import { createSimpleSchema, deserialize, ModelSchema, primitive, serialize } from "serializr"; +import { + createSimpleSchema, + deserialize, + ModelSchema, + primitive, + serialize +} from "serializr"; import * as requests from "@common/sprinklersRpc/deviceRequests"; import * as common from "./common"; export const withType: ModelSchema = createSimpleSchema({ - type: primitive(), + type: primitive() }); -export const withProgram: ModelSchema = createSimpleSchema({ - ...withType.props, - programId: primitive(), +export const withProgram: ModelSchema< + requests.WithProgram +> = createSimpleSchema({ + ...withType.props, + programId: primitive() }); -export const withSection: ModelSchema = createSimpleSchema({ - ...withType.props, - sectionId: primitive(), +export const withSection: ModelSchema< + requests.WithSection +> = createSimpleSchema({ + ...withType.props, + sectionId: primitive() }); -export const updateProgram: ModelSchema = createSimpleSchema({ - ...withProgram.props, - data: { - serializer: (data) => data, - deserializer: (json, done) => { done(null, json); }, - }, +export const updateProgram: ModelSchema< + requests.UpdateProgramData +> = createSimpleSchema({ + ...withProgram.props, + data: { + serializer: data => data, + deserializer: (json, done) => { + done(null, json); + } + } }); -export const runSection: ModelSchema = createSimpleSchema({ - ...withSection.props, - duration: common.duration, +export const runSection: ModelSchema< + requests.RunSectionData +> = createSimpleSchema({ + ...withSection.props, + duration: common.duration }); -export const cancelSectionRunId: ModelSchema = createSimpleSchema({ - ...withType.props, - runId: primitive(), +export const cancelSectionRunId: ModelSchema< + requests.CancelSectionRunIdData +> = createSimpleSchema({ + ...withType.props, + runId: primitive() }); -export const pauseSectionRunner: ModelSchema = createSimpleSchema({ - ...withType.props, - paused: primitive(), +export const pauseSectionRunner: ModelSchema< + requests.PauseSectionRunnerData +> = createSimpleSchema({ + ...withType.props, + paused: primitive() }); export function getRequestSchema(request: requests.WithType): ModelSchema { - switch (request.type as requests.RequestType) { - case "runProgram": - case "cancelProgram": - return withProgram; - case "updateProgram": - return updateProgram; - case "runSection": - return runSection; - case "cancelSection": - return withSection; - case "cancelSectionRunId": - return cancelSectionRunId; - case "pauseSectionRunner": - return pauseSectionRunner; - default: - throw new Error(`Cannot serialize request with type "${request.type}"`); - } + switch (request.type as requests.RequestType) { + case "runProgram": + case "cancelProgram": + return withProgram; + case "updateProgram": + return updateProgram; + case "runSection": + return runSection; + case "cancelSection": + return withSection; + case "cancelSectionRunId": + return cancelSectionRunId; + case "pauseSectionRunner": + return pauseSectionRunner; + default: + throw new Error(`Cannot serialize request with type "${request.type}"`); + } } export function seralizeRequest(request: requests.Request): any { - return serialize(getRequestSchema(request), request); + return serialize(getRequestSchema(request), request); } export function deserializeRequest(json: any): requests.Request { - return deserialize(getRequestSchema(json), json); + return deserialize(getRequestSchema(json), json); } diff --git a/common/sprinklersRpc/websocketData.ts b/common/sprinklersRpc/websocketData.ts index 80cc9d6..73b5437 100644 --- a/common/sprinklersRpc/websocketData.ts +++ b/common/sprinklersRpc/websocketData.ts @@ -3,76 +3,92 @@ import * as rpc from "@common/jsonRpc/index"; import { Response as ResponseData } from "@common/sprinklersRpc/deviceRequests"; export interface IAuthenticateRequest { - accessToken: string; + accessToken: string; } export interface IDeviceSubscribeRequest { - deviceId: string; + deviceId: string; } export interface IDeviceCallRequest { - deviceId: string; - data: any; + deviceId: string; + data: any; } export interface IClientRequestTypes { - "authenticate": IAuthenticateRequest; - "deviceSubscribe": IDeviceSubscribeRequest; - "deviceUnsubscribe": IDeviceSubscribeRequest; - "deviceCall": IDeviceCallRequest; + authenticate: IAuthenticateRequest; + deviceSubscribe: IDeviceSubscribeRequest; + deviceUnsubscribe: IDeviceSubscribeRequest; + deviceCall: IDeviceCallRequest; } export interface IAuthenticateResponse { - authenticated: boolean; - message: string; - user: IUser; + authenticated: boolean; + message: string; + user: IUser; } export interface IDeviceSubscribeResponse { - deviceId: string; + deviceId: string; } export interface IDeviceCallResponse { - data: ResponseData; + data: ResponseData; } export interface IServerResponseTypes { - "authenticate": IAuthenticateResponse; - "deviceSubscribe": IDeviceSubscribeResponse; - "deviceUnsubscribe": IDeviceSubscribeResponse; - "deviceCall": IDeviceCallResponse; + authenticate: IAuthenticateResponse; + deviceSubscribe: IDeviceSubscribeResponse; + deviceUnsubscribe: IDeviceSubscribeResponse; + deviceCall: IDeviceCallResponse; } export type ClientRequestMethods = keyof IClientRequestTypes; export interface IBrokerConnectionUpdate { - brokerConnected: boolean; + brokerConnected: boolean; } export interface IDeviceUpdate { - deviceId: string; - data: any; + deviceId: string; + data: any; } export interface IServerNotificationTypes { - "brokerConnectionUpdate": IBrokerConnectionUpdate; - "deviceUpdate": IDeviceUpdate; - "error": IError; + brokerConnectionUpdate: IBrokerConnectionUpdate; + deviceUpdate: IDeviceUpdate; + error: IError; } export type ServerNotificationMethod = keyof IServerNotificationTypes; export type IError = rpc.DefaultErrorType; export type ErrorData = rpc.ErrorData; -export type ServerMessage = rpc.Message<{}, IServerResponseTypes, IError, IServerNotificationTypes>; +export type ServerMessage = rpc.Message< + {}, + IServerResponseTypes, + IError, + IServerNotificationTypes +>; export type ServerNotification = rpc.Notification; export type ServerResponse = rpc.Response; -export type ServerResponseData = - rpc.ResponseData; -export type ServerResponseHandlers = rpc.ResponseHandlers; -export type ServerNotificationHandlers = rpc.NotificationHandlers; +export type ServerResponseData< + Method extends keyof IServerResponseTypes = keyof IServerResponseTypes +> = rpc.ResponseData; +export type ServerResponseHandlers = rpc.ResponseHandlers< + IServerResponseTypes, + IError +>; +export type ServerNotificationHandlers = rpc.NotificationHandlers< + IServerNotificationTypes +>; -export type ClientRequest = - rpc.Request; +export type ClientRequest< + Method extends keyof IClientRequestTypes = keyof IClientRequestTypes +> = rpc.Request; export type ClientMessage = rpc.Message; -export type ClientRequestHandlers = rpc.RequestHandlers; +export type ClientRequestHandlers = rpc.RequestHandlers< + IClientRequestTypes, + IServerResponseTypes, + IError +>; diff --git a/common/utils.ts b/common/utils.ts index 85cba02..2c70604 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -1,26 +1,30 @@ -export function checkedIndexOf(o: T | number, arr: T[], type: string = "object"): number { - let idx: number; - if (typeof o === "number") { - idx = o; - } else if (typeof (o as any).id === "number") { - idx = (o as any).id; - } else { - idx = arr.indexOf(o); - } - if (idx < 0 || idx > arr.length) { - throw new Error(`Invalid ${type} specified: ${o}`); - } - return idx; +export function checkedIndexOf( + o: T | number, + arr: T[], + type: string = "object" +): number { + let idx: number; + if (typeof o === "number") { + idx = o; + } else if (typeof (o as any).id === "number") { + idx = (o as any).id; + } else { + idx = arr.indexOf(o); + } + if (idx < 0 || idx > arr.length) { + throw new Error(`Invalid ${type} specified: ${o}`); + } + return idx; } export function getRandomId() { - return Math.floor(Math.random() * 1000000000); + return Math.floor(Math.random() * 1000000000); } export function applyMixins(derivedCtor: any, baseCtors: any[]) { - baseCtors.forEach((baseCtor) => { - Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { - derivedCtor.prototype[name] = baseCtor.prototype[name]; - }); + baseCtors.forEach(baseCtor => { + Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => { + derivedCtor.prototype[name] = baseCtor.prototype[name]; }); + }); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8c553b9..62ccb6c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,4 +39,4 @@ services: ports: - "5432:5432" environment: - - POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV \ No newline at end of file + - POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV diff --git a/package.json b/package.json index 57267a6..b9f6415 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,8 @@ "style-loader": "^0.22.1", "ts-loader": "^4.5.0", "tslint": "^5.11.0", + "tslint-config-prettier": "^1.15.0", + "tslint-consistent-codestyle": "^1.13.3", "tslint-react": "^3.6.0", "typescript": "^3.0.1", "uglify-es": "^3.3.9", diff --git a/paths.js b/paths.js index 401bee1..03c2990 100644 --- a/paths.js +++ b/paths.js @@ -33,4 +33,4 @@ exports.clientBuildDir = resolveRoot("public"); exports.publicDir = exports.clientBuildDir; exports.serverDir = resolveRoot("server"); -exports.serverBuildDir = resolveRoot("dist"); +exports.serverBuildDir = resolveRoot("dist"); \ No newline at end of file diff --git a/server/Database.ts b/server/Database.ts index d6a1936..04ca250 100644 --- a/server/Database.ts +++ b/server/Database.ts @@ -1,5 +1,11 @@ import * as path from "path"; -import { Connection, createConnection, EntityManager, getConnectionOptions, Repository } from "typeorm"; +import { + Connection, + createConnection, + EntityManager, + getConnectionOptions, + Repository +} from "typeorm"; import logger from "@common/logger"; @@ -7,80 +13,84 @@ import { SprinklersDevice, User } from "./entities"; import { SprinklersDeviceRepository, UserRepository } from "./repositories/"; export class Database { - users!: UserRepository; - sprinklersDevices!: SprinklersDeviceRepository; + users!: UserRepository; + sprinklersDevices!: SprinklersDeviceRepository; - private _conn: Connection | null = null; + private _conn: Connection | null = null; - get conn(): Connection { - if (this._conn == null) { - throw new Error("Not connected to rethinkDB"); - } - return this._conn; + get conn(): Connection { + if (this._conn == null) { + throw new Error("Not connected to rethinkDB"); } + return this._conn; + } - async connect() { - const options = await getConnectionOptions(); - Object.assign(options, { - entities: [ - path.resolve(__dirname, "entities", "*.js"), - ], - }); - this._conn = await createConnection(options); - this.users = this._conn.getCustomRepository(UserRepository); - this.sprinklersDevices = this._conn.getCustomRepository(SprinklersDeviceRepository); - } + async connect() { + const options = await getConnectionOptions(); + Object.assign(options, { + entities: [path.resolve(__dirname, "entities", "*.js")] + }); + this._conn = await createConnection(options); + this.users = this._conn.getCustomRepository(UserRepository); + this.sprinklersDevices = this._conn.getCustomRepository( + SprinklersDeviceRepository + ); + } - async disconnect() { - if (this._conn) { - return this._conn.close(); - } + async disconnect() { + if (this._conn) { + return this._conn.close(); } + } - async createAll() { - await this.conn.synchronize(); - if (process.env.INSERT_TEST_DATA) { - await this.insertData(); - } + async createAll() { + await this.conn.synchronize(); + if (process.env.INSERT_TEST_DATA) { + await this.insertData(); } + } - async insertData() { - const NUM = 100; - const users: User[] = []; - for (let i = 0; i < NUM; i++) { - const username = "alex" + i; - let user = await this.users.findByUsername(username); - if (!user) { - user = await this.users.create({ - name: "Alex Mikhalev" + i, - username, - }); - } - await user.setPassword("kakashka" + i); - users.push(user); - } - - for (let i = 0; i < NUM; i++) { - const name = "Test" + i; - let device = await this.sprinklersDevices.findByName(name); - if (!device) { - device = await this.sprinklersDevices.create(); - } - Object.assign(device, { name, deviceId: "grinklers" + (i === 1 ? "" : i) }); - await this.sprinklersDevices.save(device); - for (let j = 0; j < 5; j++) { - const userIdx = (i + j * 10) % NUM; - const user = users[userIdx]; - user.devices = (user.devices || []).concat([device]); - } - } - logger.info("inserted/updated devices"); + async insertData() { + const NUM = 100; + const users: User[] = []; + for (let i = 0; i < NUM; i++) { + const username = "alex" + i; + let user = await this.users.findByUsername(username); + if (!user) { + user = await this.users.create({ + name: "Alex Mikhalev" + i, + username + }); + } + await user.setPassword("kakashka" + i); + users.push(user); + } - await this.users.save(users); - logger.info("inserted/updated users"); + for (let i = 0; i < NUM; i++) { + const name = "Test" + i; + let device = await this.sprinklersDevices.findByName(name); + if (!device) { + device = await this.sprinklersDevices.create(); + } + Object.assign(device, { + name, + deviceId: "grinklers" + (i === 1 ? "" : i) + }); + await this.sprinklersDevices.save(device); + for (let j = 0; j < 5; j++) { + const userIdx = (i + j * 10) % NUM; + const user = users[userIdx]; + user.devices = (user.devices || []).concat([device]); + } + } + logger.info("inserted/updated devices"); - const alex2 = await this.users.findOne({ username: "alex0" }); - logger.info("password valid: " + await alex2!.comparePassword("kakashka0")); + await this.users.save(users); + logger.info("inserted/updated users"); - } + const alex2 = await this.users.findOne({ username: "alex0" }); + logger.info( + "password valid: " + (await alex2!.comparePassword("kakashka0")) + ); + } } diff --git a/server/authentication.ts b/server/authentication.ts index f192f9a..9bb314b 100644 --- a/server/authentication.ts +++ b/server/authentication.ts @@ -5,10 +5,10 @@ import * as jwt from "jsonwebtoken"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; import { - TokenGrantPasswordRequest, - TokenGrantRefreshRequest, - TokenGrantRequest, - TokenGrantResponse, + TokenGrantPasswordRequest, + TokenGrantRefreshRequest, + TokenGrantRequest, + TokenGrantResponse } from "@common/httpApi"; import * as tok from "@common/TokenClaims"; import { User } from "@server/entities"; @@ -16,96 +16,117 @@ import { ServerState } from "@server/state"; const JWT_SECRET = process.env.JWT_SECRET!; if (!JWT_SECRET) { - throw new Error("Must specify JWT_SECRET environment variable"); + throw new Error("Must specify JWT_SECRET environment variable"); } const ISSUER = "sprinklers3"; -const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes -const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours +const ACCESS_TOKEN_LIFETIME = 30 * 60; // 30 minutes +const REFRESH_TOKEN_LIFETIME = 24 * 60 * 60; // 24 hours -function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise { - const options: jwt.SignOptions = { - issuer: ISSUER, - ...opts, - }; - return new Promise((resolve, reject) => { - jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => { - if (err) { - reject(err); - } else { - resolve(encoded); - } - }); +function signToken( + claims: tok.TokenClaimTypes, + opts?: jwt.SignOptions +): Promise { + const options: jwt.SignOptions = { + issuer: ISSUER, + ...opts + }; + return new Promise((resolve, reject) => { + jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => { + if (err) { + reject(err); + } else { + resolve(encoded); + } }); + }); } -export function verifyToken( - token: string, type?: TClaims["type"], -): Promise { - return new Promise((resolve, reject) => { - jwt.verify(token, JWT_SECRET, { - issuer: ISSUER, - }, (err, decoded) => { - if (err) { - if (err.name === "TokenExpiredError") { - reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err)); - } else if (err.name === "JsonWebTokenError") { - reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); - } else { - reject(err); - } - } else { - const claims: tok.TokenClaims = decoded as any; - if (type != null && claims.type !== type) { - reject(new ApiError(`Expected a "${type}" token, received a "${claims.type}" token`, - ErrorCode.BadToken)); - } - resolve(claims as TClaims & tok.BaseClaims); - } - }); - }); +export function verifyToken< + TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes +>(token: string, type?: TClaims["type"]): Promise { + return new Promise((resolve, reject) => { + jwt.verify( + token, + JWT_SECRET, + { + issuer: ISSUER + }, + (err, decoded) => { + if (err) { + if (err.name === "TokenExpiredError") { + reject( + new ApiError( + "The specified token is expired", + ErrorCode.BadToken, + err + ) + ); + } else if (err.name === "JsonWebTokenError") { + reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); + } else { + reject(err); + } + } else { + const claims: tok.TokenClaims = decoded as any; + if (type != null && claims.type !== type) { + reject( + new ApiError( + `Expected a "${type}" token, received a "${claims.type}" token`, + ErrorCode.BadToken + ) + ); + } + resolve(claims as TClaims & tok.BaseClaims); + } + } + ); + }); } export function generateAccessToken(user: User): Promise { - const access_token_claims: tok.AccessToken = { - aud: user.id, - name: user.name, - type: "access", - }; + const accessTokenClaims: tok.AccessToken = { + aud: user.id, + name: user.name, + type: "access" + }; - return signToken(access_token_claims, { expiresIn: ACCESS_TOKEN_LIFETIME }); + return signToken(accessTokenClaims, { expiresIn: ACCESS_TOKEN_LIFETIME }); } export function generateRefreshToken(user: User): Promise { - const refresh_token_claims: tok.RefreshToken = { - aud: user.id, - name: user.name, - type: "refresh", - }; + const refreshTokenClaims: tok.RefreshToken = { + aud: user.id, + name: user.name, + type: "refresh" + }; - return signToken(refresh_token_claims, { expiresIn: REFRESH_TOKEN_LIFETIME }); + return signToken(refreshTokenClaims, { expiresIn: REFRESH_TOKEN_LIFETIME }); } export function generateDeviceRegistrationToken(): Promise { - const device_reg_token_claims: tok.DeviceRegistrationToken = { - type: "device_reg", - }; - return signToken(device_reg_token_claims); + const deviceRegTokenClaims: tok.DeviceRegistrationToken = { + type: "device_reg" + }; + return signToken(deviceRegTokenClaims); } -export function generateDeviceToken(id: number, deviceId: string): Promise { - const device_token_claims: tok.DeviceToken = { - type: "device", - aud: deviceId, - id, - }; - return signToken(device_token_claims); +export function generateDeviceToken( + id: number, + deviceId: string +): Promise { + const deviceTokenClaims: tok.DeviceToken = { + type: "device", + aud: deviceId, + id + }; + return signToken(deviceTokenClaims); } export function generateSuperuserToken(): Promise { - const superuser_claims: tok.SuperuserToken = { - type: "superuser", - }; - return signToken(superuser_claims); + const superuserClaims: tok.SuperuserToken = { + type: "superuser" + }; + return signToken(superuserClaims); } diff --git a/server/configureLogger.ts b/server/configureLogger.ts index b3eab68..73dba32 100644 --- a/server/configureLogger.ts +++ b/server/configureLogger.ts @@ -1,5 +1,5 @@ import log from "@common/logger"; Object.assign(log, { - name: "sprinklers3/server", - level: process.env.LOG_LEVEL || "debug", + name: "sprinklers3/server", + level: process.env.LOG_LEVEL || "debug" }); diff --git a/server/entities/SprinklersDevice.ts b/server/entities/SprinklersDevice.ts index cb14c0e..42ab082 100644 --- a/server/entities/SprinklersDevice.ts +++ b/server/entities/SprinklersDevice.ts @@ -5,38 +5,38 @@ import { User } from "./User"; @Entity() export class SprinklersDevice implements ISprinklersDevice { - @PrimaryGeneratedColumn() - id!: number; + @PrimaryGeneratedColumn() + id!: number; - @Column({ unique: true, nullable: true, type: "varchar" }) - deviceId: string | null = null; + @Column({ unique: true, nullable: true, type: "varchar" }) + deviceId: string | null = null; - @Column() - name: string = ""; + @Column() + name: string = ""; - @ManyToMany((type) => User) - users: User[] | undefined; + @ManyToMany(type => User) + users: User[] | undefined; - constructor(data?: Partial) { - if (data) { - Object.assign(this, data); - } + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); } + } } // @Entity() export class UserSprinklersDevice { - @PrimaryGeneratedColumn() - id!: number; - - @Column() - userId: string = ""; - @Column() - sprinklersDeviceId: string = ""; - - constructor(data?: UserSprinklersDevice) { - if (data) { - Object.assign(this, data); - } + @PrimaryGeneratedColumn() + id!: number; + + @Column() + userId: string = ""; + @Column() + sprinklersDeviceId: string = ""; + + constructor(data?: UserSprinklersDevice) { + if (data) { + Object.assign(this, data); } + } } diff --git a/server/entities/User.ts b/server/entities/User.ts index be9c01d..a34c839 100644 --- a/server/entities/User.ts +++ b/server/entities/User.ts @@ -1,45 +1,51 @@ import * as bcrypt from "bcrypt"; import { omit } from "lodash"; -import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + JoinTable, + ManyToMany, + PrimaryGeneratedColumn +} from "typeorm"; import { IUser } from "@common/httpApi"; -import { SprinklersDevice} from "./SprinklersDevice"; +import { SprinklersDevice } from "./SprinklersDevice"; const HASH_ROUNDS = 1; @Entity() export class User implements IUser { - @PrimaryGeneratedColumn() - id!: number; + @PrimaryGeneratedColumn() + id!: number; - @Column({ unique: true }) - username: string = ""; + @Column({ unique: true }) + username: string = ""; - @Column() - name: string = ""; + @Column() + name: string = ""; - @Column() - passwordHash: string = ""; + @Column() + passwordHash: string = ""; - @ManyToMany((type) => SprinklersDevice) - @JoinTable({ name: "user_sprinklers_device" }) - devices: SprinklersDevice[] | undefined; + @ManyToMany(type => SprinklersDevice) + @JoinTable({ name: "user_sprinklers_device" }) + devices: SprinklersDevice[] | undefined; - constructor(data?: Partial) { - if (data) { - Object.assign(this, data); - } + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); } + } - async setPassword(newPassword: string): Promise { - this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS); - } + async setPassword(newPassword: string): Promise { + this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS); + } - async comparePassword(password: string): Promise { - return bcrypt.compare(password, this.passwordHash); - } + async comparePassword(password: string): Promise { + return bcrypt.compare(password, this.passwordHash); + } - toJSON() { - return omit(this, "passwordHash"); - } + toJSON() { + return omit(this, "passwordHash"); + } } diff --git a/server/env.ts b/server/env.ts index 694a4e3..1fd5c86 100644 --- a/server/env.ts +++ b/server/env.ts @@ -12,13 +12,13 @@ const dotenvFiles: string[] = [ // since normally you expect tests to produce the same // results for everyone NODE_ENV !== "test" && `${paths.dotenv}.local`, - paths.dotenv, + paths.dotenv ].filter(Boolean) as string[]; -dotenvFiles.forEach((dotenvFile) => { +dotenvFiles.forEach(dotenvFile => { if (fs.existsSync(dotenvFile)) { require("dotenv").config({ - path: dotenvFile, + path: dotenvFile }); } }); diff --git a/server/express/api/devices.ts b/server/express/api/devices.ts index fbb6d7a..ea83a84 100644 --- a/server/express/api/devices.ts +++ b/server/express/api/devices.ts @@ -1,5 +1,5 @@ import PromiseRouter from "express-promise-router"; -import { serialize} from "serializr"; +import { serialize } from "serializr"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; @@ -11,59 +11,76 @@ import { ServerState } from "@server/state"; const DEVICE_ID_LEN = 20; function randomDeviceId(): string { - let deviceId = ""; - for (let i = 0; i < DEVICE_ID_LEN; i++) { - const j = Math.floor(Math.random() * 36); - let ch; // tslint:disable-next-line - if (j < 10) { // 0-9 - ch = String.fromCharCode(48 + j); - } else { // a-z - ch = String.fromCharCode(97 + (j - 10)); - } - deviceId += ch; + let deviceId = ""; + for (let i = 0; i < DEVICE_ID_LEN; i++) { + const j = Math.floor(Math.random() * 36); + let ch; // tslint:disable-next-line + if (j < 10) { + // 0-9 + ch = String.fromCharCode(48 + j); + } else { + // a-z + ch = String.fromCharCode(97 + (j - 10)); } - return deviceId; + deviceId += ch; + } + return deviceId; } export function devices(state: ServerState) { - const router = PromiseRouter(); + const router = PromiseRouter(); - router.get("/:deviceId", verifyAuthorization(), async (req, res) => { - const token = req.token!; - const userId = token.aud; - const deviceId = req.params.deviceId; - const userDevice = await state.database.sprinklersDevices - .findUserDevice(userId, deviceId); - if (!userDevice) { - throw new ApiError("User does not have access to the specified device", ErrorCode.NoPermission); - } - const device = state.mqttClient.acquireDevice(req.params.deviceId); - const j = serialize(schema.sprinklersDevice, device); - res.send(j); - device.release(); - }); + router.get("/:deviceId", verifyAuthorization(), async (req, res) => { + const token = req.token!; + const userId = token.aud; + const deviceId = req.params.deviceId; + const userDevice = await state.database.sprinklersDevices.findUserDevice( + userId, + deviceId + ); + if (!userDevice) { + throw new ApiError( + "User does not have access to the specified device", + ErrorCode.NoPermission + ); + } + const device = state.mqttClient.acquireDevice(req.params.deviceId); + const j = serialize(schema.sprinklersDevice, device); + res.send(j); + device.release(); + }); - router.post("/register", verifyAuthorization({ - type: "device_reg", - }), async (req, res) => { - const deviceId = randomDeviceId(); - const newDevice = state.database.sprinklersDevices.create({ - name: "Sprinklers Device", deviceId, - }); - await state.database.sprinklersDevices.save(newDevice); - const token = await generateDeviceToken(newDevice.id, deviceId); - res.send({ - data: newDevice, token, - }); - }); + router.post( + "/register", + verifyAuthorization({ + type: "device_reg" + }), + async (req, res) => { + const deviceId = randomDeviceId(); + const newDevice = state.database.sprinklersDevices.create({ + name: "Sprinklers Device", + deviceId + }); + await state.database.sprinklersDevices.save(newDevice); + const token = await generateDeviceToken(newDevice.id, deviceId); + res.send({ + data: newDevice, + token + }); + } + ); - router.post("/connect", verifyAuthorization({ - type: "device", - }), async (req, res) => { - res.send({ - url: state.mqttUrl, - }); - }); + router.post( + "/connect", + verifyAuthorization({ + type: "device" + }), + async (req, res) => { + res.send({ + url: state.mqttUrl + }); + } + ); - return router; + return router; } diff --git a/server/express/api/index.ts b/server/express/api/index.ts index 7458a65..5f1bba2 100644 --- a/server/express/api/index.ts +++ b/server/express/api/index.ts @@ -10,16 +10,16 @@ import { token } from "./token"; import { users } from "./users"; export default function createApi(state: ServerState) { - const router = PromiseRouter(); + const router = PromiseRouter(); - router.use("/devices", devices(state)); - router.use("/users", users(state)); - router.use("/mosquitto", mosquitto(state)); - router.use("/token", token(state)); + router.use("/devices", devices(state)); + router.use("/users", users(state)); + router.use("/mosquitto", mosquitto(state)); + router.use("/token", token(state)); - router.use("*", (req, res) => { - throw new ApiError("API endpoint not found", ErrorCode.NotFound); - }); + router.use("*", (req, res) => { + throw new ApiError("API endpoint not found", ErrorCode.NotFound); + }); - return router; + return router; } diff --git a/server/express/api/mosquitto.ts b/server/express/api/mosquitto.ts index 9adcaf3..1ab2790 100644 --- a/server/express/api/mosquitto.ts +++ b/server/express/api/mosquitto.ts @@ -10,49 +10,56 @@ import { ServerState } from "@server/state"; export const SUPERUSER = "sprinklers3"; export function mosquitto(state: ServerState) { - const router = PromiseRouter(); + const router = PromiseRouter(); - router.post("/auth", async (req, res) => { - const body = req.body; - const { username, password, topic, acc } = body; - if (typeof username !== "string" || typeof password !== "string") { - throw new ApiError("Must specify a username and password", ErrorCode.BadRequest); - } - if (username === SUPERUSER) { - await verifyToken(password, "superuser"); - return res.status(200).send({ username }); - } - const claims = await verifyToken(password, "device"); - if (claims.aud !== username) { - throw new ApiError("Username does not match token", ErrorCode.BadRequest); - } - res.status(200).send({ - username, id: claims.id, - }); + router.post("/auth", async (req, res) => { + const body = req.body; + const { username, password, topic, acc } = body; + if (typeof username !== "string" || typeof password !== "string") { + throw new ApiError( + "Must specify a username and password", + ErrorCode.BadRequest + ); + } + if (username === SUPERUSER) { + await verifyToken(password, "superuser"); + return res.status(200).send({ username }); + } + const claims = await verifyToken(password, "device"); + if (claims.aud !== username) { + throw new ApiError("Username does not match token", ErrorCode.BadRequest); + } + res.status(200).send({ + username, + id: claims.id }); + }); - router.post("/superuser", async (req, res) => { - const { username } = req.body; - if (typeof username !== "string") { - throw new ApiError("Must specify a username", ErrorCode.BadRequest); - } - if (username !== SUPERUSER) { - return res.status(403).send(); - } - res.status(200).send(); - }); + router.post("/superuser", async (req, res) => { + const { username } = req.body; + if (typeof username !== "string") { + throw new ApiError("Must specify a username", ErrorCode.BadRequest); + } + if (username !== SUPERUSER) { + return res.status(403).send(); + } + res.status(200).send(); + }); - router.post("/acl", async (req, res) => { - const { username, topic, clientid, acc } = req.body; - if (typeof username !== "string" || typeof topic !== "string") { - throw new ApiError("username and topic must be specified as strings", ErrorCode.BadRequest); - } - const prefix = DEVICE_PREFIX + "/" + username; - if (!topic.startsWith(prefix)) { - throw new ApiError(`device ${username} cannot access topic ${topic}`); - } - res.status(200).send(); - }); + router.post("/acl", async (req, res) => { + const { username, topic, clientid, acc } = req.body; + if (typeof username !== "string" || typeof topic !== "string") { + throw new ApiError( + "username and topic must be specified as strings", + ErrorCode.BadRequest + ); + } + const prefix = DEVICE_PREFIX + "/" + username; + if (!topic.startsWith(prefix)) { + throw new ApiError(`device ${username} cannot access topic ${topic}`); + } + res.status(200).send(); + }); - return router; + return router; } diff --git a/server/express/api/token.ts b/server/express/api/token.ts index 294c76c..85db5d3 100644 --- a/server/express/api/token.ts +++ b/server/express/api/token.ts @@ -9,73 +9,81 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization"; import { ServerState } from "@server/state"; export function token(state: ServerState) { - const router = PromiseRouter(); + const router = PromiseRouter(); - async function passwordGrant(body: httpApi.TokenGrantPasswordRequest, res: Express.Response): Promise { - const { username, password } = body; - if (!body || !username || !password) { - throw new ApiError("Must specify username and password"); - } - const user = await state.database.users.findByUsername(username); - if (!user) { - throw new ApiError("User does not exist"); - } - const passwordMatches = await user.comparePassword(password); - if (passwordMatches) { - return user; - } else { - throw new ApiError("Invalid user credentials"); - } + async function passwordGrant( + body: httpApi.TokenGrantPasswordRequest, + res: Express.Response + ): Promise { + const { username, password } = body; + if (!body || !username || !password) { + throw new ApiError("Must specify username and password"); } + const user = await state.database.users.findByUsername(username); + if (!user) { + throw new ApiError("User does not exist"); + } + const passwordMatches = await user.comparePassword(password); + if (passwordMatches) { + return user; + } else { + throw new ApiError("Invalid user credentials"); + } + } - async function refreshGrant(body: httpApi.TokenGrantRefreshRequest, res: Express.Response): Promise { - const { refresh_token } = body; - if (!body || !refresh_token) { - throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken); - } - const claims = await authentication.verifyToken(refresh_token); - if (claims.type !== "refresh") { - throw new ApiError("Not a refresh token", ErrorCode.BadToken); - } - const user = await state.database.users.findOne(claims.aud); - if (!user) { - throw new ApiError("User no longer exists", ErrorCode.BadToken); - } - return user; + async function refreshGrant( + body: httpApi.TokenGrantRefreshRequest, + res: Express.Response + ): Promise { + const { refresh_token } = body; + if (!body || !refresh_token) { + throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken); + } + const claims = await authentication.verifyToken(refresh_token); + if (claims.type !== "refresh") { + throw new ApiError("Not a refresh token", ErrorCode.BadToken); + } + const user = await state.database.users.findOne(claims.aud); + if (!user) { + throw new ApiError("User no longer exists", ErrorCode.BadToken); } + return user; + } - router.post("/grant", async (req, res) => { - const body: httpApi.TokenGrantRequest = req.body; - let user: User; - if (body.grant_type === "password") { - user = await passwordGrant(body, res); - } else if (body.grant_type === "refresh") { - user = await refreshGrant(body, res); - } else { - throw new ApiError("Invalid grant_type"); - } - const [access_token, refresh_token] = await Promise.all([ - await authentication.generateAccessToken(user), - await authentication.generateRefreshToken(user), - ]); - const response: httpApi.TokenGrantResponse = { - access_token, refresh_token, - }; - res.json(response); - }); + router.post("/grant", async (req, res) => { + const body: httpApi.TokenGrantRequest = req.body; + let user: User; + if (body.grant_type === "password") { + user = await passwordGrant(body, res); + } else if (body.grant_type === "refresh") { + user = await refreshGrant(body, res); + } else { + throw new ApiError("Invalid grant_type"); + } + // tslint:disable-next-line:variable-name + const [access_token, refresh_token] = await Promise.all([ + await authentication.generateAccessToken(user), + await authentication.generateRefreshToken(user) + ]); + const response: httpApi.TokenGrantResponse = { + access_token, + refresh_token + }; + res.json(response); + }); - router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => { - // tslint:disable-next-line:no-shadowed-variable - const token = await authentication.generateDeviceRegistrationToken(); - res.json({ token }); - }); + router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => { + // tslint:disable-next-line:no-shadowed-variable + const token = await authentication.generateDeviceRegistrationToken(); + res.json({ token }); + }); - router.post("/verify", verifyAuthorization(), async (req, res) => { - res.json({ - ok: true, - token: req.token, - }); + router.post("/verify", verifyAuthorization(), async (req, res) => { + res.json({ + ok: true, + token: req.token }); + }); - return router; + return router; } diff --git a/server/express/api/users.ts b/server/express/api/users.ts index 36d0ae6..859f56e 100644 --- a/server/express/api/users.ts +++ b/server/express/api/users.ts @@ -7,42 +7,42 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization"; import { ServerState } from "@server/state"; export function users(state: ServerState) { - const router = PromiseRouter(); - - router.use(verifyAuthorization()); - - async function getUser(params: { username: string }): Promise { - const { username } = params; - const user = await state.database.users - .findByUsername(username, { devices: true }); - if (!user) { - throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound); - } - return user; - } + const router = PromiseRouter(); + + router.use(verifyAuthorization()); - router.get("/", (req, res) => { - state.database.users.findAll() - .then((users_) => { - res.json({ - data: users_, - }); - }); + async function getUser(params: { username: string }): Promise { + const { username } = params; + const user = await state.database.users.findByUsername(username, { + devices: true + }); + if (!user) { + throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound); + } + return user; + } + + router.get("/", (req, res) => { + state.database.users.findAll().then(_users => { + res.json({ + data: _users + }); }); + }); - router.get("/:username", async (req, res) => { - const user = await getUser(req.params); - res.json({ - data: user, - }); + router.get("/:username", async (req, res) => { + const user = await getUser(req.params); + res.json({ + data: user }); + }); - router.get("/:username/devices", async (req, res) => { - const user = await getUser(req.params); - res.json({ - data: user.devices, - }); + router.get("/:username/devices", async (req, res) => { + const user = await getUser(req.params); + res.json({ + data: user.devices }); + }); - return router; + return router; } diff --git a/server/express/errorHandler.ts b/server/express/errorHandler.ts index 494ce99..e161a0f 100644 --- a/server/express/errorHandler.ts +++ b/server/express/errorHandler.ts @@ -2,13 +2,17 @@ import * as express from "express"; import ApiError from "@common/ApiError"; -const errorHandler: express.ErrorRequestHandler = - (err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { - if (err instanceof ApiError) { - res.status(err.statusCode).json(err.toJSON()); - } else { - next(err); - } - }; +const errorHandler: express.ErrorRequestHandler = ( + err: any, + req: express.Request, + res: express.Response, + next: express.NextFunction +) => { + if (err instanceof ApiError) { + res.status(err.statusCode).json(err.toJSON()); + } else { + next(err); + } +}; export default errorHandler; diff --git a/server/express/index.ts b/server/express/index.ts index 5ced086..bdc45a9 100644 --- a/server/express/index.ts +++ b/server/express/index.ts @@ -8,17 +8,17 @@ import requestLogger from "./requestLogger"; import serveApp from "./serveApp"; export function createApp(state: ServerState) { - const app = express(); + const app = express(); - app.use(requestLogger); - app.use(bodyParser.json()); - app.use(bodyParser.urlencoded({ extended: true })); + app.use(requestLogger); + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ extended: true })); - app.use("/api", createApi(state)); + app.use("/api", createApi(state)); - serveApp(app); + serveApp(app); - app.use(errorHandler); + app.use(errorHandler); - return app; + return app; } diff --git a/server/express/serveApp.ts b/server/express/serveApp.ts index fa875c6..23d41c2 100644 --- a/server/express/serveApp.ts +++ b/server/express/serveApp.ts @@ -8,8 +8,8 @@ const paths = require("paths"); const index = path.join(paths.publicDir, "index.html"); export default function serveApp(app: Express) { - app.use(serveStatic(paths.clientBuildDir)); - app.get("/*", (req, res) => { - res.sendFile(index); - }); + app.use(serveStatic(paths.clientBuildDir)); + app.get("/*", (req, res) => { + res.sendFile(index); + }); } diff --git a/server/express/verifyAuthorization.ts b/server/express/verifyAuthorization.ts index 7b7173b..da7ae6c 100644 --- a/server/express/verifyAuthorization.ts +++ b/server/express/verifyAuthorization.ts @@ -6,36 +6,44 @@ import * as tok from "@common/TokenClaims"; import { verifyToken } from "@server/authentication"; declare global { - namespace Express { - interface Request { - token?: tok.AccessToken; - } + namespace Express { + interface Request { + token?: tok.AccessToken; } + } } export interface VerifyAuthorizationOpts { - type: tok.TokenClaims["type"]; + type: tok.TokenClaims["type"]; } -export function verifyAuthorization(options?: Partial): Express.RequestHandler { - const opts: VerifyAuthorizationOpts = { - type: "access", - ...options, - }; - return (req, res, next) => { - const fun = async () => { - const bearer = req.headers.authorization; - if (!bearer) { - throw new ApiError("No Authorization header specified", ErrorCode.BadToken); - } - const matches = /^Bearer (.*)$/.exec(bearer); - if (!matches || !matches[1]) { - throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken); - } - const token = matches[1]; +export function verifyAuthorization( + options?: Partial +): Express.RequestHandler { + const opts: VerifyAuthorizationOpts = { + type: "access", + ...options + }; + return (req, res, next) => { + const fun = async () => { + const bearer = req.headers.authorization; + if (!bearer) { + throw new ApiError( + "No Authorization header specified", + ErrorCode.BadToken + ); + } + const matches = /^Bearer (.*)$/.exec(bearer); + if (!matches || !matches[1]) { + throw new ApiError( + "Invalid Authorization header, must be Bearer", + ErrorCode.BadToken + ); + } + const token = matches[1]; - req.token = await verifyToken(token, opts.type) as any; - }; - fun().then(() => next(null), (err) => next(err)); + req.token = (await verifyToken(token, opts.type)) as any; }; + fun().then(() => next(null), err => next(err)); + }; } diff --git a/server/index.ts b/server/index.ts index cfc6981..cc667f7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,7 +5,7 @@ import "./env"; import "./configureLogger"; import log from "@common/logger"; -import {Server} from "http"; +import { Server } from "http"; import * as WebSocket from "ws"; import { ServerState } from "./state"; @@ -20,15 +20,16 @@ const port = +(process.env.PORT || 8080); const host = process.env.HOST || "0.0.0.0"; const server = new Server(app); -const webSocketServer = new WebSocket.Server({server}); +const webSocketServer = new WebSocket.Server({ server }); webSocketApi.listen(webSocketServer); -state.start() - .then(() => { - server.listen(port, host, () => { - log.info(`listening at ${host}:${port}`); - }); - }) - .catch((err) => { - log.error({ err }, "error starting server"); +state + .start() + .then(() => { + server.listen(port, host, () => { + log.info(`listening at ${host}:${port}`); }); + }) + .catch(err => { + log.error({ err }, "error starting server"); + }); diff --git a/server/logging/prettyPrint.ts b/server/logging/prettyPrint.ts index bb581b3..199a893 100644 --- a/server/logging/prettyPrint.ts +++ b/server/logging/prettyPrint.ts @@ -6,137 +6,149 @@ import { Transform, TransformCallback } from "stream"; type Level = "default" | 60 | 50 | 40 | 30 | 20 | 10; const levels = { - default: "USERLVL", - 60: "FATAL", - 50: "ERROR", - 40: "WARN", - 30: "INFO", - 20: "DEBUG", - 10: "TRACE", + default: "USERLVL", + 60: "FATAL", + 50: "ERROR", + 40: "WARN", + 30: "INFO", + 20: "DEBUG", + 10: "TRACE" }; const levelColors = { - default: chalk.white.underline, - 60: chalk.bgRed.underline, - 50: chalk.red.underline, - 40: chalk.yellow.underline, - 30: chalk.green.underline, - 20: chalk.blue.underline, - 10: chalk.grey.underline, + default: chalk.white.underline, + 60: chalk.bgRed.underline, + 50: chalk.red.underline, + 40: chalk.yellow.underline, + 30: chalk.green.underline, + 20: chalk.blue.underline, + 10: chalk.grey.underline }; -const standardKeys = ["pid", "hostname", "name", "level", "time", "v", "source", "msg"]; +const standardKeys = [ + "pid", + "hostname", + "name", + "level", + "time", + "v", + "source", + "msg" +]; function formatter(value: any) { - let line = formatTime(value, " "); - line += formatSource(value); - line += asColoredLevel(value); - - // line += " ("; - // if (value.name) { - // line += value.name + "/"; - // } - // line += value.pid + " on " + value.hostname + ")"; - - const isRequest = value.req && value.res; - - line += ": "; - if (isRequest) { - line += chalk.reset(formatRequest(value)); - return line; - } - if (value.msg) { - line += chalk.cyan(value.msg); - } - if (value.err) { - line += "\n " + withSpaces(value.err.stack) + "\n"; - } else { - line += filter(value); - } - line += "\n"; + let line = formatTime(value, " "); + line += formatSource(value); + line += asColoredLevel(value); + + // line += " ("; + // if (value.name) { + // line += value.name + "/"; + // } + // line += value.pid + " on " + value.hostname + ")"; + + const isRequest = value.req && value.res; + + line += ": "; + if (isRequest) { + line += chalk.reset(formatRequest(value)); return line; + } + if (value.msg) { + line += chalk.cyan(value.msg); + } + if (value.err) { + line += "\n " + withSpaces(value.err.stack) + "\n"; + } else { + line += filter(value); + } + line += "\n"; + return line; } function formatRequest(value: any): string { - const matches = /Content-Length: (\d+)/.exec(value.res.header); - const contentLength = matches ? matches[1] : null; - return `${value.req.remoteAddress} - ` + - `"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` + - `${value.responseTime} ms - ${contentLength}`; + const matches = /Content-Length: (\d+)/.exec(value.res.header); + const contentLength = matches ? matches[1] : null; + return ( + `${value.req.remoteAddress} - ` + + `"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` + + `${value.responseTime} ms - ${contentLength}` + ); } function withSpaces(value: string): string { - const lines = value.split("\n"); - for (let i = 1; i < lines.length; i++) { - lines[i] = " " + lines[i]; - } - return lines.join("\n"); + const lines = value.split("\n"); + for (let i = 1; i < lines.length; i++) { + lines[i] = " " + lines[i]; + } + return lines.join("\n"); } function filter(value: any) { - const keys = Object.keys(value); - const filteredKeys = standardKeys; - let result = ""; - - for (const key of keys) { - if (filteredKeys.indexOf(key) < 0) { - result += "\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2)); - } + const keys = Object.keys(value); + const filteredKeys = standardKeys; + let result = ""; + + for (const key of keys) { + if (filteredKeys.indexOf(key) < 0) { + result += + "\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2)); } + } - return result; + return result; } function asISODate(time: string) { - return new Date(time).toISOString(); + return new Date(time).toISOString(); } function formatTime(value: any, after?: string) { - after = after || ""; - try { - if (!value || !value.time) { - return ""; - } else { - return "[" + asISODate(value.time) + "]" + after; - } - } catch (_) { - return ""; + after = after || ""; + try { + if (!value || !value.time) { + return ""; + } else { + return "[" + asISODate(value.time) + "]" + after; } + } catch (_) { + return ""; + } } function formatSource(value: any) { - if (value.source) { - return chalk.magenta("(" + value.source + ") "); - } else { - return ""; - } + if (value.source) { + return chalk.magenta("(" + value.source + ") "); + } else { + return ""; + } } function asColoredLevel(value: any) { - const level = value.level as Level; - if (levelColors.hasOwnProperty(level)) { - return levelColors[level](levels[level]); - } else { - return levelColors.default(levels.default); - } + const level = value.level as Level; + if (levelColors.hasOwnProperty(level)) { + return levelColors[level](levels[level]); + } else { + return levelColors.default(levels.default); + } } class PrettyPrintTranform extends Transform { - _transform(chunk: any, encoding: string, cb: TransformCallback) { - let value: any; - try { - value = JSON.parse(chunk.toString()); - } catch (e) { - process.stdout.write(chunk.toString() + "\n"); - return cb(); - } - const line = formatter(value); - if (!line) { - return cb(); - } - process.stdout.write(line); - cb(); + _transform(chunk: any, encoding: string, cb: TransformCallback) { + let value: any; + try { + value = JSON.parse(chunk.toString()); + } catch (e) { + process.stdout.write(chunk.toString() + "\n"); + return cb(); + } + const line = formatter(value); + if (!line) { + return cb(); } + process.stdout.write(line); + cb(); + } } pump(process.stdin, split(), new PrettyPrintTranform()); diff --git a/server/repositories/SprinklersDeviceRepository.ts b/server/repositories/SprinklersDeviceRepository.ts index a2ce336..9f0b9b3 100644 --- a/server/repositories/SprinklersDeviceRepository.ts +++ b/server/repositories/SprinklersDeviceRepository.ts @@ -4,30 +4,39 @@ import { SprinklersDevice, User } from "@server/entities"; @EntityRepository(SprinklersDevice) export class SprinklersDeviceRepository extends Repository { - findByName(name: string) { - return this.findOne({ name }); - } + findByName(name: string) { + return this.findOne({ name }); + } - async userHasAccess(userId: number, deviceId: number): Promise { - const count = await this.manager - .createQueryBuilder(User, "user") - .innerJoinAndSelect("user.devices", "sprinklers_device", - "user.id = :userId AND sprinklers_device.id = :deviceId", - { userId, deviceId }) - .getCount(); - return count > 0; - } + async userHasAccess(userId: number, deviceId: number): Promise { + const count = await this.manager + .createQueryBuilder(User, "user") + .innerJoinAndSelect( + "user.devices", + "sprinklers_device", + "user.id = :userId AND sprinklers_device.id = :deviceId", + { userId, deviceId } + ) + .getCount(); + return count > 0; + } - async findUserDevice(userId: number, deviceId: number): Promise { - const user = await this.manager - .createQueryBuilder(User, "user") - .innerJoinAndSelect("user.devices", "sprinklers_device", - "user.id = :userId AND sprinklers_device.id = :deviceId", - { userId, deviceId }) - .getOne(); - if (!user) { - return null; - } - return user.devices![0]; + async findUserDevice( + userId: number, + deviceId: number + ): Promise { + const user = await this.manager + .createQueryBuilder(User, "user") + .innerJoinAndSelect( + "user.devices", + "sprinklers_device", + "user.id = :userId AND sprinklers_device.id = :deviceId", + { userId, deviceId } + ) + .getOne(); + if (!user) { + return null; } + return user.devices![0]; + } } diff --git a/server/repositories/UserRepository.ts b/server/repositories/UserRepository.ts index 2048839..330bbe1 100644 --- a/server/repositories/UserRepository.ts +++ b/server/repositories/UserRepository.ts @@ -3,30 +3,31 @@ import { EntityRepository, FindOneOptions, Repository } from "typeorm"; import { User } from "@server/entities"; export interface FindUserOptions { - devices: boolean; + devices: boolean; } -function applyDefaultOptions(options?: Partial): FindOneOptions { - const opts: FindUserOptions = { devices: false, ...options }; - const relations = [opts.devices && "devices"] - .filter(Boolean) as string[]; - return { relations }; +function applyDefaultOptions( + options?: Partial +): FindOneOptions { + const opts: FindUserOptions = { devices: false, ...options }; + const relations = [opts.devices && "devices"].filter(Boolean) as string[]; + return { relations }; } @EntityRepository(User) export class UserRepository extends Repository { - findAll(options?: Partial) { - const opts = applyDefaultOptions(options); - return super.find(opts); - } + findAll(options?: Partial) { + const opts = applyDefaultOptions(options); + return super.find(opts); + } - findById(id: number, options?: Partial) { - const opts = applyDefaultOptions(options); - return super.findOne(id, opts); - } + findById(id: number, options?: Partial) { + const opts = applyDefaultOptions(options); + return super.findOne(id, opts); + } - findByUsername(username: string, options?: Partial) { - const opts = applyDefaultOptions(options); - return this.findOne({ username }, opts); - } + findByUsername(username: string, options?: Partial) { + const opts = applyDefaultOptions(options); + return this.findOne({ username }, opts); + } } diff --git a/server/sprinklersRpc/WebSocketApi.ts b/server/sprinklersRpc/WebSocketApi.ts index 96aa093..8c38671 100644 --- a/server/sprinklersRpc/WebSocketApi.ts +++ b/server/sprinklersRpc/WebSocketApi.ts @@ -4,23 +4,23 @@ import { ServerState } from "@server/state"; import { WebSocketConnection } from "./WebSocketConnection"; export class WebSocketApi { - state: ServerState; - clients: Set = new Set(); + state: ServerState; + clients: Set = new Set(); - constructor(state: ServerState) { - this.state = state; - } + constructor(state: ServerState) { + this.state = state; + } - listen(webSocketServer: WebSocket.Server) { - webSocketServer.on("connection", this.handleConnection); - } + listen(webSocketServer: WebSocket.Server) { + webSocketServer.on("connection", this.handleConnection); + } - handleConnection = (socket: WebSocket) => { - const client = new WebSocketConnection(this, socket); - this.clients.add(client); - } + handleConnection = (socket: WebSocket) => { + const client = new WebSocketConnection(this, socket); + this.clients.add(client); + }; - removeClient(client: WebSocketConnection) { - return this.clients.delete(client); - } + removeClient(client: WebSocketConnection) { + return this.clients.delete(client); + } } diff --git a/server/sprinklersRpc/WebSocketConnection.ts b/server/sprinklersRpc/WebSocketConnection.ts index 307e30a..240941a 100644 --- a/server/sprinklersRpc/WebSocketConnection.ts +++ b/server/sprinklersRpc/WebSocketConnection.ts @@ -18,245 +18,299 @@ import { WebSocketApi } from "./WebSocketApi"; type Disposer = () => void; export class WebSocketConnection { - api: WebSocketApi; - socket: WebSocket; - - disposers: Array<() => void> = []; - // map of device id to disposer function - deviceSubscriptions: Map = new Map(); - - /// This shall be the user id if the client has been authenticated, null otherwise - userId: number | null = null; - user: User | null = null; - - private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers(); - - get state() { - return this.api.state; - } - - constructor(api: WebSocketApi, socket: WebSocket) { - this.api = api; - this.socket = socket; - - this.socket.on("message", this.handleSocketMessage); - this.socket.on("close", this.onClose); - } - - stop = () => { - this.socket.close(); - } - - onClose = (code: number, reason: string) => { - log.debug({ code, reason }, "WebSocketConnection closing"); - this.disposers.forEach((disposer) => disposer()); - this.deviceSubscriptions.forEach((disposer) => disposer()); - this.api.removeClient(this); - } - - subscribeBrokerConnection() { - this.disposers.push(autorun(() => { - const updateData: ws.IBrokerConnectionUpdate = { - brokerConnected: this.state.mqttClient.connected, - }; - this.sendNotification("brokerConnectionUpdate", updateData); - })); - } - - checkAuthorization() { - if (!this.userId || !this.user) { - throw new RpcError("this WebSocket session has not been authenticated", - ErrorCode.Unauthorized); - } - } - - checkDevice(devId: string) { - const userDevice = this.user!.devices!.find((dev) => dev.deviceId === devId); - if (userDevice == null) { - throw new RpcError("you do not have permission to subscribe to device", - ErrorCode.NoPermission, { id: devId }); - } - const deviceId = userDevice.deviceId; - if (!deviceId) { - throw new RpcError("device has no associated device prefix", ErrorCode.Internal); - } - return userDevice; - } - - sendMessage(data: ws.ServerMessage) { - this.socket.send(JSON.stringify(data)); + api: WebSocketApi; + socket: WebSocket; + + disposers: Array<() => void> = []; + // map of device id to disposer function + deviceSubscriptions: Map = new Map(); + + /// This shall be the user id if the client has been authenticated, null otherwise + userId: number | null = null; + user: User | null = null; + + private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers(); + + get state() { + return this.api.state; + } + + constructor(api: WebSocketApi, socket: WebSocket) { + this.api = api; + this.socket = socket; + + this.socket.on("message", this.handleSocketMessage); + this.socket.on("close", this.onClose); + } + + stop = () => { + this.socket.close(); + }; + + onClose = (code: number, reason: string) => { + log.debug({ code, reason }, "WebSocketConnection closing"); + this.disposers.forEach(disposer => disposer()); + this.deviceSubscriptions.forEach(disposer => disposer()); + this.api.removeClient(this); + }; + + subscribeBrokerConnection() { + this.disposers.push( + autorun(() => { + const updateData: ws.IBrokerConnectionUpdate = { + brokerConnected: this.state.mqttClient.connected + }; + this.sendNotification("brokerConnectionUpdate", updateData); + }) + ); + } + + checkAuthorization() { + if (!this.userId || !this.user) { + throw new RpcError( + "this WebSocket session has not been authenticated", + ErrorCode.Unauthorized + ); } - - sendNotification( - method: Method, - data: ws.IServerNotificationTypes[Method]) { - this.sendMessage({ type: "notification", method, data }); + } + + checkDevice(devId: string) { + const userDevice = this.user!.devices!.find(dev => dev.deviceId === devId); + if (userDevice == null) { + throw new RpcError( + "you do not have permission to subscribe to device", + ErrorCode.NoPermission, + { id: devId } + ); } - - sendResponse( - method: Method, - id: number, - data: ws.ServerResponseData) { - this.sendMessage({ type: "response", method, id, ...data }); + const deviceId = userDevice.deviceId; + if (!deviceId) { + throw new RpcError( + "device has no associated device prefix", + ErrorCode.Internal + ); } - - handleSocketMessage = (socketData: WebSocket.Data) => { - this.doHandleSocketMessage(socketData) - .catch((err) => { - this.onError({ err }, "unhandled error on handling socket message"); - }); + return userDevice; + } + + sendMessage(data: ws.ServerMessage) { + this.socket.send(JSON.stringify(data)); + } + + sendNotification( + method: Method, + data: ws.IServerNotificationTypes[Method] + ) { + this.sendMessage({ type: "notification", method, data }); + } + + sendResponse( + method: Method, + id: number, + data: ws.ServerResponseData + ) { + this.sendMessage({ type: "response", method, id, ...data }); + } + + handleSocketMessage = (socketData: WebSocket.Data) => { + this.doHandleSocketMessage(socketData).catch(err => { + this.onError({ err }, "unhandled error on handling socket message"); + }); + }; + + async doDeviceCallRequest( + requestData: ws.IDeviceCallRequest + ): Promise { + const userDevice = this.checkDevice(requestData.deviceId); + const deviceId = userDevice.deviceId!; + const device = this.state.mqttClient.acquireDevice(deviceId); + try { + const request = schema.requests.deserializeRequest(requestData.data); + return await device.makeRequest(request); + } finally { + device.release(); } - - async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise { - const userDevice = this.checkDevice(requestData.deviceId); - const deviceId = userDevice.deviceId!; - const device = this.state.mqttClient.acquireDevice(deviceId); - try { - const request = schema.requests.deserializeRequest(requestData.data); - return await device.makeRequest(request); - } finally { - device.release(); - } + } + + private async doHandleSocketMessage(socketData: WebSocket.Data) { + if (typeof socketData !== "string") { + return this.onError( + { type: typeof socketData }, + "received invalid socket data type from client", + ErrorCode.Parse + ); } - - private async doHandleSocketMessage(socketData: WebSocket.Data) { - if (typeof socketData !== "string") { - return this.onError({ type: typeof socketData }, - "received invalid socket data type from client", ErrorCode.Parse); - } - let data: ws.ClientMessage; - try { - data = JSON.parse(socketData); - } catch (err) { - return this.onError({ socketData, err }, "received invalid websocket message from client", - ErrorCode.Parse); - } - switch (data.type) { - case "request": - await this.handleRequest(data); - break; - default: - return this.onError({ data }, "received invalid message type from client", - ErrorCode.BadRequest); - } + let data: ws.ClientMessage; + try { + data = JSON.parse(socketData); + } catch (err) { + return this.onError( + { socketData, err }, + "received invalid websocket message from client", + ErrorCode.Parse + ); } - - private async handleRequest(request: ws.ClientRequest) { - let response: ws.ServerResponseData; - try { - if (!this.requestHandlers[request.method]) { - // noinspection ExceptionCaughtLocallyJS - throw new RpcError("received invalid client request method"); - } - response = await rpc.handleRequest(this.requestHandlers, request, this); - } catch (err) { - if (err instanceof RpcError) { - log.debug({ err }, "rpc error"); - response = { result: "error", error: err.toJSON() }; - } else { - log.error({ method: request.method, err }, "unhandled error during processing of client request"); - response = { - result: "error", error: { - code: ErrorCode.Internal, message: "unhandled error during processing of client request", - data: err.toString(), - }, - }; - } - } - this.sendResponse(request.method, request.id, response); + switch (data.type) { + case "request": + await this.handleRequest(data); + break; + default: + return this.onError( + { data }, + "received invalid message type from client", + ErrorCode.BadRequest + ); } - - private onError(data: any, message: string, code: number = ErrorCode.Internal) { - log.error(data, message); - const errorData: ws.IError = { code, message, data }; - this.sendNotification("error", errorData); + } + + private async handleRequest(request: ws.ClientRequest) { + let response: ws.ServerResponseData; + try { + if (!this.requestHandlers[request.method]) { + // noinspection ExceptionCaughtLocallyJS + throw new RpcError("received invalid client request method"); + } + response = await rpc.handleRequest(this.requestHandlers, request, this); + } catch (err) { + if (err instanceof RpcError) { + log.debug({ err }, "rpc error"); + response = { result: "error", error: err.toJSON() }; + } else { + log.error( + { method: request.method, err }, + "unhandled error during processing of client request" + ); + response = { + result: "error", + error: { + code: ErrorCode.Internal, + message: "unhandled error during processing of client request", + data: err.toString() + } + }; + } } + this.sendResponse(request.method, request.id, response); + } + + private onError( + data: any, + message: string, + code: number = ErrorCode.Internal + ) { + log.error(data, message); + const errorData: ws.IError = { code, message, data }; + this.sendNotification("error", errorData); + } } class WebSocketRequestHandlers implements ws.ClientRequestHandlers { - async authenticate(this: WebSocketConnection, data: ws.IAuthenticateRequest): - Promise> { - if (!data.accessToken) { - throw new RpcError("no token specified", ErrorCode.BadRequest); - } - let claims: AccessToken; - try { - claims = await verifyToken(data.accessToken, "access"); - } catch (e) { - throw new RpcError("invalid token", ErrorCode.BadToken, e); - } - this.userId = claims.aud; - this.user = await this.state.database.users. - findById(this.userId, { devices: true }) || null; - if (!this.user) { - throw new RpcError("user no longer exists", ErrorCode.BadToken); - } - log.debug({ userId: claims.aud, name: claims.name }, "authenticated websocket client"); - this.subscribeBrokerConnection(); - return { - result: "success", - data: { authenticated: true, message: "authenticated", user: this.user.toJSON() }, - }; + async authenticate( + this: WebSocketConnection, + data: ws.IAuthenticateRequest + ): Promise> { + if (!data.accessToken) { + throw new RpcError("no token specified", ErrorCode.BadRequest); } - - async deviceSubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): - Promise> { - this.checkAuthorization(); - const userDevice = this.checkDevice(data.deviceId); - const deviceId = userDevice.deviceId!; - if (!this.deviceSubscriptions.has(deviceId)) { - const device = this.state.mqttClient.acquireDevice(deviceId); - log.debug({ deviceId, userId: this.userId }, "websocket client subscribed to device"); - - const autorunDisposer = autorun(() => { - const json = serialize(schema.sprinklersDevice, device); - log.trace({ device: json }); - const updateData: ws.IDeviceUpdate = { deviceId, data: json }; - this.sendNotification("deviceUpdate", updateData); - }, { delay: 100 }); - - this.deviceSubscriptions.set(deviceId, () => { - autorunDisposer(); - device.release(); - this.deviceSubscriptions.delete(deviceId); - }); - } - - const response: ws.IDeviceSubscribeResponse = { - deviceId, - }; - return { result: "success", data: response }; + let claims: AccessToken; + try { + claims = await verifyToken(data.accessToken, "access"); + } catch (e) { + throw new RpcError("invalid token", ErrorCode.BadToken, e); + } + this.userId = claims.aud; + this.user = + (await this.state.database.users.findById(this.userId, { + devices: true + })) || null; + if (!this.user) { + throw new RpcError("user no longer exists", ErrorCode.BadToken); + } + log.debug( + { userId: claims.aud, name: claims.name }, + "authenticated websocket client" + ); + this.subscribeBrokerConnection(); + return { + result: "success", + data: { + authenticated: true, + message: "authenticated", + user: this.user.toJSON() + } + }; + } + + async deviceSubscribe( + this: WebSocketConnection, + data: ws.IDeviceSubscribeRequest + ): Promise> { + this.checkAuthorization(); + const userDevice = this.checkDevice(data.deviceId); + const deviceId = userDevice.deviceId!; + if (!this.deviceSubscriptions.has(deviceId)) { + const device = this.state.mqttClient.acquireDevice(deviceId); + log.debug( + { deviceId, userId: this.userId }, + "websocket client subscribed to device" + ); + + const autorunDisposer = autorun( + () => { + const json = serialize(schema.sprinklersDevice, device); + log.trace({ device: json }); + const updateData: ws.IDeviceUpdate = { deviceId, data: json }; + this.sendNotification("deviceUpdate", updateData); + }, + { delay: 100 } + ); + + this.deviceSubscriptions.set(deviceId, () => { + autorunDisposer(); + device.release(); + this.deviceSubscriptions.delete(deviceId); + }); } - async deviceUnsubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): - Promise> { - this.checkAuthorization(); - const userDevice = this.checkDevice(data.deviceId); - const deviceId = userDevice.deviceId!; - const disposer = this.deviceSubscriptions.get(deviceId); - - if (disposer) { - disposer(); - } - - const response: ws.IDeviceSubscribeResponse = { - deviceId, - }; - return { result: "success", data: response }; + const response: ws.IDeviceSubscribeResponse = { + deviceId + }; + return { result: "success", data: response }; + } + + async deviceUnsubscribe( + this: WebSocketConnection, + data: ws.IDeviceSubscribeRequest + ): Promise> { + this.checkAuthorization(); + const userDevice = this.checkDevice(data.deviceId); + const deviceId = userDevice.deviceId!; + const disposer = this.deviceSubscriptions.get(deviceId); + + if (disposer) { + disposer(); } - async deviceCall(this: WebSocketConnection, data: ws.IDeviceCallRequest): - Promise> { - this.checkAuthorization(); - try { - const response = await this.doDeviceCallRequest(data); - const resData: ws.IDeviceCallResponse = { - data: response, - }; - return { result: "success", data: resData }; - } catch (err) { - const e: deviceRequests.ErrorResponseData = err; - throw new RpcError(e.message, e.code, e); - } + const response: ws.IDeviceSubscribeResponse = { + deviceId + }; + return { result: "success", data: response }; + } + + async deviceCall( + this: WebSocketConnection, + data: ws.IDeviceCallRequest + ): Promise> { + this.checkAuthorization(); + try { + const response = await this.doDeviceCallRequest(data); + const resData: ws.IDeviceCallResponse = { + data: response + }; + return { result: "success", data: resData }; + } catch (err) { + const e: deviceRequests.ErrorResponseData = err; + throw new RpcError(e.message, e.code, e); } + } } diff --git a/server/state.ts b/server/state.ts index 8a2b6ee..71d17a6 100644 --- a/server/state.ts +++ b/server/state.ts @@ -5,29 +5,29 @@ import { SUPERUSER } from "@server/express/api/mosquitto"; import { Database } from "./Database"; export class ServerState { - mqttUrl: string; - mqttClient: mqtt.MqttRpcClient; - database: Database; + mqttUrl: string; + mqttClient: mqtt.MqttRpcClient; + database: Database; - constructor() { - const mqttUrl = process.env.MQTT_URL; - if (!mqttUrl) { - throw new Error("Must specify a MQTT_URL to connect to"); - } - this.mqttUrl = mqttUrl; - this.mqttClient = new mqtt.MqttRpcClient({ - mqttUri: mqttUrl, - }); - this.database = new Database(); + constructor() { + const mqttUrl = process.env.MQTT_URL; + if (!mqttUrl) { + throw new Error("Must specify a MQTT_URL to connect to"); } + this.mqttUrl = mqttUrl; + this.mqttClient = new mqtt.MqttRpcClient({ + mqttUri: mqttUrl + }); + this.database = new Database(); + } - async start() { - await this.database.connect(); - await this.database.createAll(); - logger.info("created database and tables"); + async start() { + await this.database.connect(); + await this.database.createAll(); + logger.info("created database and tables"); - this.mqttClient.username = SUPERUSER; - this.mqttClient.password = await generateSuperuserToken(); - this.mqttClient.start(); - } + this.mqttClient.username = SUPERUSER; + this.mqttClient.password = await generateSuperuserToken(); + this.mqttClient.start(); + } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 8442075..bdaa6e8 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -26,11 +26,9 @@ ] } }, - "references": [ - { - "path": "../common" - } - ], + "references": [{ + "path": "../common" + }], "include": [ "./**/*.ts" ] diff --git a/server/types/express-pino-logger.d.ts b/server/types/express-pino-logger.d.ts index 7cc0574..af70964 100644 --- a/server/types/express-pino-logger.d.ts +++ b/server/types/express-pino-logger.d.ts @@ -1,7 +1,7 @@ declare module "express-pino-logger" { - import { Logger } from "pino"; - import { ErrorRequestHandler } from "express"; + import { Logger } from "pino"; + import { ErrorRequestHandler } from "express"; - function makeLogger(logger: Logger): ErrorRequestHandler; - export = makeLogger; -} \ No newline at end of file + function makeLogger(logger: Logger): ErrorRequestHandler; + export = makeLogger; +} diff --git a/start-tmux.sh b/start-tmux.sh index 34b487b..ae0aeab 100755 --- a/start-tmux.sh +++ b/start-tmux.sh @@ -6,12 +6,11 @@ SESSION_NAME=sprinklers3 tmux has-session -t ${SESSION_NAME} if [ $? != 0 ]; then - tmux new-session -s ${SESSION_NAME} -n server -d - tmux send-keys -t ${SESSION_NAME} "cd ${DIR}" C-m - tmux send-keys -t ${SESSION_NAME} "yarn start:watch" C-m - tmux new-window -t ${SESSION_NAME} -n client - tmux send-keys -t "${SESSION_NAME}:client" "yarn start:dev-server" C-m + tmux new-session -s ${SESSION_NAME} -n server -d + tmux send-keys -t ${SESSION_NAME} "cd ${DIR}" C-m + tmux send-keys -t ${SESSION_NAME} "yarn start:watch" C-m + tmux new-window -t ${SESSION_NAME} -n client + tmux send-keys -t "${SESSION_NAME}:client" "yarn start:dev-server" C-m fi tmux attach -t ${SESSION_NAME} - diff --git a/tslint.json b/tslint.json index ef71cfa..998f486 100644 --- a/tslint.json +++ b/tslint.json @@ -1,39 +1,20 @@ { "defaultSeverity": "warning", - "extends": [ - "tslint:latest", "tslint-react" - ], + "extends": ["tslint:latest", "tslint-consistent-codestyle", "tslint-react", "tslint-config-prettier"], "jsRules": {}, "rules": { - "no-console": [ - true - ], - "max-classes-per-file": [ - true, 3 - ], + "no-console": [true], + "max-classes-per-file": [true, 3], "ordered-imports": true, - "variable-name": [ - "allow-leading-underscore" - ], - "no-namespace": false, + "variable-name": [true, "check-format", "allow-pascal-case", "allow-leading-underscore", "ban-keywords"], "interface-name": false, - "member-access": [ - true, - "no-public" - ], - "member-ordering": [ - true, - { - "order": "statics-first" - } - ], - "object-literal-sort-keys": [ - false - ], - "no-submodule-imports": false, - "jsx-boolean-value": [ true, "never" ], + "member-access": [true, "no-public"], + "object-literal-sort-keys": [false], + "no-submodule-imports": [false, ["@common", "@server", "@client"]], + "jsx-boolean-value": [true, "never"], "no-implicit-dependencies": false, - "curly": [ true, "ignore-same-line" ] + "curly": [true, "ignore-same-line"], + "no-unnecessary-qualifier": true }, "rulesDirectory": [] } diff --git a/yarn.lock b/yarn.lock index 1857b72..79a75df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,23 @@ version "1.4.0" resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" +"@fimbul/bifrost@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@fimbul/bifrost/-/bifrost-0.11.0.tgz#83cacc21464198b12e3cc1c2204ae6c6d7afd158" + dependencies: + "@fimbul/ymir" "^0.11.0" + get-caller-file "^1.0.2" + tslib "^1.8.1" + tsutils "^2.24.0" + +"@fimbul/ymir@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@fimbul/ymir/-/ymir-0.11.0.tgz#892a01997f1f80c7e4e437cf5ca51c95994c136f" + dependencies: + inversify "^4.10.0" + reflect-metadata "^0.1.12" + tslib "^1.8.1" + "@most/multicast@^1.2.5": version "1.3.0" resolved "https://registry.yarnpkg.com/@most/multicast/-/multicast-1.3.0.tgz#e01574840df634478ac3fabd164c6e830fb3b966" @@ -2581,7 +2598,7 @@ gaze@^1.0.0: dependencies: globule "^1.0.0" -get-caller-file@^1.0.1: +get-caller-file@^1.0.1, get-caller-file@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" @@ -3199,6 +3216,10 @@ invariant@^2.2.1, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +inversify@^4.10.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/inversify/-/inversify-4.13.0.tgz#0ab40570bfa4474b04d5b919bbab3a4f682a72f5" + invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" @@ -6884,10 +6905,22 @@ ts-loader@^4.5.0: micromatch "^3.1.4" semver "^5.0.1" -tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" +tslint-config-prettier@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.15.0.tgz#76b9714399004ab6831fdcf76d89b73691c812cf" + +tslint-consistent-codestyle@^1.13.3: + version "1.13.3" + resolved "https://registry.yarnpkg.com/tslint-consistent-codestyle/-/tslint-consistent-codestyle-1.13.3.tgz#763e8575accc19f17b7d0369ead382bdbf78fd5b" + dependencies: + "@fimbul/bifrost" "^0.11.0" + tslib "^1.7.1" + tsutils "^2.27.0" + tslint-react@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/tslint-react/-/tslint-react-3.6.0.tgz#7f462c95c4a0afaae82507f06517ff02942196a1" @@ -6911,7 +6944,7 @@ tslint@^5.11.0: tslib "^1.8.0" tsutils "^2.27.2" -tsutils@^2.13.1, tsutils@^2.27.2: +tsutils@^2.13.1, tsutils@^2.24.0, tsutils@^2.27.0, tsutils@^2.27.2: version "2.29.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" dependencies: