Browse Source

Use prettier on everything

update-deps
Alex Mikhalev 6 years ago
parent
commit
e6c3904701
  1. 6
      README.md
  2. 18
      client/App.tsx
  3. 4
      client/components/DeviceImage.tsx
  4. 68
      client/components/DeviceView.tsx
  5. 19
      client/components/DurationView.tsx
  6. 18
      client/components/MessagesView.tsx
  7. 19
      client/components/NavBar.tsx
  8. 98
      client/components/ProgramSequenceView.tsx
  9. 66
      client/components/ProgramTable.tsx
  10. 51
      client/components/RunSectionForm.tsx
  11. 48
      client/components/ScheduleView/ScheduleDate.tsx
  12. 16
      client/components/ScheduleView/ScheduleTimes.tsx
  13. 37
      client/components/ScheduleView/TimeInput.tsx
  14. 19
      client/components/ScheduleView/WeekdaysView.tsx
  15. 33
      client/components/ScheduleView/index.tsx
  16. 30
      client/components/SectionChooser.tsx
  17. 77
      client/components/SectionRunnerView.tsx
  18. 24
      client/components/SectionTable.tsx
  19. 8
      client/env.js
  20. 3
      client/index.html
  21. 14
      client/index.tsx
  22. 10
      client/pages/DevicePage.tsx
  23. 6
      client/pages/DevicesPage.tsx
  24. 59
      client/pages/LoginPage.tsx
  25. 8
      client/pages/LogoutPage.tsx
  26. 22
      client/pages/MessageTest.tsx
  27. 51
      client/pages/ProgramPage.tsx
  28. 7
      client/routePaths.ts
  29. 17
      client/sprinklersRpc/WSSprinklersDevice.ts
  30. 82
      client/sprinklersRpc/WebSocketRpcClient.ts
  31. 17
      client/state/AppState.ts
  32. 69
      client/state/HttpApi.ts
  33. 27
      client/state/Token.ts
  34. 16
      client/state/TokenStore.ts
  35. 2
      client/state/UiStore.ts
  36. 10
      client/state/UserStore.ts
  37. 27
      client/state/reactContext.tsx
  38. 13
      client/styles/DeviceView.scss
  39. 8
      client/styles/DurationView.scss
  40. 2
      client/styles/ProgramSequenceView.scss
  41. 7
      client/styles/ScheduleView.scss
  42. 6
      client/styles/SectionRunnerView.scss
  43. 6
      client/tsconfig.json
  44. 24
      client/webpack.config.js
  45. 10
      common/ApiError.ts
  46. 2
      common/ErrorCode.ts
  47. 7
      common/TokenClaims.ts
  48. 54
      common/TypedEventEmitter.ts
  49. 4
      common/httpApi/index.ts
  50. 139
      common/jsonRpc/index.ts
  51. 48
      common/logger.ts
  52. 24
      common/sprinklersRpc/ConnectionState.ts
  53. 28
      common/sprinklersRpc/Program.ts
  54. 6
      common/sprinklersRpc/RpcError.ts
  55. 6
      common/sprinklersRpc/Section.ts
  56. 26
      common/sprinklersRpc/SectionRunner.ts
  57. 29
      common/sprinklersRpc/SprinklersDevice.ts
  58. 45
      common/sprinklersRpc/deviceRequests.ts
  59. 2
      common/sprinklersRpc/mqtt/MqttProgram.ts
  60. 2
      common/sprinklersRpc/mqtt/MqttSection.ts
  61. 99
      common/sprinklersRpc/mqtt/index.ts
  62. 75
      common/sprinklersRpc/schedule.ts
  63. 18
      common/sprinklersRpc/schema/common.ts
  64. 58
      common/sprinklersRpc/schema/index.ts
  65. 24
      common/sprinklersRpc/schema/list.ts
  66. 52
      common/sprinklersRpc/schema/requests.ts
  67. 54
      common/sprinklersRpc/websocketData.ts
  68. 10
      common/utils.ts
  69. 2
      package.json
  70. 28
      server/Database.ts
  71. 83
      server/authentication.ts
  72. 2
      server/configureLogger.ts
  73. 2
      server/entities/SprinklersDevice.ts
  74. 12
      server/entities/User.ts
  75. 6
      server/env.ts
  76. 51
      server/express/api/devices.ts
  77. 13
      server/express/api/mosquitto.ts
  78. 18
      server/express/api/token.ts
  79. 14
      server/express/api/users.ts
  80. 10
      server/express/errorHandler.ts
  81. 20
      server/express/verifyAuthorization.ts
  82. 9
      server/index.ts
  83. 24
      server/logging/prettyPrint.ts
  84. 19
      server/repositories/SprinklersDeviceRepository.ts
  85. 7
      server/repositories/UserRepository.ts
  86. 2
      server/sprinklersRpc/WebSocketApi.ts
  87. 152
      server/sprinklersRpc/WebSocketConnection.ts
  88. 2
      server/state.ts
  89. 6
      server/tsconfig.json
  90. 1
      start-tmux.sh
  91. 39
      tslint.json
  92. 39
      yarn.lock

6
README.md

@ -5,18 +5,16 @@ @@ -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 @@ -25,6 +23,4 @@ yarn start:pretty
# for development build (http://localhost:8081)
yarn start:dev
```

18
client/App.tsx

@ -15,17 +15,17 @@ import "@client/styles/app"; @@ -15,17 +15,17 @@ import "@client/styles/app";
function NavContainer() {
return (
<Container className="app">
<NavBar/>
<NavBar />
<Switch>
<Route path={route.device(":deviceId")} component={p.DevicePage}/>
<Route path={route.device()} component={p.DevicesPage}/>
<Route path={route.messagesTest} component={p.MessageTest}/>
<Route path={route.device(":deviceId")} component={p.DevicePage} />
<Route path={route.device()} component={p.DevicesPage} />
<Route path={route.messagesTest} component={p.MessageTest} />
<Redirect from="/" to={route.device()} />
<Redirect to="/"/>
<Redirect to="/" />
</Switch>
<MessagesView/>
<MessagesView />
</Container>
);
}
@ -33,9 +33,9 @@ function NavContainer() { @@ -33,9 +33,9 @@ function NavContainer() {
export default function App() {
return (
<Switch>
<Route path={route.login} component={p.LoginPage}/>
<Route path={route.logout} component={p.LogoutPage}/>
<NavContainer/>
<Route path={route.login} component={p.LoginPage} />
<Route path={route.logout} component={p.LogoutPage} />
<NavContainer />
</Switch>
);
}

4
client/components/DeviceImage.tsx

@ -2,5 +2,7 @@ import * as React from "react"; @@ -2,5 +2,7 @@ import * as React from "react";
import { Item, ItemImageProps } from "semantic-ui-react";
export default function DeviceImage(props: ItemImageProps) {
return <Item.Image {...props} src={require("@client/images/raspberry_pi.png")} />;
return (
<Item.Image {...props} src={require("@client/images/raspberry_pi.png")} />
);
}

68
client/components/DeviceView.tsx

@ -9,14 +9,28 @@ import * as p from "@client/pages"; @@ -9,14 +9,28 @@ 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";
@ -40,11 +54,13 @@ const ConnectionState = observer(({ connectionState, className }: @@ -40,11 +54,13 @@ const ConnectionState = observer(({ connectionState, className }:
const classes = classNames("connectionState", clazzName, className);
return (
<div className={classes}>
<Icon name={iconName} />&nbsp;
<Icon name={iconName} />
&nbsp;
{connectionText}
</div>
);
});
}
);
interface DeviceViewProps {
deviceId: number;
@ -63,7 +79,10 @@ class DeviceView extends React.Component<DeviceViewProps> { @@ -63,7 +79,10 @@ class DeviceView extends React.Component<DeviceViewProps> {
}
renderBody() {
const { inList, appState: { uiStore, routerStore } } = this.props;
const {
inList,
appState: { uiStore, routerStore }
} = this.props;
if (!this.deviceInfo || !this.device) {
return null;
}
@ -75,7 +94,10 @@ class DeviceView extends React.Component<DeviceViewProps> { @@ -75,7 +94,10 @@ class DeviceView extends React.Component<DeviceViewProps> {
<React.Fragment>
<Grid>
<Grid.Column mobile="16" tablet="16" computer="16" largeScreen="6">
<SectionRunnerView sectionRunner={sectionRunner} sections={sections} />
<SectionRunnerView
sectionRunner={sectionRunner}
sections={sections}
/>
</Grid.Column>
<Grid.Column mobile="16" tablet="9" computer="9" largeScreen="6">
<SectionTable sections={sections} />
@ -84,8 +106,15 @@ class DeviceView extends React.Component<DeviceViewProps> { @@ -84,8 +106,15 @@ class DeviceView extends React.Component<DeviceViewProps> {
<RunSectionForm device={this.device} uiStore={uiStore} />
</Grid.Column>
</Grid>
<ProgramTable iDevice={this.deviceInfo} device={this.device} routerStore={routerStore} />
<Route path={route.program(":deviceId", ":programId")} component={p.ProgramPage} />
<ProgramTable
iDevice={this.deviceInfo}
device={this.device}
routerStore={routerStore}
/>
<Route
path={route.program(":deviceId", ":programId")}
component={p.ProgramPage}
/>
</React.Fragment>
);
}
@ -123,12 +152,21 @@ class DeviceView extends React.Component<DeviceViewProps> { @@ -123,12 +152,21 @@ class DeviceView extends React.Component<DeviceViewProps> {
const { connectionState } = this.device;
let header: React.ReactNode;
let image: React.ReactNode;
if (inList) { // tslint:disable-line:prefer-conditional-expression
if (inList) {
// tslint:disable-line:prefer-conditional-expression
const devicePath = route.device(this.deviceInfo.id);
header = <Link to={devicePath}>Device <kbd>{this.deviceInfo.name}</kbd></Link>;
header = (
<Link to={devicePath}>
Device <kbd>{this.deviceInfo.name}</kbd>
</Link>
);
image = <DeviceImage size="tiny" as={Link} to={devicePath} />;
} else {
header = <span>Device <kbd>{this.deviceInfo.name}</kbd></span>;
header = (
<span>
Device <kbd>{this.deviceInfo.name}</kbd>
</span>
);
image = <DeviceImage />;
}
itemContent = (
@ -139,9 +177,7 @@ class DeviceView extends React.Component<DeviceViewProps> { @@ -139,9 +177,7 @@ class DeviceView extends React.Component<DeviceViewProps> {
{header}
<ConnectionState connectionState={connectionState} />
</Header>
<Item.Meta>
Raspberry Pi Grinklers Device
</Item.Meta>
<Item.Meta>Raspberry Pi Grinklers Device</Item.Meta>
{this.renderBody()}
</Item.Content>
</React.Fragment>

19
client/components/DurationView.tsx

@ -7,11 +7,11 @@ import { Duration } from "@common/Duration"; @@ -7,11 +7,11 @@ 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;
@ -48,7 +48,8 @@ export default class DurationView extends React.Component<{ @@ -48,7 +48,8 @@ export default class DurationView extends React.Component<{
} else {
return (
<span className={className}>
{label && <label>{label}</label>} {duration.minutes}M {duration.seconds}S
{label && <label>{label}</label>} {duration.minutes}M{" "}
{duration.seconds}S
</span>
);
}
@ -60,7 +61,7 @@ export default class DurationView extends React.Component<{ @@ -60,7 +61,7 @@ export default class DurationView extends React.Component<{
}
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))) {
@ -68,9 +69,9 @@ export default class DurationView extends React.Component<{ @@ -68,9 +69,9 @@ export default class DurationView extends React.Component<{
}
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}
};
private onWheel = () => {
// do nothing
}
};
}

18
client/components/MessagesView.tsx

@ -9,11 +9,10 @@ import "@client/styles/MessagesView"; @@ -9,11 +9,10 @@ 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);
@ -32,18 +31,23 @@ class MessageView extends React.Component<{ @@ -32,18 +31,23 @@ class MessageView extends React.Component<{
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) => (
const messages = uiStore.messages.map(message => (
<MessageView key={message.id} uiStore={uiStore} message={message} />
));
messages.reverse();
return (
<TransitionGroup as={Message.List} className="messages" animation="scale" duration={200}>
<TransitionGroup
as={Message.List}
className="messages"
animation="scale"
duration={200}
>
{messages}
</TransitionGroup>
);

19
client/components/NavBar.tsx

@ -15,31 +15,28 @@ const NavItem = observer(({ to, children }: NavItemProps) => { @@ -15,31 +15,28 @@ const NavItem = observer(({ to, children }: NavItemProps) => {
function consumeState(appState: AppState) {
const { location } = appState.routerStore;
return (
<Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>{children}</Menu.Item>
<Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>
{children}
</Menu.Item>
);
}
return (<ConsumeState>{consumeState}</ConsumeState>);
return <ConsumeState>{consumeState}</ConsumeState>;
});
function NavBar({ appState }: { appState: AppState }) {
let loginMenu;
// tslint:disable-next-line:prefer-conditional-expression
if (appState.isLoggedIn) {
loginMenu = (
<NavItem to={route.logout}>Logout</NavItem>
);
loginMenu = <NavItem to={route.logout}>Logout</NavItem>;
} else {
loginMenu = (
<NavItem to={route.login}>Login</NavItem>
);
loginMenu = <NavItem to={route.login}>Login</NavItem>;
}
return (
<Menu>
<NavItem to={route.device()}>Devices</NavItem>
<NavItem to={route.messagesTest}>Messages test</NavItem>
<Menu.Menu position="right">
{loginMenu}
</Menu.Menu>
<Menu.Menu position="right">{loginMenu}</Menu.Menu>
</Menu>
);
}

98
client/components/ProgramSequenceView.tsx

@ -1,7 +1,12 @@ @@ -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,16 +19,20 @@ import { action } from "mobx"; @@ -14,16 +19,20 @@ import { action } from "mobx";
type ItemChangeHandler = (index: number, newItem: ProgramItem) => void;
type ItemRemoveHandler = (index: number) => void;
const Handle = SortableHandle(() => <Button basic icon><Icon name="bars"/></Button>);
const Handle = SortableHandle(() => (
<Button basic icon>
<Icon name="bars" />
</Button>
));
@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;
@ -63,39 +72,49 @@ class ProgramSequenceItem extends React.Component<{ @@ -63,39 +72,49 @@ class ProgramSequenceItem extends React.Component<{
const { editing } = this.props;
return (
<li className="programSequence-item ui form">
{editing ? <Handle /> : <List.Icon name="caret right"/>}
{editing ? <Handle /> : <List.Icon name="caret right" />}
<List.Content>{this.renderContent()}</List.Content>
</li>
);
}
private onSectionChange = (newSectionId: number) => {
this.props.onChange(this.props.idx, new ProgramItem({
...this.props.sequenceItem, section: newSectionId,
}));
}
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(),
}));
}
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 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}`;
@ -111,11 +130,16 @@ const ProgramSequenceList = SortableContainer(observer((props: { @@ -111,11 +130,16 @@ const ProgramSequenceList = SortableContainer(observer((props: {
);
});
return <ul className={className}>{listItems}</ul>;
}), { withRef: true });
}
),
{ 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;
@ -125,7 +149,7 @@ class ProgramSequenceView extends React.Component<{ @@ -125,7 +149,7 @@ class ProgramSequenceView extends React.Component<{
if (editing) {
addButton = (
<Button onClick={this.addItem}>
<Icon name="add"/>
<Icon name="add" />
Add item
</Button>
);
@ -151,20 +175,20 @@ class ProgramSequenceView extends React.Component<{ @@ -151,20 +175,20 @@ class ProgramSequenceView extends React.Component<{
@action.bound
private changeItem: ItemChangeHandler = (index, newItem) => {
this.props.sequence[index] = newItem;
}
};
@action.bound
private removeItem: ItemRemoveHandler = (index) => {
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);
const sectionNotIncluded = this.props.sequence.every(
sequenceItem => sequenceItem.section !== section.id
);
if (sectionNotIncluded) {
sectionId = section.id;
break;
@ -172,13 +196,13 @@ class ProgramSequenceView extends React.Component<{ @@ -172,13 +196,13 @@ class ProgramSequenceView extends React.Component<{
}
const item = new ProgramItem({
section: sectionId,
duration: new Duration(5, 0).toSeconds(),
duration: new Duration(5, 0).toSeconds()
});
this.props.sequence.push(item);
}
@action.bound
private onSortEnd({oldIndex, newIndex}: SortEnd) {
private onSortEnd({ oldIndex, newIndex }: SortEnd) {
const { sequence: array } = this.props;
if (newIndex >= array.length) {
return;

66
client/components/ProgramTable.tsx

@ -11,11 +11,12 @@ import { Program, SprinklersDevice } from "@common/sprinklersRpc"; @@ -11,11 +11,12 @@ 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;
@ -27,7 +28,12 @@ class ProgramRows extends React.Component<{ @@ -27,7 +28,12 @@ class ProgramRows extends React.Component<{
const detailUrl = route.program(iDevice.id, program.id);
const stopStartButton = (
<Button onClick={this.cancelOrRun} {...buttonStyle} positive={!running} negative={running}>
<Button
onClick={this.cancelOrRun}
{...buttonStyle}
positive={!running}
negative={running}
>
<Icon name={running ? "stop" : "play"} />
{running ? "Stop" : "Run"}
</Button>
@ -37,7 +43,9 @@ class ProgramRows extends React.Component<{ @@ -37,7 +43,9 @@ class ProgramRows extends React.Component<{
<Table.Row>
<Table.Cell className="program--number">{"" + program.id}</Table.Cell>
<Table.Cell className="program--name">{name}</Table.Cell>
<Table.Cell className="program--enabled">{enabled ? "Enabled" : "Not enabled"}</Table.Cell>
<Table.Cell className="program--enabled">
{enabled ? "Enabled" : "Not enabled"}
</Table.Cell>
<Table.Cell className="program--running">
<span>{running ? "Running" : "Not running"}</span>
</Table.Cell>
@ -58,7 +66,8 @@ class ProgramRows extends React.Component<{ @@ -58,7 +66,8 @@ class ProgramRows extends React.Component<{
<Table.Row>
<Table.Cell className="program--sequence" colSpan="5">
<Form>
<h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={sections} />
<h4>Sequence: </h4>{" "}
<ProgramSequenceView sequence={sequence} sections={sections} />
<ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} />
</Form>
</Table.Cell>
@ -75,21 +84,26 @@ class ProgramRows extends React.Component<{ @@ -75,21 +84,26 @@ class ProgramRows extends React.Component<{
private cancelOrRun = () => {
const { program } = this.props;
program.running ? program.cancel() : program.run();
}
};
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[],
}> {
export default class ProgramTable extends React.Component<
{
iDevice: ISprinklersDevice;
device: SprinklersDevice;
routerStore: RouterStore;
},
{
expandedPrograms: ProgramId[];
}
> {
constructor(p: any) {
super(p);
this.state = { expandedPrograms: [] };
@ -108,14 +122,18 @@ export default class ProgramTable extends React.Component<{ @@ -108,14 +122,18 @@ export default class ProgramTable extends React.Component<{
<Table.Row>
<Table.HeaderCell className="program--number">#</Table.HeaderCell>
<Table.HeaderCell className="program--name">Name</Table.HeaderCell>
<Table.HeaderCell className="program--enabled">Enabled?</Table.HeaderCell>
<Table.HeaderCell className="program--running">Running?</Table.HeaderCell>
<Table.HeaderCell className="program--actions">Actions</Table.HeaderCell>
<Table.HeaderCell className="program--enabled">
Enabled?
</Table.HeaderCell>
<Table.HeaderCell className="program--running">
Running?
</Table.HeaderCell>
<Table.HeaderCell className="program--actions">
Actions
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{programRows}
</Table.Body>
<Table.Body>{programRows}</Table.Body>
</Table>
);
}
@ -136,7 +154,7 @@ export default class ProgramTable extends React.Component<{ @@ -136,7 +154,7 @@ export default class ProgramTable extends React.Component<{
key={i}
/>
);
}
};
private toggleExpanded = (program: Program) => {
const { expandedPrograms } = this.state;
@ -147,7 +165,7 @@ export default class ProgramTable extends React.Component<{ @@ -147,7 +165,7 @@ export default class ProgramTable extends React.Component<{
expandedPrograms.push(program.id);
}
this.setState({
expandedPrograms,
expandedPrograms
});
}
};
}

51
client/components/RunSectionForm.tsx

@ -10,18 +10,21 @@ import { Section, SprinklersDevice } from "@common/sprinklersRpc"; @@ -10,18 +10,21 @@ 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,
}> {
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,
sectionId: undefined
};
}
@ -42,12 +45,8 @@ export default class RunSectionForm extends React.Component<{ @@ -42,12 +45,8 @@ export default class RunSectionForm extends React.Component<{
duration={duration}
onDurationChange={this.onDurationChange}
/>
<Form.Button
primary
onClick={this.run}
disabled={!this.isValid}
>
<Icon name="play"/>
<Form.Button primary onClick={this.run} disabled={!this.isValid}>
<Icon name="play" />
Run
</Form.Button>
</Form>
@ -57,11 +56,11 @@ export default class RunSectionForm extends React.Component<{ @@ -57,11 +56,11 @@ export default class RunSectionForm extends React.Component<{
private onSectionChange = (newSectionId: number) => {
this.setState({ sectionId: newSectionId });
}
};
private onDurationChange = (newDuration: Duration) => {
this.setState({ duration: newDuration });
}
};
private run = (e: React.SyntheticEvent<HTMLElement>) => {
e.preventDefault();
@ -70,26 +69,30 @@ export default class RunSectionForm extends React.Component<{ @@ -70,26 +69,30 @@ export default class RunSectionForm extends React.Component<{
return;
}
const section = this.props.device.sections[sectionId];
section.run(duration.toSeconds())
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,
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,
error: true,
header: "Error running section",
content: err.message
});
}
};
private get isValid(): boolean {
return this.state.sectionId != null && this.state.duration.toSeconds() > 0;

48
client/components/ScheduleView/ScheduleDate.tsx

@ -18,12 +18,22 @@ interface ScheduleDateState { @@ -18,12 +18,22 @@ interface ScheduleDateState {
lastDate: DateOfYear | null | undefined;
}
export default class ScheduleDate extends React.Component<ScheduleDateProps, ScheduleDateState> {
static getDerivedStateFromProps(props: ScheduleDateProps, state: ScheduleDateState): Partial<ScheduleDateState> {
export default class ScheduleDate extends React.Component<
ScheduleDateProps,
ScheduleDateState
> {
static getDerivedStateFromProps(
props: ScheduleDateProps,
state: ScheduleDateState
): Partial<ScheduleDateState> {
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);
const rawValue =
props.date == null
? ""
: moment(props.date)
.year(thisYear)
.format(HTML_DATE_INPUT_FORMAT);
return { lastDate: props.date, rawValue };
}
return {};
@ -38,17 +48,25 @@ export default class ScheduleDate extends React.Component<ScheduleDateProps, Sch @@ -38,17 +48,25 @@ export default class ScheduleDate extends React.Component<ScheduleDateProps, Sch
const { date, label, editing } = this.props;
let dayNode: React.ReactNode;
if (editing) { // tslint:disable-line:prefer-conditional-expression
if (editing) {
// tslint:disable-line:prefer-conditional-expression
let clearIcon: React.ReactNode | undefined;
if (date) {
clearIcon = <Icon name="ban" link onClick={this.onClear} />;
}
dayNode = <Input type="date" icon={clearIcon} value={this.state.rawValue} onChange={this.onChange} />;
dayNode = (
<Input
type="date"
icon={clearIcon}
value={this.state.rawValue}
onChange={this.onChange}
/>
);
} else {
const m = moment(date || "");
let dayString: string;
if (m.isValid()) {
const format = (m.year() === 0) ? "M/D" : "l";
const format = m.year() === 0 ? "M/D" : "l";
dayString = m.format(format);
} else {
dayString = "N/A";
@ -63,19 +81,27 @@ export default class ScheduleDate extends React.Component<ScheduleDateProps, Sch @@ -63,19 +81,27 @@ export default class ScheduleDate extends React.Component<ScheduleDateProps, Sch
labelNode = label;
}
return <Form.Field inline>{labelNode}{dayNode}</Form.Field>;
return (
<Form.Field inline>
{labelNode}
{dayNode}
</Form.Field>
);
}
private onChange = (e: React.SyntheticEvent<HTMLInputElement>, data: InputOnChangeData) => {
private onChange = (
e: React.SyntheticEvent<HTMLInputElement>,
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);
}
};
}

16
client/components/ScheduleView/ScheduleTimes.tsx

@ -18,13 +18,17 @@ export default class ScheduleTimes extends React.Component<{ @@ -18,13 +18,17 @@ export default class ScheduleTimes extends React.Component<{
const { times, editing } = this.props;
let timesNode: React.ReactNode;
if (editing) {
timesNode = times
.map((time, i) => <TimeInput value={time} key={i} index={i} onChange={this.onTimeChange} />);
timesNode = times.map((time, i) => (
<TimeInput
value={time}
key={i}
index={i}
onChange={this.onTimeChange}
/>
));
} else {
timesNode = (
<span>
{times.map((time) => timeToString(time)).join(", ")}
</span>
<span>{times.map(time => timeToString(time)).join(", ")}</span>
);
}
return (
@ -38,5 +42,5 @@ export default class ScheduleTimes extends React.Component<{ @@ -38,5 +42,5 @@ export default class ScheduleTimes extends React.Component<{
const newTimes = times.slice();
newTimes[index] = newTime;
onChange(newTimes);
}
};
}

37
client/components/ScheduleView/TimeInput.tsx

@ -21,10 +21,19 @@ export interface TimeInputState { @@ -21,10 +21,19 @@ export interface TimeInputState {
lastTime: TimeOfDay | null;
}
export default class TimeInput extends React.Component<TimeInputProps, TimeInputState> {
static getDerivedStateFromProps(props: TimeInputProps, state: TimeInputState): Partial<TimeInputState> {
export default class TimeInput extends React.Component<
TimeInputProps,
TimeInputState
> {
static getDerivedStateFromProps(
props: TimeInputProps,
state: TimeInputState
): Partial<TimeInputState> {
if (!TimeOfDay.equals(props.value, state.lastTime)) {
return { lastTime: props.value, rawValue: timeOfDayToHtmlDateInput(props.value) };
return {
lastTime: props.value,
rawValue: timeOfDayToHtmlDateInput(props.value)
};
}
return {};
}
@ -35,21 +44,31 @@ export default class TimeInput extends React.Component<TimeInputProps, TimeInput @@ -35,21 +44,31 @@ export default class TimeInput extends React.Component<TimeInputProps, TimeInput
}
render() {
return <Input type="time" value={this.state.rawValue} onChange={this.onChange} onBlur={this.onBlur} />;
return (
<Input
type="time"
value={this.state.rawValue}
onChange={this.onChange}
onBlur={this.onBlur}
/>
);
}
private onChange = (e: React.SyntheticEvent<HTMLInputElement>, data: InputOnChangeData) => {
private onChange = (
e: React.SyntheticEvent<HTMLInputElement>,
data: InputOnChangeData
) => {
this.setState({
rawValue: data.value,
rawValue: data.value
});
}
};
private onBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
private onBlur: React.FocusEventHandler<HTMLInputElement> = 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) });
}
}
};
}

19
client/components/ScheduleView/WeekdaysView.tsx

@ -14,8 +14,8 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> { @@ -14,8 +14,8 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> {
const { weekdays, editing } = this.props;
let node: React.ReactNode;
if (editing) {
node = WEEKDAYS.map((weekday) => {
const checked = weekdays.find((wd) => wd === weekday) != null;
node = WEEKDAYS.map(weekday => {
const checked = weekdays.find(wd => wd === weekday) != null;
const name = Weekday[weekday];
return (
<Form.Field
@ -29,7 +29,7 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> { @@ -29,7 +29,7 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> {
);
});
} else {
node = weekdays.map((weekday) => Weekday[weekday]).join(", ");
node = weekdays.map(weekday => Weekday[weekday]).join(", ");
}
return (
<Form.Group inline>
@ -37,18 +37,23 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> { @@ -37,18 +37,23 @@ export default class WeekdaysView extends React.Component<WeekdaysViewProps> {
</Form.Group>
);
}
private toggleWeekday = (event: React.FormEvent<HTMLInputElement>, data: CheckboxProps) => {
private toggleWeekday = (
event: React.FormEvent<HTMLInputElement>,
data: CheckboxProps
) => {
const { weekdays, onChange } = this.props;
if (!onChange) {
return;
}
const weekday: Weekday = Number(event.currentTarget.getAttribute("x-weekday"));
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));
}
onChange(weekdays.filter(wd => wd !== weekday));
}
};
}

33
client/components/ScheduleView/index.tsx

@ -2,7 +2,12 @@ import { observer } from "mobx-react"; @@ -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";
@ -32,10 +37,28 @@ export default class ScheduleView extends React.Component<ScheduleViewProps> { @@ -32,10 +37,28 @@ export default class ScheduleView extends React.Component<ScheduleViewProps> {
return (
<Form.Field className="scheduleView">
{labelNode}
<ScheduleTimes times={schedule.times} editing={editing} onChange={this.updateTimes} />
<WeekdaysView weekdays={schedule.weekdays} editing={editing} onChange={this.updateWeekdays} />
<ScheduleDate label="From" date={schedule.from} editing={editing} onChange={this.updateFromDate} />
<ScheduleDate label="To" date={schedule.to} editing={editing} onChange={this.updateToDate} />
<ScheduleTimes
times={schedule.times}
editing={editing}
onChange={this.updateTimes}
/>
<WeekdaysView
weekdays={schedule.weekdays}
editing={editing}
onChange={this.updateWeekdays}
/>
<ScheduleDate
label="From"
date={schedule.from}
editing={editing}
onChange={this.updateFromDate}
/>
<ScheduleDate
label="To"
date={schedule.to}
editing={editing}
onChange={this.updateToDate}
/>
</Form.Field>
);
}

30
client/components/SectionChooser.tsx

@ -9,19 +9,24 @@ import "@client/styles/SectionChooser"; @@ -9,19 +9,24 @@ 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 <React.Fragment>{label || ""} '{sectionStr}'</React.Fragment>;
const sectionStr =
sectionId != null ? sections[sectionId].toString() : "";
return (
<React.Fragment>
{label || ""} '{sectionStr}'
</React.Fragment>
);
}
const section = (sectionId == null) ? "" : sectionId;
const section = sectionId == null ? "" : sectionId;
return (
<Form.Select
className="sectionChooser"
@ -35,15 +40,18 @@ export default class SectionChooser extends React.Component<{ @@ -35,15 +40,18 @@ export default class SectionChooser extends React.Component<{
);
}
private onSectionChange = (e: React.SyntheticEvent<HTMLElement>, v: DropdownProps) => {
private onSectionChange = (
e: React.SyntheticEvent<HTMLElement>,
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,
value: i
}));
}
}

77
client/components/SectionRunnerView.tsx

@ -18,22 +18,25 @@ function PausedState({ paused, togglePaused }: PausedStateProps) { @@ -18,22 +18,25 @@ function PausedState({ paused, togglePaused }: PausedStateProps) {
const classes = classNames({
"sectionRunner--pausedState": true,
"sectionRunner--pausedState-paused": paused,
"sectionRunner--pausedState-unpaused": !paused,
"sectionRunner--pausedState-unpaused": !paused
});
return (
<Button className={classes} size="medium" onClick={togglePaused}>
<Icon name={paused ? "pause" : "play"}/>
<Icon name={paused ? "pause" : "play"} />
{paused ? "Paused" : "Processing"}
</Button>
);
}
class SectionRunView extends React.Component<{
class SectionRunView extends React.Component<
{
run: SectionRun;
sections: Section[];
}, {
},
{
now: number;
}> {
}
> {
animationFrameHandle: number | null = null;
startTime: number;
@ -61,7 +64,7 @@ class SectionRunView extends React.Component<{ @@ -61,7 +64,7 @@ class SectionRunView extends React.Component<{
cancelAnimationFrame(this.animationFrameHandle);
this.animationFrameHandle = null;
}
}
};
requestAnimationFrame = () => {
const startTime = this.props.run.startTime;
@ -72,15 +75,15 @@ class SectionRunView extends React.Component<{ @@ -72,15 +75,15 @@ class SectionRunView extends React.Component<{
} else {
this.cancelAnimationFrame();
}
}
};
updateNow = (now: number) => {
this.animationFrameHandle = null;
this.setState({
now: this.startTime + now,
now: this.startTime + now
});
this.requestAnimationFrame();
}
};
render() {
const { run, sections } = this.props;
@ -93,7 +96,7 @@ class SectionRunView extends React.Component<{ @@ -93,7 +96,7 @@ class SectionRunView extends React.Component<{
let paused: boolean = false;
let progressBar: React.ReactNode | undefined;
if (startTime != null) {
let elapsed = (run.totalDuration - run.duration);
let elapsed = run.totalDuration - run.duration;
if (run.pauseTime) {
paused = true;
} else {
@ -101,17 +104,25 @@ class SectionRunView extends React.Component<{ @@ -101,17 +104,25 @@ class SectionRunView extends React.Component<{
elapsed += (now - startTime.valueOf()) / 1000;
}
const percentage = elapsed / run.totalDuration;
progressBar =
<Progress color={paused ? "yellow" : "blue"} size="tiny" percent={percentage * 100}/>;
progressBar = (
<Progress
color={paused ? "yellow" : "blue"}
size="tiny"
percent={percentage * 100}
/>
);
}
const description = `'${section.name}' for ${duration.toString()}` +
const description =
`'${section.name}' for ${duration.toString()}` +
(paused ? " (paused)" : "") +
(running ? " (running)" : "");
return (
<Segment className="sectionRun">
<div className="flex-horizontal-space-between">
{description}
<Button negative onClick={cancel} icon size="mini"><Icon name="remove"/></Button>
<Button negative onClick={cancel} icon size="mini">
<Icon name="remove" />
</Button>
</div>
{progressBar}
</Segment>
@ -120,16 +131,23 @@ class SectionRunView extends React.Component<{ @@ -120,16 +131,23 @@ class SectionRunView extends React.Component<{
}
@observer
export default class SectionRunnerView extends React.Component<{
sectionRunner: SectionRunner, sections: Section[],
}, {}> {
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) =>
<SectionRunView key={run.id} run={run} sections={sections}/>);
const queueView = queue.map(run => (
<SectionRunView key={run.id} run={run} sections={sections} />
));
if (current) {
queueView.unshift(<SectionRunView key={-1} run={current} sections={sections}/>);
queueView.unshift(
<SectionRunView key={-1} run={current} sections={sections} />
);
}
if (queueView.length === 0) {
queueView.push(<Segment key={0}>No items in queue</Segment>);
@ -138,12 +156,10 @@ export default class SectionRunnerView extends React.Component<{ @@ -138,12 +156,10 @@ export default class SectionRunnerView extends React.Component<{
<Segment className="sectionRunner">
<div style={{ display: "flex", alignContent: "baseline" }}>
<h3 style={{ marginBottom: 0 }}>Section Runner Queue</h3>
<div className="flex-spacer"/>
<PausedState paused={paused} togglePaused={this.togglePaused}/>
<div className="flex-spacer" />
<PausedState paused={paused} togglePaused={this.togglePaused} />
</div>
<Segment.Group className="queue">
{queueView}
</Segment.Group>
<Segment.Group className="queue">{queueView}</Segment.Group>
</Segment>
);
}
@ -151,8 +167,11 @@ export default class SectionRunnerView extends React.Component<{ @@ -151,8 +167,11 @@ export default class SectionRunnerView extends React.Component<{
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"));
}
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")
);
};
}

24
client/components/SectionTable.tsx

@ -8,7 +8,9 @@ import { Section } from "@common/sprinklersRpc"; @@ -8,7 +8,9 @@ import { Section } from "@common/sprinklersRpc";
/* tslint:disable:object-literal-sort-keys */
@observer
export default class SectionTable extends React.Component<{ sections: Section[] }> {
export default class SectionTable extends React.Component<{
sections: Section[];
}> {
private static renderRow(section: Section, index: number) {
if (!section) {
return null;
@ -16,11 +18,15 @@ export default class SectionTable extends React.Component<{ sections: Section[] @@ -16,11 +18,15 @@ export default class SectionTable extends React.Component<{ sections: Section[]
const { name, state } = section;
const sectionStateClass = classNames({
"section-state": true,
"running": state,
running: state
});
const sectionState = state ?
(<span><Icon name={"shower" as any} /> Irrigating</span>)
: "Not irrigating";
const sectionState = state ? (
<span>
<Icon name={"shower" as any} /> Irrigating
</span>
) : (
"Not irrigating"
);
return (
<Table.Row key={index}>
<Table.Cell className="section--number">{"" + (index + 1)}</Table.Cell>
@ -41,12 +47,12 @@ export default class SectionTable extends React.Component<{ sections: Section[] @@ -41,12 +47,12 @@ export default class SectionTable extends React.Component<{ sections: Section[]
<Table.Row>
<Table.HeaderCell className="section--number">#</Table.HeaderCell>
<Table.HeaderCell className="section--name">Name</Table.HeaderCell>
<Table.HeaderCell className="section--state">State</Table.HeaderCell>
<Table.HeaderCell className="section--state">
State
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{rows}
</Table.Body>
<Table.Body>{rows}</Table.Body>
</Table>
);
}

8
client/env.js

@ -59,8 +59,7 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) { @@ -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) { @@ -79,5 +78,8 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) {
}, {}),
};
return { raw, stringified };
return {
raw,
stringified
};
};

3
client/index.html

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
@ -7,7 +8,9 @@ @@ -7,7 +8,9 @@
<title>Sprinklers3</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

14
client/index.tsx

@ -8,23 +8,23 @@ import { AppState, ProvideState } from "@client/state"; @@ -8,23 +8,23 @@ import { AppState, ProvideState } from "@client/state";
import logger from "@common/logger";
const state = new AppState();
state.start()
.catch((err: any) => {
state.start().catch((err: any) => {
logger.error({ err }, "error starting state");
});
});
const rootElem = document.getElementById("app");
const doRender = (Component: React.ComponentType) => {
ReactDOM.render((
ReactDOM.render(
<AppContainer>
<ProvideState state={state}>
<Router history={state.history}>
<Component/>
<Component />
</Router>
</ProvideState>
</AppContainer>
), rootElem);
</AppContainer>,
rootElem
);
};
doRender(App);

10
client/pages/DevicePage.tsx

@ -5,9 +5,15 @@ import { Item } from "semantic-ui-react"; @@ -5,9 +5,15 @@ import { Item } from "semantic-ui-react";
import DeviceView from "@client/components/DeviceView";
import { RouteComponentProps, withRouter } from "react-router";
class DevicePage extends React.Component<RouteComponentProps<{ deviceId: string }>> {
class DevicePage extends React.Component<
RouteComponentProps<{ deviceId: string }>
> {
render() {
const { match: { params: { deviceId } } } = this.props;
const {
match: {
params: { deviceId }
}
} = this.props;
const devId = Number(deviceId);
return (
<Item.Group divided>

6
client/pages/DevicesPage.tsx

@ -15,16 +15,14 @@ class DevicesPage extends React.Component<{ appState: AppState }> { @@ -15,16 +15,14 @@ class DevicesPage extends React.Component<{ appState: AppState }> {
} else if (!userData.devices || !userData.devices.length) {
deviceNodes = <span>You have no devices</span>;
} else {
deviceNodes = userData.devices.map((device) => (
deviceNodes = userData.devices.map(device => (
<DeviceView key={device.id} deviceId={device.id} inList />
));
}
return (
<React.Fragment>
<h1>Devices</h1>
<Item.Group>
{deviceNodes}
</Item.Group>
<Item.Group>{deviceNodes}</Item.Group>
</React.Fragment>
);
}

59
client/pages/LoginPage.tsx

@ -1,7 +1,16 @@ @@ -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,13 +18,18 @@ import log from "@common/logger"; @@ -9,13 +18,18 @@ 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() {
@computed
get canLogin() {
return this.username.length > 0 && this.password.length > 0;
}
@ -33,17 +47,22 @@ class LoginPageState { @@ -33,17 +47,22 @@ class LoginPageState {
login(appState: AppState) {
this.loading = true;
this.error = null;
appState.httpApi.grantPassword(this.username, this.password)
.then(action("loginSuccess", () => {
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) => {
})
)
.catch(
action("loginError", (err: any) => {
this.loading = false;
this.error = err.message;
log.error({ err }, "login error");
}));
})
);
}
}
@ -56,20 +75,28 @@ class LoginPage extends React.Component<{ appState: AppState }> { @@ -56,20 +75,28 @@ class LoginPage extends React.Component<{ appState: AppState }> {
<Container className="loginPage">
<Segment>
<Dimmer inverted active={loading}>
<Loader/>
<Loader />
</Dimmer>
<Header as="h1">Login</Header>
<Form>
<Form.Input label="Username" value={username} onChange={this.pageState.onUsernameChange}/>
<Form.Input
label="Username"
value={username}
onChange={this.pageState.onUsernameChange}
/>
<Form.Input
label="Password"
value={password}
type="password"
onChange={this.pageState.onPasswordChange}
/>
<Message error visible={error != null}>{error}</Message>
<Form.Button disabled={!canLogin} onClick={this.login}>Login</Form.Button>
<Message error visible={error != null}>
{error}
</Message>
<Form.Button disabled={!canLogin} onClick={this.login}>
Login
</Form.Button>
</Form>
</Segment>
</Container>
@ -78,7 +105,7 @@ class LoginPage extends React.Component<{ appState: AppState }> { @@ -78,7 +105,7 @@ class LoginPage extends React.Component<{ appState: AppState }> {
login = () => {
this.pageState.login(this.props.appState);
}
};
}
export default injectState(observer(LoginPage));

8
client/pages/LogoutPage.tsx

@ -6,12 +6,8 @@ import { AppState, ConsumeState } from "@client/state"; @@ -6,12 +6,8 @@ import { AppState, ConsumeState } from "@client/state";
export default function LogoutPage() {
function consumeState(appState: AppState) {
appState.tokenStore.clearAll();
return (
<Redirect to="/login" />
);
return <Redirect to="/login" />;
}
return (
<ConsumeState>{consumeState}</ConsumeState>
);
return <ConsumeState>{consumeState}</ConsumeState>;
}

22
client/pages/MessageTest.tsx

@ -18,23 +18,29 @@ class MessageTest extends React.Component<{ appState: AppState }> { @@ -18,23 +18,29 @@ class MessageTest extends React.Component<{ appState: AppState }> {
private test1 = () => {
this.props.appState.uiStore.addMessage({
info: true, content: "Test Message! " + getRandomId(), header: "Header to test message",
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,
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: <div className="ui segment">I Have crazy content!</div>,
header: "Header to test message", timeout: 5000,
color: "brown",
content: <div className="ui segment">I Have crazy content!</div>,
header: "Header to test message",
timeout: 5000
});
}
};
}
export default injectState(MessageTest);

51
client/pages/ProgramPage.tsx

@ -3,7 +3,16 @@ import { observer } from "mobx-react"; @@ -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,7 +22,8 @@ import log from "@common/logger"; @@ -13,7 +22,8 @@ import log from "@common/logger";
import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import { action } from "mobx";
interface ProgramPageProps extends RouteComponentProps<{ deviceId: string, programId: string }> {
interface ProgramPageProps
extends RouteComponentProps<{ deviceId: string; programId: string }> {
appState: AppState;
}
@ -85,7 +95,9 @@ class ProgramPage extends React.Component<ProgramPageProps> { @@ -85,7 +95,9 @@ class ProgramPage extends React.Component<ProgramPageProps> {
<Form>
<Form.Group inline>
<Form.Field inline>
<label><h4>Program</h4></label>
<label>
<h4>Program</h4>
</label>
<Input
placeholder="Program Name"
type="text"
@ -99,7 +111,11 @@ class ProgramPage extends React.Component<ProgramPageProps> { @@ -99,7 +111,11 @@ class ProgramPage extends React.Component<ProgramPageProps> {
</Menu.Item>
);
} else {
return <Menu.Item header as="h4">Program {name} ({program.id})</Menu.Item>;
return (
<Menu.Item header as="h4">
Program {name} ({program.id})
</Menu.Item>
);
}
}
@ -170,17 +186,28 @@ class ProgramPage extends React.Component<ProgramPageProps> { @@ -170,17 +186,28 @@ class ProgramPage extends React.Component<ProgramPageProps> {
readOnly={!editing}
onChange={this.onEnabledChange}
/>
<Form.Checkbox toggle label="Running" checked={running} readOnly={!editing} />
<Form.Checkbox
toggle
label="Running"
checked={running}
readOnly={!editing}
/>
</Form.Group>
<Form.Field>
<label><h4>Sequence</h4></label>
<label>
<h4>Sequence</h4>
</label>
<ProgramSequenceView
sequence={sequence}
sections={this.device.sections}
editing={editing}
/>
</Form.Field>
<ScheduleView schedule={schedule} editing={editing} label={<h4>Schedule</h4>} />
<ScheduleView
schedule={schedule}
editing={editing}
label={<h4>Schedule</h4>}
/>
</Form>
</Modal.Content>
{this.renderActions(program)}
@ -207,12 +234,14 @@ class ProgramPage extends React.Component<ProgramPageProps> { @@ -207,12 +234,14 @@ class ProgramPage extends React.Component<ProgramPageProps> {
return;
}
assign(this.program, this.programView);
this.program.update()
.then((data) => {
this.program.update().then(
data => {
log.info({ data }, "Program updated");
}, (err) => {
},
err => {
log.error({ err }, "error updating Program");
});
}
);
this.stopEditing();
}

7
client/routePaths.ts

@ -5,7 +5,7 @@ export interface RouteParams { @@ -5,7 +5,7 @@ export interface RouteParams {
export const routerRouteParams: RouteParams = {
deviceId: ":deviceId",
programId: ":programId",
programId: ":programId"
};
export const home = "/";
@ -18,6 +18,9 @@ export function device(deviceId?: string | number): string { @@ -18,6 +18,9 @@ export function device(deviceId?: string | number): string {
return `/devices/${deviceId || ""}`;
}
export function program(deviceId: string | number, programId?: string | number): string {
export function program(
deviceId: string | number,
programId?: string | number
): string {
return `${device(deviceId)}/programs/${programId}`;
}

17
client/sprinklersRpc/WSSprinklersDevice.ts

@ -23,11 +23,11 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -23,11 +23,11 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
runInAction("updateConnectionState", () => {
Object.assign(this.connectionState, { clientToServer, serverToBroker });
});
}
};
async subscribe() {
const subscribeRequest: ws.IDeviceSubscribeRequest = {
deviceId: this.id,
deviceId: this.id
};
try {
await this.api.makeRequest("deviceSubscribe", subscribeRequest);
@ -48,7 +48,7 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -48,7 +48,7 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
async unsubscribe() {
const unsubscribeRequest: ws.IDeviceSubscribeRequest = {
deviceId: this.id,
deviceId: this.id
};
try {
await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest);
@ -60,14 +60,19 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -60,14 +60,19 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
}
}
makeRequest(request: deviceRequests.Request): Promise<deviceRequests.Response> {
makeRequest(
request: deviceRequests.Request
): Promise<deviceRequests.Response> {
return this.api.makeDeviceCall(this.id, request);
}
waitSubscribe = () => {
when(() => this.api.authenticated, () => {
when(
() => this.api.authenticated,
() => {
this.subscribe();
when(() => !this.api.authenticated, this.waitSubscribe);
});
}
);
};
}

82
client/sprinklersRpc/WebSocketRpcClient.ts

@ -11,7 +11,11 @@ import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; @@ -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,10 +24,12 @@ const TIMEOUT_MS = 5000; @@ -20,10 +24,12 @@ 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;
@ -33,8 +39,8 @@ export interface WebSocketRpcClientEvents extends DefaultEvents { @@ -33,8 +39,8 @@ export interface WebSocketRpcClientEvents extends DefaultEvents {
// tslint:disable:member-ordering
export interface WebSocketRpcClient extends TypedEventEmitter<WebSocketRpcClientEvents> {
}
export interface WebSocketRpcClient
extends TypedEventEmitter<WebSocketRpcClientEvents> {}
@typedEventEmitter
export class WebSocketRpcClient extends s.SprinklersRPC {
@ -46,7 +52,8 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -46,7 +52,8 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
readonly webSocketUrl: string;
devices: Map<string, WSSprinklersDevice> = new Map();
@observable connectionState: s.ConnectionState = new s.ConnectionState();
@observable
connectionState: s.ConnectionState = new s.ConnectionState();
socket: WebSocket | null = null;
@observable
@ -110,8 +117,7 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -110,8 +117,7 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
releaseDevice(id: string): void {
const device = this.devices.get(id);
if (!device) return;
device.unsubscribe()
.then(() => {
device.unsubscribe().then(() => {
log.debug({ id }, "released device");
this.devices.delete(id);
});
@ -122,10 +128,15 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -122,10 +128,15 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
}
async tryAuthenticate() {
when(() => this.connectionState.clientToServer === true
&& this.tokenStore.accessToken.isValid, async () => {
when(
() =>
this.connectionState.clientToServer === true &&
this.tokenStore.accessToken.isValid,
async () => {
try {
const res = await this.authenticate(this.tokenStore.accessToken.token!);
const res = await this.authenticate(
this.tokenStore.accessToken.token!
);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
@ -138,34 +149,47 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -138,34 +149,47 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
this.authenticated = false;
});
}
});
}
);
}
// args must all be JSON serializable
async makeDeviceCall(deviceId: string, request: deviceRequests.Request): Promise<deviceRequests.Response> {
async makeDeviceCall(
deviceId: string,
request: deviceRequests.Request
): Promise<deviceRequests.Response> {
if (this.socket == null) {
const error: ws.IError = {
code: ErrorCode.ServerDisconnected,
message: "the server is not connected",
message: "the server is not connected"
};
throw new s.RpcError("the server is not connected", ErrorCode.ServerDisconnected);
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);
throw new s.RpcError(
resData.data.message,
resData.data.code,
resData.data
);
} else {
return resData.data;
}
}
makeRequest<Method extends ws.ClientRequestMethods>(method: Method, params: ws.IClientRequestTypes[Method]):
Promise<ws.IServerResponseTypes[Method]> {
makeRequest<Method extends ws.ClientRequestMethods>(
method: Method,
params: ws.IClientRequestTypes[Method]
): Promise<ws.IServerResponseTypes[Method]> {
const id = this.nextRequestId++;
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
let timeoutHandle: number;
this.responseCallbacks[id] = (response) => {
this.responseCallbacks[id] = response => {
clearTimeout(timeoutHandle);
delete this.responseCallbacks[id];
if (response.result === "success") {
@ -180,8 +204,7 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -180,8 +204,7 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
reject(new s.RpcError("the request timed out", ErrorCode.Timeout));
}, TIMEOUT_MS);
this.sendRequest(id, method, params);
})
.catch((err) => {
}).catch(err => {
if (err instanceof s.RpcError) {
this.emit("rpcError", err);
}
@ -197,18 +220,19 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -197,18 +220,19 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
}
private sendRequest<Method extends ws.ClientRequestMethods>(
id: number, method: Method, params: ws.IClientRequestTypes[Method],
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)) {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.tryAuthenticate();
return;
}
@ -229,10 +253,12 @@ export class WebSocketRpcClient extends s.SprinklersRPC { @@ -229,10 +253,12 @@ export class WebSocketRpcClient extends s.SprinklersRPC {
}
private onClose(event: CloseEvent) {
log.info({ event },
"disconnected from websocket");
log.info({ event }, "disconnected from websocket");
this.onDisconnect();
this.reconnectTimer = window.setTimeout(this._reconnect, RECONNECT_TIMEOUT_MS);
this.reconnectTimer = window.setTimeout(
this._reconnect,
RECONNECT_TIMEOUT_MS
);
}
@action

17
client/state/AppState.ts

@ -40,13 +40,14 @@ export default class AppState extends TypedEventEmitter<AppEvents> { @@ -40,13 +40,14 @@ export default class AppState extends TypedEventEmitter<AppEvents> {
});
}
@computed get isLoggedIn() {
@computed
get isLoggedIn() {
return this.tokenStore.accessToken.isValid;
}
async start() {
configure({
enforceActions: true,
enforceActions: true
});
syncHistoryWithStore(this.history, this.routerStore);
@ -58,20 +59,22 @@ export default class AppState extends TypedEventEmitter<AppEvents> { @@ -58,20 +59,22 @@ export default class AppState extends TypedEventEmitter<AppEvents> {
clearToken = (err?: any) => {
this.tokenStore.clearAccessToken();
this.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
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
if (!refreshToken.isValid) {
// if the refresh token is not valid, need to login again
this.history.push("/login");
return;
}
@ -88,5 +91,5 @@ export default class AppState extends TypedEventEmitter<AppEvents> { @@ -88,5 +91,5 @@ export default class AppState extends TypedEventEmitter<AppEvents> {
// TODO: some kind of error page?
}
}
}
};
}

69
client/state/HttpApi.ts

@ -3,7 +3,11 @@ import { action } from "mobx"; @@ -3,7 +3,11 @@ 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";
@ -20,14 +24,18 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -20,14 +24,18 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
tokenStore: TokenStore;
private get authorizationHeader(): {} | { "Authorization": string } {
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`) {
constructor(
baseUrl: string = `${location.protocol}//${location.hostname}:${
location.port
}/api`
) {
super();
while (baseUrl.charAt(baseUrl.length - 1) === "/") {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
@ -45,17 +53,21 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -45,17 +53,21 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
this.on("tokenGranted", this.onTokenGranted);
}
async makeRequest(url: string, options?: RequestInit, body?: any): Promise<any> {
async makeRequest(
url: string,
options?: RequestInit,
body?: any
): Promise<any> {
try {
options = options || {};
options = {
headers: {
"Content-Type": "application/json",
...this.authorizationHeader,
...options.headers || {},
...(options.headers || {})
},
body: JSON.stringify(body),
...options,
...options
};
let response: Response;
try {
@ -65,12 +77,16 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -65,12 +77,16 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
}
let responseBody: any;
try {
responseBody = await response.json() || {};
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);
throw new ApiError(
responseBody.message || response.statusText,
responseBody.code,
responseBody.data
);
}
return responseBody;
} catch (err) {
@ -81,11 +97,17 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -81,11 +97,17 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
async grantPassword(username: string, password: string) {
const request: TokenGrantPasswordRequest = {
grant_type: "password", username, password,
grant_type: "password",
username,
password
};
const response: TokenGrantResponse = await this.makeRequest("/token/grant", {
method: "POST",
}, request);
const response: TokenGrantResponse = await this.makeRequest(
"/token/grant",
{
method: "POST"
},
request
);
this.emit("tokenGranted", response);
}
@ -95,11 +117,16 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -95,11 +117,16 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
throw new ApiError("can not grant refresh with invalid refresh_token");
}
const request: TokenGrantRefreshRequest = {
grant_type: "refresh", refresh_token: refreshToken.token!,
grant_type: "refresh",
refresh_token: refreshToken.token!
};
const response: TokenGrantResponse = await this.makeRequest("/token/grant", {
method: "POST",
}, request);
const response: TokenGrantResponse = await this.makeRequest(
"/token/grant",
{
method: "POST"
},
request
);
this.emit("tokenGranted", response);
}
@ -109,8 +136,12 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { @@ -109,8 +136,12 @@ export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
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");
log.debug(
{
accessToken: accessToken.claims,
refreshToken: refreshToken.claims
},
"got new tokens"
);
}
}

27
client/state/Token.ts

@ -3,9 +3,11 @@ import * as jwt from "jsonwebtoken"; @@ -3,9 +3,11 @@ import * as jwt from "jsonwebtoken";
import { computed, createAtom, IAtom, observable } from "mobx";
export class Token<TClaims extends TokenClaims = TokenClaims> {
@observable token: string | null;
@observable
token: string | null;
@computed get claims(): TClaims | null {
@computed
get claims(): TClaims | null {
if (this.token == null) {
return null;
}
@ -18,8 +20,11 @@ export class Token<TClaims extends TokenClaims = TokenClaims> { @@ -18,8 +20,11 @@ export class Token<TClaims extends TokenClaims = TokenClaims> {
constructor(token: string | null = null) {
this.token = token;
this.isExpiredAtom = createAtom("Token.isExpired",
this.startUpdating, this.stopUpdating);
this.isExpiredAtom = createAtom(
"Token.isExpired",
this.startUpdating,
this.stopUpdating
);
this.updateCurrentTime();
}
@ -32,7 +37,7 @@ export class Token<TClaims extends TokenClaims = TokenClaims> { @@ -32,7 +37,7 @@ export class Token<TClaims extends TokenClaims = TokenClaims> {
this.isExpiredAtom.reportChanged();
}
this.currentTime = Date.now() / 1000;
}
};
get remainingTime(): number {
if (!this.isExpiredAtom.reportObserved()) {
@ -48,22 +53,26 @@ export class Token<TClaims extends TokenClaims = TokenClaims> { @@ -48,22 +53,26 @@ export class Token<TClaims extends TokenClaims = TokenClaims> {
this.stopUpdating();
const remaining = this.remainingTime;
if (remaining > 0) {
this.expirationTimer = setTimeout(this.updateCurrentTime, this.remainingTime);
}
this.expirationTimer = setTimeout(
this.updateCurrentTime,
this.remainingTime
);
}
};
private stopUpdating = () => {
if (this.expirationTimer != null) {
clearTimeout(this.expirationTimer);
this.expirationTimer = undefined;
}
}
};
get isExpired() {
return this.remainingTime <= 0;
}
@computed get isValid() {
@computed
get isValid() {
return this.token != null && !this.isExpired;
}
}

16
client/state/TokenStore.ts

@ -6,8 +6,10 @@ import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims"; @@ -6,8 +6,10 @@ import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims";
const LOCAL_STORAGE_KEY = "TokenStore";
export class TokenStore {
@observable accessToken: Token<AccessToken & BaseClaims> = new Token();
@observable refreshToken: Token<RefreshToken & BaseClaims> = new Token();
@observable
accessToken: Token<AccessToken & BaseClaims> = new Token();
@observable
refreshToken: Token<RefreshToken & BaseClaims> = new Token();
@action
clearAccessToken() {
@ -24,7 +26,10 @@ export class TokenStore { @@ -24,7 +26,10 @@ export class TokenStore {
@action
saveLocalStorage() {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.toJSON()));
window.localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(this.toJSON())
);
}
@action
@ -37,7 +42,10 @@ export class TokenStore { @@ -37,7 +42,10 @@ export class TokenStore {
}
toJSON() {
return { accessToken: this.accessToken.toJSON(), refreshToken: this.refreshToken.toJSON() };
return {
accessToken: this.accessToken.toJSON(),
refreshToken: this.refreshToken.toJSON()
};
}
@action

2
client/state/UiStore.ts

@ -19,7 +19,7 @@ export class UiStore { @@ -19,7 +19,7 @@ export class UiStore {
const { timeout, ...otherProps } = message;
const msg = observable({
...otherProps,
id: getRandomId(),
id: getRandomId()
});
this.messages.push(msg);
if (timeout) {

10
client/state/UserStore.ts

@ -2,7 +2,8 @@ import { ISprinklersDevice, IUser } from "@common/httpApi"; @@ -2,7 +2,8 @@ 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) {
@ -10,8 +11,11 @@ export class UserStore { @@ -10,8 +11,11 @@ export class UserStore {
}
findDevice(id: number): ISprinklersDevice | null {
return this.userData &&
return (
(this.userData &&
this.userData.devices &&
this.userData.devices.find((dev) => dev.id === id) || null;
this.userData.devices.find(dev => dev.id === id)) ||
null
);
}
}

27
client/state/reactContext.tsx

@ -11,9 +11,7 @@ export interface ProvideStateProps { @@ -11,9 +11,7 @@ export interface ProvideStateProps {
export function ProvideState({ state, children }: ProvideStateProps) {
return (
<StateContext.Provider value={state}>
{children}
</StateContext.Provider>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
);
}
@ -24,26 +22,33 @@ export interface ConsumeStateProps { @@ -24,26 +22,33 @@ export interface ConsumeStateProps {
export function ConsumeState({ children }: ConsumeStateProps) {
const consumeState = (state: AppState | null) => {
if (state == null) {
throw new Error("Component with ConsumeState must be mounted inside ProvideState");
throw new Error(
"Component with ConsumeState must be mounted inside ProvideState"
);
}
return children(state);
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
}
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<T, K extends keyof T> = {[P in Diff<keyof T, K>]: T[P]};
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<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };
export function injectState<P extends { appState: AppState }>(Component: React.ComponentType<P>):
React.ComponentClass<Omit<P, "appState">> {
export function injectState<P extends { appState: AppState }>(
Component: React.ComponentType<P>
): React.ComponentClass<Omit<P, "appState">> {
return class extends React.Component<Omit<P, "appState">> {
render() {
const consumeState = (state: AppState | null) => {
if (state == null) {
throw new Error("Component with injectState must be mounted inside ProvideState");
throw new Error(
"Component with injectState must be mounted inside ProvideState"
);
}
return <Component {...this.props} appState={state}/>;
return <Component {...this.props} appState={state} />;
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
}

13
client/styles/DeviceView.scss

@ -11,35 +11,34 @@ @@ -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 {

8
client/styles/DurationView.scss

@ -1,4 +1,4 @@ @@ -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; @@ -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; @@ -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; @@ -30,7 +30,7 @@ $durationInput-labelWidth: 2.5em;
flex-shrink: 0;
}
>.label {
> .label {
min-width: $durationInput-labelWidth;
width: $durationInput-labelWidth;
flex: $durationInput-labelWidth;

2
client/styles/ProgramSequenceView.scss

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
.programSequence-item {
list-style-type: none;
display: flex;
margin-bottom: .5em;
margin-bottom: 0.5em;
&.dragging {
z-index: 1010;
}

7
client/styles/ScheduleView.scss

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
.scheduleView {
>.field, >.fields {
>label {
> .field,
> .fields {
> label {
width: 2rem !important;
}
}
@ -8,6 +9,6 @@ @@ -8,6 +9,6 @@
.scheduleTimes {
input {
margin: 0 .5rem;
margin: 0 0.5rem;
}
}

6
client/styles/SectionRunnerView.scss

@ -5,13 +5,13 @@ @@ -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 {

6
client/tsconfig.json

@ -26,9 +26,7 @@ @@ -26,9 +26,7 @@
]
}
},
"references": [
{
"references": [{
"path": "../common"
}
]
}]
}

24
client/webpack.config.js

@ -11,7 +11,9 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); @@ -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,8 +85,7 @@ const rules = (env) => { @@ -83,8 +85,7 @@ const rules = (env) => {
sassConfig,
],
};
return [
{
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.
@ -104,8 +105,9 @@ const rules = (env) => { @@ -104,8 +105,9 @@ const rules = (env) => {
sassRule,
// Process TypeScript with TSC through HappyPack.
{
test: /\.tsx?$/, use: "happypack/loader?id=ts",
include: [ paths.clientDir, paths.commonDir ],
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.
@ -124,8 +126,7 @@ const rules = (env) => { @@ -124,8 +126,7 @@ const rules = (env) => {
},
},
],
},
];
}, ];
}
@ -200,8 +201,7 @@ const getConfig = module.exports = (env) => { @@ -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,11 +212,9 @@ const getConfig = module.exports = (env) => { @@ -212,11 +212,9 @@ 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) =>

10
common/ApiError.ts

@ -6,7 +6,11 @@ export default class ApiError extends Error { @@ -6,7 +6,11 @@ export default class ApiError extends Error {
code: ErrorCode;
data: any;
constructor(message: string, code: ErrorCode = ErrorCode.BadRequest, data: any = {}) {
constructor(
message: string,
code: ErrorCode = ErrorCode.BadRequest,
data: any = {}
) {
super(message);
this.statusCode = toHttpStatus(code);
this.code = code;
@ -20,7 +24,9 @@ export default class ApiError extends Error { @@ -20,7 +24,9 @@ export default class ApiError extends Error {
toJSON() {
return {
message: this.message, code: this.code, data: this.data,
message: this.message,
code: this.code,
data: this.data
};
}
}

2
common/ErrorCode.ts

@ -12,7 +12,7 @@ export enum ErrorCode { @@ -12,7 +12,7 @@ export enum ErrorCode {
Internal = 200,
Timeout = 300,
ServerDisconnected = 301,
BrokerDisconnected = 302,
BrokerDisconnected = 302
}
export function toHttpStatus(errorCode: ErrorCode): number {

7
common/TokenClaims.ts

@ -29,6 +29,11 @@ export interface SuperuserToken { @@ -29,6 +29,11 @@ export interface SuperuserToken {
type: "superuser";
}
export type TokenClaimTypes = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken;
export type TokenClaimTypes =
| AccessToken
| RefreshToken
| DeviceRegistrationToken
| DeviceToken
| SuperuserToken;
export type TokenClaims = TokenClaimTypes & BaseClaims;

54
common/TypedEventEmitter.ts

@ -4,21 +4,28 @@ type TEventName = string | symbol; @@ -4,21 +4,28 @@ type TEventName = string | symbol;
type AnyListener = (...args: any[]) => void;
type Arguments<TListener> = TListener extends (...args: infer TArgs) => any ? TArgs : any[];
type Listener<TEvents, TEvent extends keyof TEvents> = TEvents[TEvent] extends (...args: infer TArgs) => any ?
(...args: TArgs) => void : AnyListener;
type Arguments<TListener> = TListener extends (...args: infer TArgs) => any
? TArgs
: any[];
type Listener<TEvents, TEvent extends keyof TEvents> = 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;
}
export type AnyEvents = DefaultEvents & {
[event in TEventName]: any[];
};
export type AnyEvents = DefaultEvents & { [event in TEventName]: any[] };
type IEventSubscriber<TEvents extends DefaultEvents, This> =
<TEvent extends keyof TEvents & TEventName>(event: TEvent, listener: Listener<TEvents, TEvent>) => This;
type IEventSubscriber<TEvents extends DefaultEvents, This> = <
TEvent extends keyof TEvents & TEventName
>(
event: TEvent,
listener: Listener<TEvents, TEvent>
) => This;
// tslint:disable:ban-types
@ -31,31 +38,44 @@ interface ITypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> { @@ -31,31 +38,44 @@ interface ITypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> {
prependListener: IEventSubscriber<TEvents, this>;
prependOnceListener: IEventSubscriber<TEvents, this>;
emit<TEvent extends keyof TEvents & TEventName>(event: TEvent, ...args: Arguments<TEvents[TEvent]>): boolean;
listeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
rawListeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
emit<TEvent extends keyof TEvents & TEventName>(
event: TEvent,
...args: Arguments<TEvents[TEvent]>
): boolean;
listeners<TEvent extends keyof TEvents & TEventName>(
event: TEvent
): Function[];
rawListeners<TEvent extends keyof TEvents & TEventName>(
event: TEvent
): Function[];
eventNames(): Array<keyof TEvents | TEventName>;
setMaxListeners(maxListeners: number): this;
getMaxListeners(): number;
listenerCount<TEvent extends keyof TEvents & TEventName>(event: TEvent): number;
listenerCount<TEvent extends keyof TEvents & TEventName>(
event: TEvent
): number;
}
const TypedEventEmitter = EventEmitter as {
new<TEvents extends DefaultEvents = AnyEvents>(): TypedEventEmitter<TEvents>,
new <TEvents extends DefaultEvents = AnyEvents>(): TypedEventEmitter<TEvents>;
};
type TypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> = ITypedEventEmitter<TEvents>;
type TypedEventEmitter<
TEvents extends DefaultEvents = AnyEvents
> = ITypedEventEmitter<TEvents>;
type Constructable = new (...args: any[]) => any;
export function typedEventEmitter<TBase extends Constructable, TEvents extends DefaultEvents = AnyEvents>(Base: TBase):
TBase & TypedEventEmitter<TEvents> {
export function typedEventEmitter<
TBase extends Constructable,
TEvents extends DefaultEvents = AnyEvents
>(Base: TBase): TBase & TypedEventEmitter<TEvents> {
const NewClass = class extends Base {
constructor(...args: any[]) {
super(...args);
EventEmitter.call(this);
}
};
Object.getOwnPropertyNames(EventEmitter.prototype).forEach((name) => {
Object.getOwnPropertyNames(EventEmitter.prototype).forEach(name => {
NewClass.prototype[name] = (EventEmitter.prototype as any)[name];
});
return NewClass as any;

4
common/httpApi/index.ts

@ -9,7 +9,9 @@ export interface TokenGrantRefreshRequest { @@ -9,7 +9,9 @@ export interface TokenGrantRefreshRequest {
refresh_token: string;
}
export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshRequest;
export type TokenGrantRequest =
| TokenGrantPasswordRequest
| TokenGrantRefreshRequest;
export interface TokenGrantResponse {
access_token: string;

139
common/jsonRpc/index.ts

@ -16,8 +16,10 @@ export type DefaultNotificationTypes = {}; @@ -16,8 +16,10 @@ export type DefaultNotificationTypes = {};
// ErrorType: DefaultErrorType;
// }
export interface Request<RequestTypes = DefaultRequestTypes,
Method extends keyof RequestTypes = keyof RequestTypes> {
export interface Request<
RequestTypes = DefaultRequestTypes,
Method extends keyof RequestTypes = keyof RequestTypes
> {
type: "request";
id: number;
method: Method;
@ -40,93 +42,142 @@ export interface ErrorData<ErrorType> { @@ -40,93 +42,142 @@ export interface ErrorData<ErrorType> {
error: ErrorType;
}
export type ResponseData<ResponseTypes, ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes> =
SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>;
export type ResponseData<
ResponseTypes,
ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes
> = SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>;
export type Response<ResponseTypes,
export type Response<
ResponseTypes,
ErrorType = DefaultErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes> =
ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>;
Method extends keyof ResponseTypes = keyof ResponseTypes
> = ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>;
export interface Notification<NotificationTypes = DefaultNotificationTypes,
Method extends keyof NotificationTypes = keyof NotificationTypes> {
export interface Notification<
NotificationTypes = DefaultNotificationTypes,
Method extends keyof NotificationTypes = keyof NotificationTypes
> {
type: "notification";
method: Method;
data: NotificationTypes[Method];
}
export type Message<RequestTypes = DefaultRequestTypes,
export type Message<
RequestTypes = DefaultRequestTypes,
ResponseTypes = DefaultResponseTypes,
ErrorType = DefaultErrorType,
NotificationTypes = DefaultNotificationTypes> =
Request<RequestTypes> |
Response<ResponseTypes, ErrorType> |
Notification<NotificationTypes>;
NotificationTypes = DefaultNotificationTypes
> =
| Request<RequestTypes>
| Response<ResponseTypes, ErrorType>
| Notification<NotificationTypes>;
// export type TypesMessage<Types extends RpcTypes = RpcTypes> =
// Message<Types["RequestTypes"], Types["ResponseTypes"], Types["ErrorType"], Types["NotificationTypes"]>;
export function isRequestMethod<Method extends keyof RequestTypes, RequestTypes>(
message: Request<RequestTypes>, method: Method,
export function isRequestMethod<
Method extends keyof RequestTypes,
RequestTypes
>(
message: Request<RequestTypes>,
method: Method
): message is Request<RequestTypes, Method> {
return message.method === method;
}
export function isResponseMethod<Method extends keyof ResponseTypes, ErrorType, ResponseTypes>(
message: Response<ResponseTypes, ErrorType>, method: Method,
export function isResponseMethod<
Method extends keyof ResponseTypes,
ErrorType,
ResponseTypes
>(
message: Response<ResponseTypes, ErrorType>,
method: Method
): message is Response<ResponseTypes, ErrorType, Method> {
return message.method === method;
}
export function isNotificationMethod<Method extends keyof NotificationTypes, NotificationTypes = any>(
message: Notification<NotificationTypes>, method: Method,
export function isNotificationMethod<
Method extends keyof NotificationTypes,
NotificationTypes = any
>(
message: Notification<NotificationTypes>,
method: Method
): message is Notification<NotificationTypes, Method> {
return message.method === method;
}
export type IRequestHandler<RequestTypes, ResponseTypes extends { [M in Method]: any }, ErrorType,
Method extends keyof RequestTypes> =
(request: RequestTypes[Method]) => Promise<ResponseData<ResponseTypes, ErrorType, Method>>;
export type RequestHandlers<RequestTypes, ResponseTypes extends { [M in keyof RequestTypes]: any }, ErrorType> = {
[Method in keyof RequestTypes]:
IRequestHandler<RequestTypes, ResponseTypes, ErrorType, Method>;
export type IRequestHandler<
RequestTypes,
ResponseTypes extends { [M in Method]: any },
ErrorType,
Method extends keyof RequestTypes
> = (
request: RequestTypes[Method]
) => Promise<ResponseData<ResponseTypes, ErrorType, Method>>;
export type RequestHandlers<
RequestTypes,
ResponseTypes extends { [M in keyof RequestTypes]: any },
ErrorType
> = {
[Method in keyof RequestTypes]: IRequestHandler<
RequestTypes,
ResponseTypes,
ErrorType,
Method
>
};
export type IResponseHandler<ResponseTypes, ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes> =
(response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
export type IResponseHandler<
ResponseTypes,
ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes
> = (response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
export interface ResponseHandlers<ResponseTypes = DefaultResponseTypes, ErrorType = DefaultErrorType> {
export interface ResponseHandlers<
ResponseTypes = DefaultResponseTypes,
ErrorType = DefaultErrorType
> {
[id: number]: IResponseHandler<ResponseTypes, ErrorType>;
}
export type NotificationHandler<NotificationTypes, Method extends keyof NotificationTypes> =
(notification: NotificationTypes[Method]) => void;
export type NotificationHandler<
NotificationTypes,
Method extends keyof NotificationTypes
> = (notification: NotificationTypes[Method]) => void;
export type NotificationHandlers<NotificationTypes> = {
[Method in keyof NotificationTypes]: NotificationHandler<NotificationTypes, Method>;
[Method in keyof NotificationTypes]: NotificationHandler<
NotificationTypes,
Method
>
};
export function listRequestHandlerMethods<RequestTypes,
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>(
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
export function listRequestHandlerMethods<
RequestTypes,
ResponseTypes extends { [Method in keyof RequestTypes]: any },
ErrorType
>(
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>
): Array<keyof RequestTypes> {
return Object.keys(handlers) as any;
}
export function listNotificationHandlerMethods<NotificationTypes>(
handlers: NotificationHandlers<NotificationTypes>,
handlers: NotificationHandlers<NotificationTypes>
): Array<keyof NotificationTypes> {
return Object.keys(handlers) as any;
}
export async function handleRequest<RequestTypes,
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>(
export async function handleRequest<
RequestTypes,
ResponseTypes extends { [Method in keyof RequestTypes]: any },
ErrorType
>(
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
message: Request<RequestTypes>,
thisParam?: any,
thisParam?: any
): Promise<ResponseData<ResponseTypes, ErrorType>> {
const handler = handlers[message.method];
if (!handler) {
@ -138,7 +189,7 @@ export async function handleRequest<RequestTypes, @@ -138,7 +189,7 @@ export async function handleRequest<RequestTypes,
export function handleResponse<ResponseTypes, ErrorType>(
handlers: ResponseHandlers<ResponseTypes, ErrorType>,
message: Response<ResponseTypes, ErrorType>,
thisParam?: any,
thisParam?: any
) {
const handler = handlers[message.id];
if (!handler) {
@ -150,7 +201,7 @@ export function handleResponse<ResponseTypes, ErrorType>( @@ -150,7 +201,7 @@ export function handleResponse<ResponseTypes, ErrorType>(
export function handleNotification<NotificationTypes>(
handlers: NotificationHandlers<NotificationTypes>,
message: Notification<NotificationTypes>,
thisParam?: any,
thisParam?: any
) {
const handler = handlers[message.method];
if (!handler) {

48
common/logger.ts

@ -4,24 +4,24 @@ import * as pino from "pino"; @@ -4,24 +4,24 @@ import * as pino from "pino";
type Level = "default" | "60" | "50" | "40" | "30" | "20" | "10";
const levels: {[level in Level]: string } = {
const levels: { [level in Level]: string } = {
default: "USERLVL",
60: "FATAL",
50: "ERROR",
40: "WARN",
30: "INFO",
20: "DEBUG",
10: "TRACE",
10: "TRACE"
};
const levelColors: {[level in Level]: string } = {
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;",
10: "text-decoration: underline; color: #AAAAAA;"
};
interface ColoredString {
@ -34,31 +34,43 @@ function makeColored(str: string = ""): ColoredString { @@ -34,31 +34,43 @@ function makeColored(str: string = ""): ColoredString {
}
function concatColored(...coloredStrings: ColoredString[]): ColoredString {
return coloredStrings.reduce((prev, cur) => ({
return coloredStrings.reduce(
(prev, cur) => ({
str: prev.str + cur.str,
args: prev.args.concat(cur.args),
}), makeColored());
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(": "),
makeColored(": ")
);
if (value.msg) {
line = concatColored(line, {
str: "%c" + value.msg, args: ["color: #00FFFF"],
str: "%c" + value.msg,
args: ["color: #00FFFF"]
});
}
const args = [line.str].concat(line.args)
.concat([
(value.type === "Error") ? value.stack : filter(value),
]);
const args = [line.str]
.concat(line.args)
.concat([value.type === "Error" ? value.stack : filter(value)]);
let fn;
if (value.level >= 50) {
fn = console.error;
@ -83,7 +95,7 @@ function filter(value: any) { @@ -83,7 +95,7 @@ function filter(value: any) {
return result;
}
function formatSource(value: any): { str: string, args: any[] } {
function formatSource(value: any): { str: string; args: any[] } {
if (value.source) {
return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] };
} else {
@ -96,12 +108,12 @@ function formatLevel(value: any): ColoredString { @@ -96,12 +108,12 @@ function formatLevel(value: any): ColoredString {
if (levelColors.hasOwnProperty(level)) {
return {
str: "%c" + levels[level] + "%c",
args: [levelColors[level], ""],
args: [levelColors[level], ""]
};
} else {
return {
str: levels.default,
args: [levelColors.default],
args: [levelColors.default]
};
}
}
@ -109,7 +121,7 @@ function formatLevel(value: any): ColoredString { @@ -109,7 +121,7 @@ function formatLevel(value: any): ColoredString {
const logger: pino.Logger = pino({
serializers: pino.stdSerializers,
browser: { serialize: true, write },
level: "trace",
level: "trace"
});
export default logger;

24
common/sprinklersRpc/ConnectionState.ts

@ -5,31 +5,37 @@ export class ConnectionState { @@ -5,31 +5,37 @@ 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;
@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;
@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;
@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;
@observable
hasPermission: boolean | null = null;
@computed get noPermission() {
@computed
get noPermission() {
return this.hasPermission === false;
}
@computed get isAvailable(): boolean {
@computed
get isAvailable(): boolean {
if (this.hasPermission === false) {
return false;
}
@ -45,7 +51,8 @@ export class ConnectionState { @@ -45,7 +51,8 @@ export class ConnectionState {
return false;
}
@computed get isDeviceConnected(): boolean | null {
@computed
get isDeviceConnected(): boolean | null {
if (this.hasPermission === false) {
return false;
}
@ -58,7 +65,8 @@ export class ConnectionState { @@ -58,7 +65,8 @@ export class ConnectionState {
return null;
}
@computed get isServerConnected(): boolean | null {
@computed
get isServerConnected(): boolean | null {
if (this.hasPermission === false) {
return false;
}

28
common/sprinklersRpc/Program.ts

@ -22,11 +22,16 @@ export class Program { @@ -22,11 +22,16 @@ export class Program {
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<Program>) {
this.device = device;
@ -51,14 +56,19 @@ export class Program { @@ -51,14 +56,19 @@ export class Program {
clone(): Program {
return new Program(this.device, this.id, {
name: this.name, enabled: this.enabled, running: this.running,
name: this.name,
enabled: this.enabled,
running: this.running,
schedule: this.schedule.clone(),
sequence: this.sequence.slice(),
sequence: this.sequence.slice()
});
}
toString(): string {
return `Program{name="${this.name}", enabled=${this.enabled}, schedule=${this.schedule}, ` +
`sequence=${this.sequence}, running=${this.running}}`;
return (
`Program{name="${this.name}", enabled=${this.enabled}, schedule=${
this.schedule
}, ` + `sequence=${this.sequence}, running=${this.running}}`
);
}
}

6
common/sprinklersRpc/RpcError.ts

@ -6,7 +6,11 @@ export class RpcError extends Error implements IError { @@ -6,7 +6,11 @@ export class RpcError extends Error implements IError {
code: number;
data: any;
constructor(message: string, code: number = ErrorCode.BadRequest, data: any = {}) {
constructor(
message: string,
code: number = ErrorCode.BadRequest,
data: any = {}
) {
super(message);
this.code = code;
if (data instanceof Error) {

6
common/sprinklersRpc/Section.ts

@ -5,8 +5,10 @@ export class Section { @@ -5,8 +5,10 @@ export class Section {
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;

26
common/sprinklersRpc/SectionRunner.ts

@ -11,7 +11,11 @@ export class SectionRun { @@ -11,7 +11,11 @@ export class SectionRun {
pauseTime: Date | null = null;
unpauseTime: Date | null = null;
constructor(sectionRunner: SectionRunner, id: number = 0, section: number = 0) {
constructor(
sectionRunner: SectionRunner,
id: number = 0,
section: number = 0
) {
this.sectionRunner = sectionRunner;
this.id = id;
this.section = section;
@ -20,17 +24,23 @@ export class SectionRun { @@ -20,17 +24,23 @@ export class SectionRun {
cancel = () => this.sectionRunner.cancelRunById(this.id);
toString() {
return `SectionRun{id=${this.id}, section=${this.section}, duration=${this.duration},` +
` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`;
return (
`SectionRun{id=${this.id}, section=${this.section}, duration=${
this.duration
},` + ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`
);
}
}
export class SectionRunner {
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;
@ -53,6 +63,8 @@ export class SectionRunner { @@ -53,6 +63,8 @@ export class SectionRunner {
}
toString(): string {
return `SectionRunner{queue="${this.queue}", current="${this.current}", paused=${this.paused}}`;
return `SectionRunner{queue="${this.queue}", current="${
this.current
}", paused=${this.paused}}`;
}
}

29
common/sprinklersRpc/SprinklersDevice.ts

@ -10,12 +10,17 @@ export abstract class SprinklersDevice { @@ -10,12 +10,17 @@ 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 {
@observable
connectionState: ConnectionState = new ConnectionState();
@observable
sections: Section[] = [];
@observable
programs: Program[] = [];
@observable
sectionRunner: SectionRunner;
@computed
get connected(): boolean {
return this.connectionState.isDeviceConnected || false;
}
@ -28,7 +33,7 @@ export abstract class SprinklersDevice { @@ -28,7 +33,7 @@ export abstract class SprinklersDevice {
protected constructor(rpc: SprinklersRPC, id: string) {
this.rpc = rpc;
this.id = id;
this.sectionRunner = new (this.sectionRunnerConstructor)(this);
this.sectionRunner = new this.sectionRunnerConstructor(this);
}
abstract makeRequest(request: req.Request): Promise<req.Response>;
@ -62,7 +67,9 @@ export abstract class SprinklersDevice { @@ -62,7 +67,9 @@ export abstract class SprinklersDevice {
return this.makeRequest({ ...opts, type: "cancelProgram" });
}
updateProgram(opts: req.UpdateProgramData): Promise<req.UpdateProgramResponse> {
updateProgram(
opts: req.UpdateProgramData
): Promise<req.UpdateProgramResponse> {
return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise<any>;
}
@ -83,9 +90,11 @@ export abstract class SprinklersDevice { @@ -83,9 +90,11 @@ export abstract class SprinklersDevice {
}
toString(): string {
return `SprinklersDevice{id="${this.id}", connected=${this.connected}, ` +
return (
`SprinklersDevice{id="${this.id}", connected=${this.connected}, ` +
`sections=[${this.sections}], ` +
`programs=[${this.programs}], ` +
`sectionRunner=${this.sectionRunner} }`;
`sectionRunner=${this.sectionRunner} }`
);
}
}

45
common/sprinklersRpc/deviceRequests.ts

@ -2,16 +2,21 @@ export interface WithType<Type extends string = string> { @@ -2,16 +2,21 @@ export interface WithType<Type extends string = string> {
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,23 +24,37 @@ export type RunSectionResponse = Response<"runSection", { runId: number }>; @@ -19,23 +24,37 @@ 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<Type extends string = string> extends WithType<Type> {
export interface SuccessResponseData<Type extends string = string>
extends WithType<Type> {
result: "success";
message: string;
}
export interface ErrorResponseData<Type extends string = string> extends WithType<Type> {
export interface ErrorResponseData<Type extends string = string>
extends WithType<Type> {
result: "error";
message: string;
code: number;
@ -44,5 +63,5 @@ export interface ErrorResponseData<Type extends string = string> extends WithTyp @@ -44,5 +63,5 @@ export interface ErrorResponseData<Type extends string = string> extends WithTyp
}
export type Response<Type extends string = string, Res = {}> =
(SuccessResponseData<Type> & Res) |
(ErrorResponseData<Type>);
| (SuccessResponseData<Type> & Res)
| (ErrorResponseData<Type>);

2
common/sprinklersRpc/mqtt/MqttProgram.ts

@ -6,7 +6,7 @@ import * as schema from "@common/sprinklersRpc/schema"; @@ -6,7 +6,7 @@ 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");
this.running = payload === "true";
} else if (topic == null) {
this.updateFromJSON(JSON.parse(payload));
}

2
common/sprinklersRpc/mqtt/MqttSection.ts

@ -6,7 +6,7 @@ import * as schema from "@common/sprinklersRpc/schema"; @@ -6,7 +6,7 @@ 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");
this.state = payload === "true";
} else if (topic == null) {
this.updateFromJSON(JSON.parse(payload));
}

99
common/sprinklersRpc/mqtt/index.ts

@ -28,7 +28,8 @@ export interface MqttRpcClientOptions { @@ -28,7 +28,8 @@ export interface MqttRpcClientOptions {
password?: string;
}
export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptions {
export class MqttRpcClient extends s.SprinklersRPC
implements MqttRpcClientOptions {
get connected(): boolean {
return this.connectionState.isServerConnected || false;
}
@ -42,7 +43,8 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio @@ -42,7 +43,8 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio
password?: string;
client!: mqtt.Client;
@observable connectionState: s.ConnectionState = new s.ConnectionState();
@observable
connectionState: s.ConnectionState = new s.ConnectionState();
devices: Map<string, MqttSprinklersDevice> = new Map();
constructor(opts: MqttRpcClientOptions) {
@ -55,16 +57,22 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio @@ -55,16 +57,22 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio
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 = 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) => {
this.client.on("error", err => {
log.error({ err }, "mqtt error");
});
this.client.on("connect", () => {
@ -88,7 +96,7 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio @@ -88,7 +96,7 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio
}
let device = this.devices.get(id);
if (!device) {
this.devices.set(id, device = new MqttSprinklersDevice(this, id));
this.devices.set(id, (device = new MqttSprinklersDevice(this, id)));
if (this.connected) {
device.doSubscribe();
}
@ -96,7 +104,11 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio @@ -96,7 +104,11 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio
return device;
}
private onMessageArrived(topic: string, payload: Buffer, packet: mqtt.Packet) {
private onMessageArrived(
topic: string,
payload: Buffer,
packet: mqtt.Packet
) {
try {
this.processMessage(topic, payload, packet);
} catch (err) {
@ -104,7 +116,11 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio @@ -104,7 +116,11 @@ export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptio
}
}
private processMessage(topic: string, payloadBuf: Buffer, packet: mqtt.Packet) {
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}\\/([^\\/]+)\\/?(.*)$`);
@ -132,7 +148,7 @@ const subscriptions = [ @@ -132,7 +148,7 @@ const subscriptions = [
"/programs",
"/programs/+/#",
"/responses",
"/section_runner",
"/section_runner"
];
type IHandler = (payload: any, ...matches: string[]) => void;
@ -142,15 +158,19 @@ interface IHandlerEntry { @@ -142,15 +158,19 @@ interface IHandlerEntry {
handler: IHandler;
}
const handler = (test: RegExp) =>
(target: MqttSprinklersDevice, propertyKey: string, descriptor: TypedPropertyDescriptor<IHandler>) => {
const handler = (test: RegExp) => (
target: MqttSprinklersDevice,
propertyKey: string,
descriptor: TypedPropertyDescriptor<IHandler>
) => {
if (typeof descriptor.value === "function") {
const entry = {
test, handler: descriptor.value,
test,
handler: descriptor.value
};
(target.handlers || (target.handlers = [])).push(entry);
}
};
};
class MqttSprinklersDevice extends s.SprinklersDevice {
readonly apiClient: MqttRpcClient;
@ -167,7 +187,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -167,7 +187,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
this.programConstructor = MqttProgram;
this.apiClient = apiClient;
this.sectionRunner = new MqttSectionRunner(this);
this.subscriptions = subscriptions.map((filter) => this.prefix + filter);
this.subscriptions = subscriptions.map(filter => this.prefix + filter);
autorun(() => {
const brokerConnected = apiClient.connected;
@ -188,7 +208,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -188,7 +208,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
}
doSubscribe() {
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, (err) => {
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, err => {
if (err) {
log.error({ err, id: this.id }, "error subscribing to device");
} else {
@ -198,7 +218,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -198,7 +218,7 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
}
doUnsubscribe() {
this.apiClient.client.unsubscribe(this.subscriptions, (err) => {
this.apiClient.client.unsubscribe(this.subscriptions, err => {
if (err) {
log.error({ err, id: this.id }, "error unsubscribing to device");
} else {
@ -217,18 +237,21 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -217,18 +237,21 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
hndlr.call(this, payload, ...matches);
return;
}
log.warn({ topic }, "MqttSprinklersDevice recieved message on invalid topic");
log.warn(
{ topic },
"MqttSprinklersDevice recieved message on invalid topic"
);
}
makeRequest(request: requests.Request): Promise<requests.Response> {
return new Promise<requests.Response>((resolve, reject) => {
const topic = this.prefix + "/requests";
const json = seralizeRequest(request);
const requestId = json.rid = this.getRequestId();
const requestId = (json.rid = this.getRequestId());
const payloadStr = JSON.stringify(json);
let timeoutHandle: any;
const callback: ResponseCallback = (data) => {
const callback: ResponseCallback = data => {
if (data.result === "error") {
reject(new RpcError(data.message, data.code, data));
} else {
@ -256,15 +279,25 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -256,15 +279,25 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
/* 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}`);
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
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);
@ -277,9 +310,17 @@ class MqttSprinklersDevice extends s.SprinklersDevice { @@ -277,9 +310,17 @@ class MqttSprinklersDevice extends s.SprinklersDevice {
}
@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
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);

75
common/sprinklersRpc/schedule.ts

@ -7,14 +7,27 @@ export class TimeOfDay { @@ -7,14 +7,27 @@ export class TimeOfDay {
}
static fromDate(date: Date): TimeOfDay {
return new TimeOfDay(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
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 &&
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);
a.millisecond === b.millisecond)
);
}
readonly hour: number;
@ -22,7 +35,12 @@ export class TimeOfDay { @@ -22,7 +35,12 @@ export class TimeOfDay {
readonly second: number;
readonly millisecond: number;
constructor(hour: number = 0, minute: number = 0, second: number = 0, millisecond: number = 0) {
constructor(
hour: number = 0,
minute: number = 0,
second: number = 0,
millisecond: number = 0
) {
this.hour = hour;
this.minute = minute;
this.second = second;
@ -31,12 +49,18 @@ export class TimeOfDay { @@ -31,12 +49,18 @@ export class TimeOfDay {
}
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,
@ -50,17 +74,28 @@ export enum Month { @@ -50,17 +74,28 @@ export enum Month {
September = 9,
October = 10,
November = 11,
December = 12,
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) &&
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);
a.year === b.year)
);
}
static fromMoment(m: Moment): DateOfYear {
@ -85,10 +120,14 @@ export class DateOfYear { @@ -85,10 +120,14 @@ export class DateOfYear {
}
export class Schedule {
@observable times: TimeOfDay[] = [];
@observable weekdays: Weekday[] = [];
@observable from: DateOfYear | null = null;
@observable to: DateOfYear | null = null;
@observable
times: TimeOfDay[] = [];
@observable
weekdays: Weekday[] = [];
@observable
from: DateOfYear | null = null;
@observable
to: DateOfYear | null = null;
constructor(data?: Partial<Schedule>) {
if (typeof data === "object") {

18
common/sprinklersRpc/schema/common.ts

@ -1,13 +1,11 @@ @@ -1,13 +1,11 @@
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,
serializer: (jsDate: Date | null) =>
jsDate != null ? jsDate.toISOString() : null,
deserializer: (json: any, done) => {
if (json === null) {
return done(null, null);
@ -17,7 +15,7 @@ export const date: PropSchema = { @@ -17,7 +15,7 @@ export const date: PropSchema = {
} catch (e) {
done(e, undefined);
}
},
}
};
export const dateOfYear: ModelSchema<s.DateOfYear> = {
@ -25,8 +23,8 @@ export const dateOfYear: ModelSchema<s.DateOfYear> = { @@ -25,8 +23,8 @@ export const dateOfYear: ModelSchema<s.DateOfYear> = {
props: {
year: primitive(),
month: primitive(), // this only works if it is represented as a # from 0-12
day: primitive(),
},
day: primitive()
}
};
export const timeOfDay: ModelSchema<s.TimeOfDay> = {
@ -35,6 +33,6 @@ export const timeOfDay: ModelSchema<s.TimeOfDay> = { @@ -35,6 +33,6 @@ export const timeOfDay: ModelSchema<s.TimeOfDay> = {
hour: primitive(),
minute: primitive(),
second: primitive(),
millisecond: primitive(),
},
millisecond: primitive()
}
};

58
common/sprinklersRpc/schema/index.ts

@ -1,6 +1,4 @@ @@ -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,26 +9,29 @@ import * as common from "./common"; @@ -11,26 +9,29 @@ import * as common from "./common";
export * from "./common";
export const connectionState: ModelSchema<s.ConnectionState> = {
factory: (c) => new s.ConnectionState(),
factory: c => new s.ConnectionState(),
props: {
clientToServer: primitive(),
serverToBroker: primitive(),
brokerToDevice: primitive(),
},
brokerToDevice: primitive()
}
};
export const section: ModelSchema<s.Section> = {
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionConstructor(
c.parentContext.target, c.json.id),
factory: c =>
new (c.parentContext.target as s.SprinklersDevice).sectionConstructor(
c.parentContext.target,
c.json.id
),
props: {
id: primitive(),
name: primitive(),
state: primitive(),
},
state: primitive()
}
};
export const sectionRun: ModelSchema<s.SectionRun> = {
factory: (c) => new s.SectionRun(c.parentContext.target, c.json.id),
factory: c => new s.SectionRun(c.parentContext.target, c.json.id),
props: {
id: primitive(),
section: primitive(),
@ -38,18 +39,20 @@ export const sectionRun: ModelSchema<s.SectionRun> = { @@ -38,18 +39,20 @@ export const sectionRun: ModelSchema<s.SectionRun> = {
duration: common.duration,
startTime: common.date,
pauseTime: common.date,
unpauseTime: common.date,
},
unpauseTime: common.date
}
};
export const sectionRunner: ModelSchema<s.SectionRunner> = {
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor(
c.parentContext.target),
factory: c =>
new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor(
c.parentContext.target
),
props: {
queue: list(object(sectionRun)),
current: object(sectionRun),
paused: primitive(),
},
paused: primitive()
}
};
export const schedule: ModelSchema<s.Schedule> = {
@ -58,34 +61,37 @@ export const schedule: ModelSchema<s.Schedule> = { @@ -58,34 +61,37 @@ export const schedule: ModelSchema<s.Schedule> = {
times: list(object(common.timeOfDay)),
weekdays: list(primitive()),
from: object(common.dateOfYear),
to: object(common.dateOfYear),
},
to: object(common.dateOfYear)
}
};
export const programItem: ModelSchema<s.ProgramItem> = {
factory: () => new s.ProgramItem(),
props: {
section: primitive(),
duration: common.duration,
},
duration: common.duration
}
};
export const program: ModelSchema<s.Program> = {
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).programConstructor(
c.parentContext.target, c.json.id),
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(),
},
running: primitive()
}
};
export const sprinklersDevice = createSimpleSchema({
connectionState: object(connectionState),
sections: list(object(section)),
sectionRunner: object(sectionRunner),
programs: list(object(program)),
programs: list(object(program))
});

24
common/sprinklersRpc/schema/list.ts

@ -14,7 +14,11 @@ function isAliasedPropSchema(propSchema: any) { @@ -14,7 +14,11 @@ function isAliasedPropSchema(propSchema: any) {
return typeof propSchema === "object" && !!propSchema.jsonname;
}
function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any) {
function parallel(
ar: any[],
processor: (item: any, done: any) => void,
cb: any
) {
if (ar.length === 0) {
return void cb(null, []);
}
@ -40,15 +44,21 @@ function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any) @@ -40,15 +44,21 @@ function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any)
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");
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");
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
if (jsonArray === null) {
// sometimes go will return null in place of empty array
return void done(null, []);
}
if (!Array.isArray(jsonArray)) {
@ -58,8 +68,8 @@ export default function list(propSchema: PropSchema): PropSchema { @@ -58,8 +68,8 @@ export default function list(propSchema: PropSchema): PropSchema {
jsonArray,
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) =>
propSchema.deserializer(item, itemDone, context, undefined),
done,
done
);
},
}
};
}

52
common/sprinklersRpc/schema/requests.ts

@ -1,43 +1,63 @@ @@ -1,43 +1,63 @@
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<requests.WithType> = createSimpleSchema({
type: primitive(),
type: primitive()
});
export const withProgram: ModelSchema<requests.WithProgram> = createSimpleSchema({
export const withProgram: ModelSchema<
requests.WithProgram
> = createSimpleSchema({
...withType.props,
programId: primitive(),
programId: primitive()
});
export const withSection: ModelSchema<requests.WithSection> = createSimpleSchema({
export const withSection: ModelSchema<
requests.WithSection
> = createSimpleSchema({
...withType.props,
sectionId: primitive(),
sectionId: primitive()
});
export const updateProgram: ModelSchema<requests.UpdateProgramData> = createSimpleSchema({
export const updateProgram: ModelSchema<
requests.UpdateProgramData
> = createSimpleSchema({
...withProgram.props,
data: {
serializer: (data) => data,
deserializer: (json, done) => { done(null, json); },
},
serializer: data => data,
deserializer: (json, done) => {
done(null, json);
}
}
});
export const runSection: ModelSchema<requests.RunSectionData> = createSimpleSchema({
export const runSection: ModelSchema<
requests.RunSectionData
> = createSimpleSchema({
...withSection.props,
duration: common.duration,
duration: common.duration
});
export const cancelSectionRunId: ModelSchema<requests.CancelSectionRunIdData> = createSimpleSchema({
export const cancelSectionRunId: ModelSchema<
requests.CancelSectionRunIdData
> = createSimpleSchema({
...withType.props,
runId: primitive(),
runId: primitive()
});
export const pauseSectionRunner: ModelSchema<requests.PauseSectionRunnerData> = createSimpleSchema({
export const pauseSectionRunner: ModelSchema<
requests.PauseSectionRunnerData
> = createSimpleSchema({
...withType.props,
paused: primitive(),
paused: primitive()
});
export function getRequestSchema(request: requests.WithType): ModelSchema<any> {

54
common/sprinklersRpc/websocketData.ts

@ -16,10 +16,10 @@ export interface IDeviceCallRequest { @@ -16,10 +16,10 @@ export interface IDeviceCallRequest {
}
export interface IClientRequestTypes {
"authenticate": IAuthenticateRequest;
"deviceSubscribe": IDeviceSubscribeRequest;
"deviceUnsubscribe": IDeviceSubscribeRequest;
"deviceCall": IDeviceCallRequest;
authenticate: IAuthenticateRequest;
deviceSubscribe: IDeviceSubscribeRequest;
deviceUnsubscribe: IDeviceSubscribeRequest;
deviceCall: IDeviceCallRequest;
}
export interface IAuthenticateResponse {
@ -37,10 +37,10 @@ export interface IDeviceCallResponse { @@ -37,10 +37,10 @@ export interface IDeviceCallResponse {
}
export interface IServerResponseTypes {
"authenticate": IAuthenticateResponse;
"deviceSubscribe": IDeviceSubscribeResponse;
"deviceUnsubscribe": IDeviceSubscribeResponse;
"deviceCall": IDeviceCallResponse;
authenticate: IAuthenticateResponse;
deviceSubscribe: IDeviceSubscribeResponse;
deviceUnsubscribe: IDeviceSubscribeResponse;
deviceCall: IDeviceCallResponse;
}
export type ClientRequestMethods = keyof IClientRequestTypes;
@ -55,24 +55,40 @@ export interface IDeviceUpdate { @@ -55,24 +55,40 @@ export interface IDeviceUpdate {
}
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<IError>;
export type ServerMessage = rpc.Message<{}, IServerResponseTypes, IError, IServerNotificationTypes>;
export type ServerMessage = rpc.Message<
{},
IServerResponseTypes,
IError,
IServerNotificationTypes
>;
export type ServerNotification = rpc.Notification<IServerNotificationTypes>;
export type ServerResponse = rpc.Response<IServerResponseTypes, IError>;
export type ServerResponseData<Method extends keyof IServerResponseTypes = keyof IServerResponseTypes> =
rpc.ResponseData<IServerResponseTypes, IError, Method>;
export type ServerResponseHandlers = rpc.ResponseHandlers<IServerResponseTypes, IError>;
export type ServerNotificationHandlers = rpc.NotificationHandlers<IServerNotificationTypes>;
export type ServerResponseData<
Method extends keyof IServerResponseTypes = keyof IServerResponseTypes
> = rpc.ResponseData<IServerResponseTypes, IError, Method>;
export type ServerResponseHandlers = rpc.ResponseHandlers<
IServerResponseTypes,
IError
>;
export type ServerNotificationHandlers = rpc.NotificationHandlers<
IServerNotificationTypes
>;
export type ClientRequest<Method extends keyof IClientRequestTypes = keyof IClientRequestTypes> =
rpc.Request<IClientRequestTypes, Method>;
export type ClientRequest<
Method extends keyof IClientRequestTypes = keyof IClientRequestTypes
> = rpc.Request<IClientRequestTypes, Method>;
export type ClientMessage = rpc.Message<IClientRequestTypes, {}, IError, {}>;
export type ClientRequestHandlers = rpc.RequestHandlers<IClientRequestTypes, IServerResponseTypes, IError>;
export type ClientRequestHandlers = rpc.RequestHandlers<
IClientRequestTypes,
IServerResponseTypes,
IError
>;

10
common/utils.ts

@ -1,4 +1,8 @@ @@ -1,4 +1,8 @@
export function checkedIndexOf<T>(o: T | number, arr: T[], type: string = "object"): number {
export function checkedIndexOf<T>(
o: T | number,
arr: T[],
type: string = "object"
): number {
let idx: number;
if (typeof o === "number") {
idx = o;
@ -18,8 +22,8 @@ export function getRandomId() { @@ -18,8 +22,8 @@ export function getRandomId() {
}
export function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach((baseCtor) => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
derivedCtor.prototype[name] = baseCtor.prototype[name];
});
});

2
package.json

@ -124,6 +124,8 @@ @@ -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",

28
server/Database.ts

@ -1,5 +1,11 @@ @@ -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";
@ -22,13 +28,13 @@ export class Database { @@ -22,13 +28,13 @@ export class Database {
async connect() {
const options = await getConnectionOptions();
Object.assign(options, {
entities: [
path.resolve(__dirname, "entities", "*.js"),
],
entities: [path.resolve(__dirname, "entities", "*.js")]
});
this._conn = await createConnection(options);
this.users = this._conn.getCustomRepository(UserRepository);
this.sprinklersDevices = this._conn.getCustomRepository(SprinklersDeviceRepository);
this.sprinklersDevices = this._conn.getCustomRepository(
SprinklersDeviceRepository
);
}
async disconnect() {
@ -53,7 +59,7 @@ export class Database { @@ -53,7 +59,7 @@ export class Database {
if (!user) {
user = await this.users.create({
name: "Alex Mikhalev" + i,
username,
username
});
}
await user.setPassword("kakashka" + i);
@ -66,7 +72,10 @@ export class Database { @@ -66,7 +72,10 @@ export class Database {
if (!device) {
device = await this.sprinklersDevices.create();
}
Object.assign(device, { name, deviceId: "grinklers" + (i === 1 ? "" : i) });
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;
@ -80,7 +89,8 @@ export class Database { @@ -80,7 +89,8 @@ export class Database {
logger.info("inserted/updated users");
const alex2 = await this.users.findOne({ username: "alex0" });
logger.info("password valid: " + await alex2!.comparePassword("kakashka0"));
logger.info(
"password valid: " + (await alex2!.comparePassword("kakashka0"))
);
}
}

83
server/authentication.ts

@ -8,7 +8,7 @@ import { @@ -8,7 +8,7 @@ import {
TokenGrantPasswordRequest,
TokenGrantRefreshRequest,
TokenGrantRequest,
TokenGrantResponse,
TokenGrantResponse
} from "@common/httpApi";
import * as tok from "@common/TokenClaims";
import { User } from "@server/entities";
@ -21,13 +21,16 @@ if (!JWT_SECRET) { @@ -21,13 +21,16 @@ if (!JWT_SECRET) {
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<string> {
function signToken(
claims: tok.TokenClaimTypes,
opts?: jwt.SignOptions
): Promise<string> {
const options: jwt.SignOptions = {
issuer: ISSUER,
...opts,
...opts
};
return new Promise((resolve, reject) => {
jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => {
@ -40,16 +43,26 @@ function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise @@ -40,16 +43,26 @@ function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise
});
}
export function verifyToken<TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes>(
token: string, type?: TClaims["type"],
): Promise<TClaims & tok.BaseClaims> {
export function verifyToken<
TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes
>(token: string, type?: TClaims["type"]): Promise<TClaims & tok.BaseClaims> {
return new Promise((resolve, reject) => {
jwt.verify(token, JWT_SECRET, {
issuer: ISSUER,
}, (err, decoded) => {
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));
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 {
@ -58,54 +71,62 @@ export function verifyToken<TClaims extends tok.TokenClaimTypes = tok.TokenClaim @@ -58,54 +71,62 @@ export function verifyToken<TClaims extends tok.TokenClaimTypes = tok.TokenClaim
} 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));
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<string> {
const access_token_claims: tok.AccessToken = {
const accessTokenClaims: tok.AccessToken = {
aud: user.id,
name: user.name,
type: "access",
type: "access"
};
return signToken(access_token_claims, { expiresIn: ACCESS_TOKEN_LIFETIME });
return signToken(accessTokenClaims, { expiresIn: ACCESS_TOKEN_LIFETIME });
}
export function generateRefreshToken(user: User): Promise<string> {
const refresh_token_claims: tok.RefreshToken = {
const refreshTokenClaims: tok.RefreshToken = {
aud: user.id,
name: user.name,
type: "refresh",
type: "refresh"
};
return signToken(refresh_token_claims, { expiresIn: REFRESH_TOKEN_LIFETIME });
return signToken(refreshTokenClaims, { expiresIn: REFRESH_TOKEN_LIFETIME });
}
export function generateDeviceRegistrationToken(): Promise<string> {
const device_reg_token_claims: tok.DeviceRegistrationToken = {
type: "device_reg",
const deviceRegTokenClaims: tok.DeviceRegistrationToken = {
type: "device_reg"
};
return signToken(device_reg_token_claims);
return signToken(deviceRegTokenClaims);
}
export function generateDeviceToken(id: number, deviceId: string): Promise<string> {
const device_token_claims: tok.DeviceToken = {
export function generateDeviceToken(
id: number,
deviceId: string
): Promise<string> {
const deviceTokenClaims: tok.DeviceToken = {
type: "device",
aud: deviceId,
id,
id
};
return signToken(device_token_claims);
return signToken(deviceTokenClaims);
}
export function generateSuperuserToken(): Promise<string> {
const superuser_claims: tok.SuperuserToken = {
type: "superuser",
const superuserClaims: tok.SuperuserToken = {
type: "superuser"
};
return signToken(superuser_claims);
return signToken(superuserClaims);
}

2
server/configureLogger.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import log from "@common/logger";
Object.assign(log, {
name: "sprinklers3/server",
level: process.env.LOG_LEVEL || "debug",
level: process.env.LOG_LEVEL || "debug"
});

2
server/entities/SprinklersDevice.ts

@ -14,7 +14,7 @@ export class SprinklersDevice implements ISprinklersDevice { @@ -14,7 +14,7 @@ export class SprinklersDevice implements ISprinklersDevice {
@Column()
name: string = "";
@ManyToMany((type) => User)
@ManyToMany(type => User)
users: User[] | undefined;
constructor(data?: Partial<SprinklersDevice>) {

12
server/entities/User.ts

@ -1,9 +1,15 @@ @@ -1,9 +1,15 @@
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;
@ -21,7 +27,7 @@ export class User implements IUser { @@ -21,7 +27,7 @@ export class User implements IUser {
@Column()
passwordHash: string = "";
@ManyToMany((type) => SprinklersDevice)
@ManyToMany(type => SprinklersDevice)
@JoinTable({ name: "user_sprinklers_device" })
devices: SprinklersDevice[] | undefined;

6
server/env.ts

@ -12,13 +12,13 @@ const dotenvFiles: string[] = [ @@ -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
});
}
});

51
server/express/api/devices.ts

@ -1,5 +1,5 @@ @@ -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";
@ -15,9 +15,11 @@ function randomDeviceId(): string { @@ -15,9 +15,11 @@ function randomDeviceId(): string {
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
if (j < 10) {
// 0-9
ch = String.fromCharCode(48 + j);
} else { // a-z
} else {
// a-z
ch = String.fromCharCode(97 + (j - 10));
}
deviceId += ch;
@ -32,10 +34,15 @@ export function devices(state: ServerState) { @@ -32,10 +34,15 @@ export function devices(state: ServerState) {
const token = req.token!;
const userId = token.aud;
const deviceId = req.params.deviceId;
const userDevice = await state.database.sprinklersDevices
.findUserDevice(userId, 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);
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);
@ -43,27 +50,37 @@ export function devices(state: ServerState) { @@ -43,27 +50,37 @@ export function devices(state: ServerState) {
device.release();
});
router.post("/register", verifyAuthorization({
type: "device_reg",
}), async (req, res) => {
router.post(
"/register",
verifyAuthorization({
type: "device_reg"
}),
async (req, res) => {
const deviceId = randomDeviceId();
const newDevice = state.database.sprinklersDevices.create({
name: "Sprinklers Device", deviceId,
name: "Sprinklers Device",
deviceId
});
await state.database.sprinklersDevices.save(newDevice);
const token = await generateDeviceToken(newDevice.id, deviceId);
res.send({
data: newDevice, token,
});
data: newDevice,
token
});
}
);
router.post("/connect", verifyAuthorization({
type: "device",
}), async (req, res) => {
router.post(
"/connect",
verifyAuthorization({
type: "device"
}),
async (req, res) => {
res.send({
url: state.mqttUrl,
});
url: state.mqttUrl
});
}
);
return router;
}

13
server/express/api/mosquitto.ts

@ -16,7 +16,10 @@ export function mosquitto(state: ServerState) { @@ -16,7 +16,10 @@ export function mosquitto(state: ServerState) {
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);
throw new ApiError(
"Must specify a username and password",
ErrorCode.BadRequest
);
}
if (username === SUPERUSER) {
await verifyToken<SuperuserToken>(password, "superuser");
@ -27,7 +30,8 @@ export function mosquitto(state: ServerState) { @@ -27,7 +30,8 @@ export function mosquitto(state: ServerState) {
throw new ApiError("Username does not match token", ErrorCode.BadRequest);
}
res.status(200).send({
username, id: claims.id,
username,
id: claims.id
});
});
@ -45,7 +49,10 @@ export function mosquitto(state: ServerState) { @@ -45,7 +49,10 @@ export function mosquitto(state: ServerState) {
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);
throw new ApiError(
"username and topic must be specified as strings",
ErrorCode.BadRequest
);
}
const prefix = DEVICE_PREFIX + "/" + username;
if (!topic.startsWith(prefix)) {

18
server/express/api/token.ts

@ -11,7 +11,10 @@ import { ServerState } from "@server/state"; @@ -11,7 +11,10 @@ import { ServerState } from "@server/state";
export function token(state: ServerState) {
const router = PromiseRouter();
async function passwordGrant(body: httpApi.TokenGrantPasswordRequest, res: Express.Response): Promise<User> {
async function passwordGrant(
body: httpApi.TokenGrantPasswordRequest,
res: Express.Response
): Promise<User> {
const { username, password } = body;
if (!body || !username || !password) {
throw new ApiError("Must specify username and password");
@ -28,7 +31,10 @@ export function token(state: ServerState) { @@ -28,7 +31,10 @@ export function token(state: ServerState) {
}
}
async function refreshGrant(body: httpApi.TokenGrantRefreshRequest, res: Express.Response): Promise<User> {
async function refreshGrant(
body: httpApi.TokenGrantRefreshRequest,
res: Express.Response
): Promise<User> {
const { refresh_token } = body;
if (!body || !refresh_token) {
throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken);
@ -54,12 +60,14 @@ export function token(state: ServerState) { @@ -54,12 +60,14 @@ export function token(state: ServerState) {
} 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),
await authentication.generateRefreshToken(user)
]);
const response: httpApi.TokenGrantResponse = {
access_token, refresh_token,
access_token,
refresh_token
};
res.json(response);
});
@ -73,7 +81,7 @@ export function token(state: ServerState) { @@ -73,7 +81,7 @@ export function token(state: ServerState) {
router.post("/verify", verifyAuthorization(), async (req, res) => {
res.json({
ok: true,
token: req.token,
token: req.token
});
});

14
server/express/api/users.ts

@ -13,8 +13,9 @@ export function users(state: ServerState) { @@ -13,8 +13,9 @@ export function users(state: ServerState) {
async function getUser(params: { username: string }): Promise<User> {
const { username } = params;
const user = await state.database.users
.findByUsername(username, { devices: true });
const user = await state.database.users.findByUsername(username, {
devices: true
});
if (!user) {
throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound);
}
@ -22,10 +23,9 @@ export function users(state: ServerState) { @@ -22,10 +23,9 @@ export function users(state: ServerState) {
}
router.get("/", (req, res) => {
state.database.users.findAll()
.then((users_) => {
state.database.users.findAll().then(_users => {
res.json({
data: users_,
data: _users
});
});
});
@ -33,14 +33,14 @@ export function users(state: ServerState) { @@ -33,14 +33,14 @@ export function users(state: ServerState) {
router.get("/:username", async (req, res) => {
const user = await getUser(req.params);
res.json({
data: user,
data: user
});
});
router.get("/:username/devices", async (req, res) => {
const user = await getUser(req.params);
res.json({
data: user.devices,
data: user.devices
});
});

10
server/express/errorHandler.ts

@ -2,13 +2,17 @@ import * as express from "express"; @@ -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) => {
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;

20
server/express/verifyAuthorization.ts

@ -17,25 +17,33 @@ export interface VerifyAuthorizationOpts { @@ -17,25 +17,33 @@ export interface VerifyAuthorizationOpts {
type: tok.TokenClaims["type"];
}
export function verifyAuthorization(options?: Partial<VerifyAuthorizationOpts>): Express.RequestHandler {
export function verifyAuthorization(
options?: Partial<VerifyAuthorizationOpts>
): Express.RequestHandler {
const opts: VerifyAuthorizationOpts = {
type: "access",
...options,
...options
};
return (req, res, next) => {
const fun = async () => {
const bearer = req.headers.authorization;
if (!bearer) {
throw new ApiError("No Authorization header specified", ErrorCode.BadToken);
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);
throw new ApiError(
"Invalid Authorization header, must be Bearer",
ErrorCode.BadToken
);
}
const token = matches[1];
req.token = await verifyToken(token, opts.type) as any;
req.token = (await verifyToken(token, opts.type)) as any;
};
fun().then(() => next(null), (err) => next(err));
fun().then(() => next(null), err => next(err));
};
}

9
server/index.ts

@ -5,7 +5,7 @@ import "./env"; @@ -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); @@ -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()
state
.start()
.then(() => {
server.listen(port, host, () => {
log.info(`listening at ${host}:${port}`);
});
})
.catch((err) => {
.catch(err => {
log.error({ err }, "error starting server");
});

24
server/logging/prettyPrint.ts

@ -12,7 +12,7 @@ const levels = { @@ -12,7 +12,7 @@ const levels = {
40: "WARN",
30: "INFO",
20: "DEBUG",
10: "TRACE",
10: "TRACE"
};
const levelColors = {
@ -22,10 +22,19 @@ const levelColors = { @@ -22,10 +22,19 @@ const levelColors = {
40: chalk.yellow.underline,
30: chalk.green.underline,
20: chalk.blue.underline,
10: chalk.grey.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, " ");
@ -60,9 +69,11 @@ function formatter(value: any) { @@ -60,9 +69,11 @@ function formatter(value: any) {
function formatRequest(value: any): string {
const matches = /Content-Length: (\d+)/.exec(value.res.header);
const contentLength = matches ? matches[1] : null;
return `${value.req.remoteAddress} - ` +
return (
`${value.req.remoteAddress} - ` +
`"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` +
`${value.responseTime} ms - ${contentLength}`;
`${value.responseTime} ms - ${contentLength}`
);
}
function withSpaces(value: string): string {
@ -80,7 +91,8 @@ function filter(value: any) { @@ -80,7 +91,8 @@ function filter(value: any) {
for (const key of keys) {
if (filteredKeys.indexOf(key) < 0) {
result += "\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2));
result +=
"\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2));
}
}

19
server/repositories/SprinklersDeviceRepository.ts

@ -11,19 +11,28 @@ export class SprinklersDeviceRepository extends Repository<SprinklersDevice> { @@ -11,19 +11,28 @@ export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
async userHasAccess(userId: number, deviceId: number): Promise<boolean> {
const count = await this.manager
.createQueryBuilder(User, "user")
.innerJoinAndSelect("user.devices", "sprinklers_device",
.innerJoinAndSelect(
"user.devices",
"sprinklers_device",
"user.id = :userId AND sprinklers_device.id = :deviceId",
{ userId, deviceId })
{ userId, deviceId }
)
.getCount();
return count > 0;
}
async findUserDevice(userId: number, deviceId: number): Promise<SprinklersDevice | null> {
async findUserDevice(
userId: number,
deviceId: number
): Promise<SprinklersDevice | null> {
const user = await this.manager
.createQueryBuilder(User, "user")
.innerJoinAndSelect("user.devices", "sprinklers_device",
.innerJoinAndSelect(
"user.devices",
"sprinklers_device",
"user.id = :userId AND sprinklers_device.id = :deviceId",
{ userId, deviceId })
{ userId, deviceId }
)
.getOne();
if (!user) {
return null;

7
server/repositories/UserRepository.ts

@ -6,10 +6,11 @@ export interface FindUserOptions { @@ -6,10 +6,11 @@ export interface FindUserOptions {
devices: boolean;
}
function applyDefaultOptions(options?: Partial<FindUserOptions>): FindOneOptions<User> {
function applyDefaultOptions(
options?: Partial<FindUserOptions>
): FindOneOptions<User> {
const opts: FindUserOptions = { devices: false, ...options };
const relations = [opts.devices && "devices"]
.filter(Boolean) as string[];
const relations = [opts.devices && "devices"].filter(Boolean) as string[];
return { relations };
}

2
server/sprinklersRpc/WebSocketApi.ts

@ -18,7 +18,7 @@ export class WebSocketApi { @@ -18,7 +18,7 @@ export class WebSocketApi {
handleConnection = (socket: WebSocket) => {
const client = new WebSocketConnection(this, socket);
this.clients.add(client);
}
};
removeClient(client: WebSocketConnection) {
return this.clients.delete(client);

152
server/sprinklersRpc/WebSocketConnection.ts

@ -45,40 +45,50 @@ export class WebSocketConnection { @@ -45,40 +45,50 @@ export class WebSocketConnection {
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.disposers.forEach(disposer => disposer());
this.deviceSubscriptions.forEach(disposer => disposer());
this.api.removeClient(this);
}
};
subscribeBrokerConnection() {
this.disposers.push(autorun(() => {
this.disposers.push(
autorun(() => {
const updateData: ws.IBrokerConnectionUpdate = {
brokerConnected: this.state.mqttClient.connected,
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);
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);
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 });
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);
throw new RpcError(
"device has no associated device prefix",
ErrorCode.Internal
);
}
return userDevice;
}
@ -89,25 +99,28 @@ export class WebSocketConnection { @@ -89,25 +99,28 @@ export class WebSocketConnection {
sendNotification<Method extends ws.ServerNotificationMethod>(
method: Method,
data: ws.IServerNotificationTypes[Method]) {
data: ws.IServerNotificationTypes[Method]
) {
this.sendMessage({ type: "notification", method, data });
}
sendResponse<Method extends ws.ClientRequestMethods>(
method: Method,
id: number,
data: ws.ServerResponseData<Method>) {
data: ws.ServerResponseData<Method>
) {
this.sendMessage({ type: "response", method, id, ...data });
}
handleSocketMessage = (socketData: WebSocket.Data) => {
this.doHandleSocketMessage(socketData)
.catch((err) => {
this.doHandleSocketMessage(socketData).catch(err => {
this.onError({ err }, "unhandled error on handling socket message");
});
}
};
async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise<deviceRequests.Response> {
async doDeviceCallRequest(
requestData: ws.IDeviceCallRequest
): Promise<deviceRequests.Response> {
const userDevice = this.checkDevice(requestData.deviceId);
const deviceId = userDevice.deviceId!;
const device = this.state.mqttClient.acquireDevice(deviceId);
@ -121,23 +134,32 @@ export class WebSocketConnection { @@ -121,23 +134,32 @@ export class WebSocketConnection {
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);
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);
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);
return this.onError(
{ data },
"received invalid message type from client",
ErrorCode.BadRequest
);
}
}
@ -154,19 +176,28 @@ export class WebSocketConnection { @@ -154,19 +176,28 @@ export class WebSocketConnection {
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");
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(),
},
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) {
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);
@ -174,8 +205,10 @@ export class WebSocketConnection { @@ -174,8 +205,10 @@ export class WebSocketConnection {
}
class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
async authenticate(this: WebSocketConnection, data: ws.IAuthenticateRequest):
Promise<ws.ServerResponseData<"authenticate">> {
async authenticate(
this: WebSocketConnection,
data: ws.IAuthenticateRequest
): Promise<ws.ServerResponseData<"authenticate">> {
if (!data.accessToken) {
throw new RpcError("no token specified", ErrorCode.BadRequest);
}
@ -186,34 +219,51 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers { @@ -186,34 +219,51 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
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;
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");
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() },
data: {
authenticated: true,
message: "authenticated",
user: this.user.toJSON()
}
};
}
async deviceSubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest):
Promise<ws.ServerResponseData<"deviceSubscribe">> {
async deviceSubscribe(
this: WebSocketConnection,
data: ws.IDeviceSubscribeRequest
): Promise<ws.ServerResponseData<"deviceSubscribe">> {
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");
log.debug(
{ deviceId, userId: this.userId },
"websocket client subscribed to device"
);
const autorunDisposer = autorun(() => {
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 });
},
{ delay: 100 }
);
this.deviceSubscriptions.set(deviceId, () => {
autorunDisposer();
@ -223,13 +273,15 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers { @@ -223,13 +273,15 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
}
const response: ws.IDeviceSubscribeResponse = {
deviceId,
deviceId
};
return { result: "success", data: response };
}
async deviceUnsubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest):
Promise<ws.ServerResponseData<"deviceUnsubscribe">> {
async deviceUnsubscribe(
this: WebSocketConnection,
data: ws.IDeviceSubscribeRequest
): Promise<ws.ServerResponseData<"deviceUnsubscribe">> {
this.checkAuthorization();
const userDevice = this.checkDevice(data.deviceId);
const deviceId = userDevice.deviceId!;
@ -240,18 +292,20 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers { @@ -240,18 +292,20 @@ class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
}
const response: ws.IDeviceSubscribeResponse = {
deviceId,
deviceId
};
return { result: "success", data: response };
}
async deviceCall(this: WebSocketConnection, data: ws.IDeviceCallRequest):
Promise<ws.ServerResponseData<"deviceCall">> {
async deviceCall(
this: WebSocketConnection,
data: ws.IDeviceCallRequest
): Promise<ws.ServerResponseData<"deviceCall">> {
this.checkAuthorization();
try {
const response = await this.doDeviceCallRequest(data);
const resData: ws.IDeviceCallResponse = {
data: response,
data: response
};
return { result: "success", data: resData };
} catch (err) {

2
server/state.ts

@ -16,7 +16,7 @@ export class ServerState { @@ -16,7 +16,7 @@ export class ServerState {
}
this.mqttUrl = mqttUrl;
this.mqttClient = new mqtt.MqttRpcClient({
mqttUri: mqttUrl,
mqttUri: mqttUrl
});
this.database = new Database();
}

6
server/tsconfig.json

@ -26,11 +26,9 @@ @@ -26,11 +26,9 @@
]
}
},
"references": [
{
"references": [{
"path": "../common"
}
],
}],
"include": [
"./**/*.ts"
]

1
start-tmux.sh

@ -14,4 +14,3 @@ if [ $? != 0 ]; then @@ -14,4 +14,3 @@ if [ $? != 0 ]; then
fi
tmux attach -t ${SESSION_NAME}

39
tslint.json

@ -1,39 +1,20 @@ @@ -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": []
}

39
yarn.lock

@ -12,6 +12,23 @@ @@ -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: @@ -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: @@ -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: @@ -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: @@ -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:

Loading…
Cancel
Save