Browse Source

Use prettier on everything

update-deps
Alex Mikhalev 6 years ago
parent
commit
e6c3904701
  1. 2
      .drone.yml
  2. 8
      README.md
  3. 2
      build.sh
  4. 40
      client/App.tsx
  5. 4
      client/components/DeviceImage.tsx
  6. 254
      client/components/DeviceView.tsx
  7. 119
      client/components/DurationView.tsx
  8. 70
      client/components/MessagesView.tsx
  9. 53
      client/components/NavBar.tsx
  10. 330
      client/components/ProgramSequenceView.tsx
  11. 260
      client/components/ProgramTable.tsx
  12. 155
      client/components/RunSectionForm.tsx
  13. 142
      client/components/ScheduleView/ScheduleDate.tsx
  14. 60
      client/components/ScheduleView/ScheduleTimes.tsx
  15. 81
      client/components/ScheduleView/TimeInput.tsx
  16. 85
      client/components/ScheduleView/WeekdaysView.tsx
  17. 103
      client/components/ScheduleView/index.tsx
  18. 74
      client/components/SectionChooser.tsx
  19. 261
      client/components/SectionRunnerView.tsx
  20. 86
      client/components/SectionTable.tsx
  21. 10
      client/env.js
  22. 3
      client/index.html
  23. 34
      client/index.tsx
  24. 26
      client/pages/DevicePage.tsx
  25. 40
      client/pages/DevicesPage.tsx
  26. 147
      client/pages/LoginPage.tsx
  27. 14
      client/pages/LogoutPage.tsx
  28. 60
      client/pages/MessageTest.tsx
  29. 429
      client/pages/ProgramPage.tsx
  30. 2
      client/polyfills.js
  31. 17
      client/routePaths.ts
  32. 109
      client/sprinklersRpc/WSSprinklersDevice.ts
  33. 518
      client/sprinklersRpc/WebSocketRpcClient.ts
  34. 129
      client/state/AppState.ts
  35. 207
      client/state/HttpApi.ts
  36. 105
      client/state/Token.ts
  37. 84
      client/state/TokenStore.ts
  38. 42
      client/state/UiStore.ts
  39. 24
      client/state/UserStore.ts
  40. 67
      client/state/reactContext.tsx
  41. 13
      client/styles/DeviceView.scss
  42. 10
      client/styles/DurationView.scss
  43. 2
      client/styles/ProgramSequenceView.scss
  44. 15
      client/styles/ScheduleView.scss
  45. 6
      client/styles/SectionRunnerView.scss
  46. 8
      client/tsconfig.json
  47. 98
      client/webpack.config.js
  48. 44
      common/ApiError.ts
  49. 62
      common/Duration.ts
  50. 72
      common/ErrorCode.ts
  51. 33
      common/TokenClaims.ts
  52. 96
      common/TypedEventEmitter.ts
  53. 34
      common/httpApi/index.ts
  54. 239
      common/jsonRpc/index.ts
  55. 166
      common/logger.ts
  56. 126
      common/sprinklersRpc/ConnectionState.ts
  57. 94
      common/sprinklersRpc/Program.ts
  58. 30
      common/sprinklersRpc/RpcError.ts
  59. 38
      common/sprinklersRpc/Section.ts
  60. 98
      common/sprinklersRpc/SectionRunner.ts
  61. 169
      common/sprinklersRpc/SprinklersDevice.ts
  62. 44
      common/sprinklersRpc/SprinklersRPC.ts
  63. 61
      common/sprinklersRpc/deviceRequests.ts
  64. 18
      common/sprinklersRpc/mqtt/MqttProgram.ts
  65. 18
      common/sprinklersRpc/mqtt/MqttSection.ts
  66. 12
      common/sprinklersRpc/mqtt/MqttSectionRunner.ts
  67. 549
      common/sprinklersRpc/mqtt/index.ts
  68. 201
      common/sprinklersRpc/schedule.ts
  69. 54
      common/sprinklersRpc/schema/common.ts
  70. 124
      common/sprinklersRpc/schema/index.ts
  71. 108
      common/sprinklersRpc/schema/list.ts
  72. 104
      common/sprinklersRpc/schema/requests.ts
  73. 78
      common/sprinklersRpc/websocketData.ts
  74. 40
      common/utils.ts
  75. 2
      docker-compose.dev.yml
  76. 2
      package.json
  77. 2
      paths.js
  78. 140
      server/Database.ts
  79. 165
      server/authentication.ts
  80. 4
      server/configureLogger.ts
  81. 48
      server/entities/SprinklersDevice.ts
  82. 58
      server/entities/User.ts
  83. 6
      server/env.ts
  84. 113
      server/express/api/devices.ts
  85. 18
      server/express/api/index.ts
  86. 87
      server/express/api/mosquitto.ts
  87. 128
      server/express/api/token.ts
  88. 62
      server/express/api/users.ts
  89. 20
      server/express/errorHandler.ts
  90. 16
      server/express/index.ts
  91. 8
      server/express/serveApp.ts
  92. 56
      server/express/verifyAuthorization.ts
  93. 21
      server/index.ts
  94. 202
      server/logging/prettyPrint.ts
  95. 55
      server/repositories/SprinklersDeviceRepository.ts
  96. 37
      server/repositories/UserRepository.ts
  97. 30
      server/sprinklersRpc/WebSocketApi.ts
  98. 502
      server/sprinklersRpc/WebSocketConnection.ts
  99. 42
      server/state.ts
  100. 8
      server/tsconfig.json
  101. Some files were not shown because too many files have changed in this diff Show More

2
.drone.yml

@ -4,4 +4,4 @@ pipeline:
commands: commands:
- apk add yarn python make g++ - apk add yarn python make g++
- yarn install --frozen-lockfile - yarn install --frozen-lockfile
- yarn build - yarn build

8
README.md

@ -5,18 +5,16 @@
### Docker ### Docker
```shell ```shell
# for production build (http://localhost:8080) # for production build (http://localhost:8080)
docker-compose up docker-compose up
# for development with hot-reload (http://localhost:8081) # for development with hot-reload (http://localhost:8081)
docker-compose -f docker-compose.dev.yml up docker-compose -f docker-compose.dev.yml up
``` ```
### Not docker ### Not docker
```shell ```shell
yarn install yarn install
# for production build (http://localhost:8080) # for production build (http://localhost:8080)
@ -25,6 +23,4 @@ yarn start:pretty
# for development build (http://localhost:8081) # for development build (http://localhost:8081)
yarn start:dev yarn start:dev
```
```

2
build.sh

@ -23,4 +23,4 @@ docker container cp "$EXTRACT_CONTAINER:/app/public" "$BUILD_DIR/public"
docker container rm -f "$EXTRACT_CONTAINER" docker container rm -f "$EXTRACT_CONTAINER"
echo "Building dist image $DIST_IMAGE" echo "Building dist image $DIST_IMAGE"
docker build -t "$DIST_IMAGE" . docker build -t "$DIST_IMAGE" .

40
client/App.tsx

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

4
client/components/DeviceImage.tsx

@ -2,5 +2,7 @@ import * as React from "react";
import { Item, ItemImageProps } from "semantic-ui-react"; import { Item, ItemImageProps } from "semantic-ui-react";
export default function DeviceImage(props: ItemImageProps) { 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")} />
);
} }

254
client/components/DeviceView.tsx

@ -9,146 +9,182 @@ import * as p from "@client/pages";
import * as route from "@client/routePaths"; import * as route from "@client/routePaths";
import { AppState, injectState } from "@client/state"; import { AppState, injectState } from "@client/state";
import { ISprinklersDevice } from "@common/httpApi"; 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 { Route, RouteComponentProps, withRouter } from "react-router";
import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from "."; import {
ProgramTable,
RunSectionForm,
SectionRunnerView,
SectionTable
} from ".";
import "@client/styles/DeviceView"; import "@client/styles/DeviceView";
const ConnectionState = observer(({ connectionState, className }: const ConnectionState = observer(
{ connectionState: ConState, className?: string }) => { ({
connectionState,
className
}: {
connectionState: ConState;
className?: string;
}) => {
const connected = connectionState.isDeviceConnected; const connected = connectionState.isDeviceConnected;
let connectionText: string; let connectionText: string;
let iconName: SemanticICONS = "unlinkify"; let iconName: SemanticICONS = "unlinkify";
let clazzName: string = "disconnected"; let clazzName: string = "disconnected";
if (connected) { if (connected) {
connectionText = "Connected"; connectionText = "Connected";
iconName = "linkify"; iconName = "linkify";
clazzName = "connected"; clazzName = "connected";
} else if (connectionState.noPermission) { } else if (connectionState.noPermission) {
connectionText = "No permission for this device"; connectionText = "No permission for this device";
iconName = "ban"; iconName = "ban";
} else if (connected === false) { } else if (connected === false) {
connectionText = "Device Disconnected"; connectionText = "Device Disconnected";
} else if (connectionState.clientToServer === false) { } else if (connectionState.clientToServer === false) {
connectionText = "Disconnected from server"; connectionText = "Disconnected from server";
} else { } else {
connectionText = "Unknown"; connectionText = "Unknown";
iconName = "question"; iconName = "question";
clazzName = "unknown"; clazzName = "unknown";
} }
const classes = classNames("connectionState", clazzName, className); const classes = classNames("connectionState", clazzName, className);
return ( return (
<div className={classes}> <div className={classes}>
<Icon name={iconName} />&nbsp; <Icon name={iconName} />
{connectionText} &nbsp;
</div> {connectionText}
</div>
); );
}); }
);
interface DeviceViewProps { interface DeviceViewProps {
deviceId: number; deviceId: number;
appState: AppState; appState: AppState;
inList?: boolean; inList?: boolean;
} }
class DeviceView extends React.Component<DeviceViewProps> { class DeviceView extends React.Component<DeviceViewProps> {
deviceInfo: ISprinklersDevice | null = null; deviceInfo: ISprinklersDevice | null = null;
device: SprinklersDevice | null = null; device: SprinklersDevice | null = null;
componentWillUnmount() { componentWillUnmount() {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
}
} }
}
renderBody() { renderBody() {
const { inList, appState: { uiStore, routerStore } } = this.props; const {
if (!this.deviceInfo || !this.device) { inList,
return null; appState: { uiStore, routerStore }
} } = this.props;
const { connectionState, sectionRunner, sections } = this.device; if (!this.deviceInfo || !this.device) {
if (!connectionState.isAvailable || inList) { return null;
return null; }
} const { connectionState, sectionRunner, sections } = this.device;
return ( if (!connectionState.isAvailable || inList) {
<React.Fragment> return null;
<Grid>
<Grid.Column mobile="16" tablet="16" computer="16" largeScreen="6">
<SectionRunnerView sectionRunner={sectionRunner} sections={sections} />
</Grid.Column>
<Grid.Column mobile="16" tablet="9" computer="9" largeScreen="6">
<SectionTable sections={sections} />
</Grid.Column>
<Grid.Column mobile="16" tablet="7" computer="7" largeScreen="4">
<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} />
</React.Fragment>
);
} }
return (
<React.Fragment>
<Grid>
<Grid.Column mobile="16" tablet="16" computer="16" largeScreen="6">
<SectionRunnerView
sectionRunner={sectionRunner}
sections={sections}
/>
</Grid.Column>
<Grid.Column mobile="16" tablet="9" computer="9" largeScreen="6">
<SectionTable sections={sections} />
</Grid.Column>
<Grid.Column mobile="16" tablet="7" computer="7" largeScreen="4">
<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}
/>
</React.Fragment>
);
}
updateDevice() { updateDevice() {
const { userStore, sprinklersRpc } = this.props.appState; const { userStore, sprinklersRpc } = this.props.appState;
const id = this.props.deviceId; const id = this.props.deviceId;
// tslint:disable-next-line:prefer-conditional-expression // tslint:disable-next-line:prefer-conditional-expression
if (this.deviceInfo == null || this.deviceInfo.id !== id) { if (this.deviceInfo == null || this.deviceInfo.id !== id) {
this.deviceInfo = userStore.findDevice(id); this.deviceInfo = userStore.findDevice(id);
} }
if (!this.deviceInfo || !this.deviceInfo.deviceId) { if (!this.deviceInfo || !this.deviceInfo.deviceId) {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
this.device = null; this.device = null;
} }
} else { } else {
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
}
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
}
} }
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
}
} }
}
render() { render() {
this.updateDevice(); this.updateDevice();
const { inList } = this.props; const { inList } = this.props;
let itemContent: React.ReactNode; let itemContent: React.ReactNode;
if (!this.deviceInfo || !this.device) { if (!this.deviceInfo || !this.device) {
// TODO: better and link back to devices list // TODO: better and link back to devices list
itemContent = <span>You do not have access to this device</span>; itemContent = <span>You do not have access to this device</span>;
} else { } else {
const { connectionState } = this.device; const { connectionState } = this.device;
let header: React.ReactNode; let header: React.ReactNode;
let image: React.ReactNode; let image: React.ReactNode;
if (inList) { // tslint:disable-line:prefer-conditional-expression if (inList) {
const devicePath = route.device(this.deviceInfo.id); // tslint:disable-line:prefer-conditional-expression
header = <Link to={devicePath}>Device <kbd>{this.deviceInfo.name}</kbd></Link>; const devicePath = route.device(this.deviceInfo.id);
image = <DeviceImage size="tiny" as={Link} to={devicePath} />; header = (
} else { <Link to={devicePath}>
header = <span>Device <kbd>{this.deviceInfo.name}</kbd></span>; Device <kbd>{this.deviceInfo.name}</kbd>
image = <DeviceImage />; </Link>
} );
itemContent = ( image = <DeviceImage size="tiny" as={Link} to={devicePath} />;
<React.Fragment> } else {
{image} header = (
<Item.Content className="device"> <span>
<Header as={inList ? "h2" : "h1"}> Device <kbd>{this.deviceInfo.name}</kbd>
{header} </span>
<ConnectionState connectionState={connectionState} /> );
</Header> image = <DeviceImage />;
<Item.Meta> }
Raspberry Pi Grinklers Device itemContent = (
</Item.Meta> <React.Fragment>
{this.renderBody()} {image}
</Item.Content> <Item.Content className="device">
</React.Fragment> <Header as={inList ? "h2" : "h1"}>
); {header}
} <ConnectionState connectionState={connectionState} />
return <Item>{itemContent}</Item>; </Header>
<Item.Meta>Raspberry Pi Grinklers Device</Item.Meta>
{this.renderBody()}
</Item.Content>
</React.Fragment>
);
} }
return <Item>{itemContent}</Item>;
}
} }
export default injectState(observer(DeviceView)); export default injectState(observer(DeviceView));

119
client/components/DurationView.tsx

@ -7,70 +7,71 @@ import { Duration } from "@common/Duration";
import "@client/styles/DurationView"; import "@client/styles/DurationView";
export default class DurationView extends React.Component<{ export default class DurationView extends React.Component<{
label?: string, label?: string;
inline?: boolean, inline?: boolean;
duration: Duration, duration: Duration;
onDurationChange?: (newDuration: Duration) => void, onDurationChange?: (newDuration: Duration) => void;
className?: string, className?: string;
}> { }> {
render() { render() {
const { duration, label, inline, onDurationChange, className } = this.props; const { duration, label, inline, onDurationChange, className } = this.props;
const inputsClassName = classNames("durationInputs", { inline }); const inputsClassName = classNames("durationInputs", { inline });
if (onDurationChange) { if (onDurationChange) {
return ( return (
<React.Fragment> <React.Fragment>
<Form.Field inline={inline} className={className}> <Form.Field inline={inline} className={className}>
{label && <label>{label}</label>} {label && <label>{label}</label>}
<div className={inputsClassName}> <div className={inputsClassName}>
<Input <Input
type="number" type="number"
className="durationInput minutes" className="durationInput minutes"
value={duration.minutes} value={duration.minutes}
onChange={this.onMinutesChange} onChange={this.onMinutesChange}
label="M" label="M"
labelPosition="right" labelPosition="right"
onWheel={this.onWheel} onWheel={this.onWheel}
/> />
<Input <Input
type="number" type="number"
className="durationInput seconds" className="durationInput seconds"
value={duration.seconds} value={duration.seconds}
onChange={this.onSecondsChange} onChange={this.onSecondsChange}
max="60" max="60"
label="S" label="S"
labelPosition="right" labelPosition="right"
onWheel={this.onWheel} onWheel={this.onWheel}
/> />
</div> </div>
</Form.Field> </Form.Field>
</React.Fragment> </React.Fragment>
); );
} else { } else {
return ( return (
<span className={className}> <span className={className}>
{label && <label>{label}</label>} {duration.minutes}M {duration.seconds}S {label && <label>{label}</label>} {duration.minutes}M{" "}
</span> {duration.seconds}S
); </span>
} );
} }
}
private onMinutesChange: InputProps["onChange"] = (e, { value }) => { private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) { if (!this.props.onDurationChange || isNaN(Number(value))) {
return; return;
}
const newMinutes = Number(value);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
} }
const newMinutes = Number(value);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
};
private onSecondsChange: InputProps["onChange"] = (e, { value }) => { private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) { if (!this.props.onDurationChange || isNaN(Number(value))) {
return; return;
}
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
} }
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
};
private onWheel = () => { private onWheel = () => {
// do nothing // do nothing
} };
} }

70
client/components/MessagesView.tsx

@ -9,45 +9,49 @@ import "@client/styles/MessagesView";
@observer @observer
class MessageView extends React.Component<{ class MessageView extends React.Component<{
uiStore: UiStore, uiStore: UiStore;
message: UiMessage, message: UiMessage;
className?: string, className?: string;
}> { }> {
render() {
const { id, ...messageProps } = this.props.message;
const className = classNames(messageProps.className, this.props.className);
return (
<Message
{...messageProps}
className={className}
onDismiss={this.dismiss}
/>
);
}
render() { private dismiss: MessageProps["onDismiss"] = (event, data) => {
const { id, ...messageProps } = this.props.message; const { uiStore, message } = this.props;
const className = classNames(messageProps.className, this.props.className); if (message.onDismiss) {
return ( message.onDismiss(event, data);
<Message
{...messageProps}
className={className}
onDismiss={this.dismiss}
/>
);
}
private dismiss: MessageProps["onDismiss"] = (event, data) => {
const { uiStore, message } = this.props;
if (message.onDismiss) {
message.onDismiss(event, data);
}
uiStore.messages.remove(message);
} }
uiStore.messages.remove(message);
};
} }
class MessagesView extends React.Component<{ appState: AppState }> { class MessagesView extends React.Component<{ appState: AppState }> {
render() { render() {
const { uiStore } = this.props.appState; 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} /> <MessageView key={message.id} uiStore={uiStore} message={message} />
)); ));
messages.reverse(); messages.reverse();
return ( return (
<TransitionGroup as={Message.List} className="messages" animation="scale" duration={200}> <TransitionGroup
{messages} as={Message.List}
</TransitionGroup> className="messages"
); animation="scale"
} duration={200}
>
{messages}
</TransitionGroup>
);
}
} }
export default injectState(observer(MessagesView)); export default injectState(observer(MessagesView));

53
client/components/NavBar.tsx

@ -7,41 +7,38 @@ import * as route from "@client/routePaths";
import { AppState, ConsumeState, injectState } from "@client/state"; import { AppState, ConsumeState, injectState } from "@client/state";
interface NavItemProps { interface NavItemProps {
to: string; to: string;
children: React.ReactNode; children: React.ReactNode;
} }
const NavItem = observer(({ to, children }: NavItemProps) => { const NavItem = observer(({ to, children }: NavItemProps) => {
function consumeState(appState: AppState) { function consumeState(appState: AppState) {
const { location } = appState.routerStore; const { location } = appState.routerStore;
return ( 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 }) { function NavBar({ appState }: { appState: AppState }) {
let loginMenu; let loginMenu;
if (appState.isLoggedIn) { // tslint:disable-next-line:prefer-conditional-expression
loginMenu = ( if (appState.isLoggedIn) {
<NavItem to={route.logout}>Logout</NavItem> loginMenu = <NavItem to={route.logout}>Logout</NavItem>;
); } else {
} else { loginMenu = <NavItem to={route.login}>Login</NavItem>;
loginMenu = ( }
<NavItem to={route.login}>Login</NavItem> return (
); <Menu>
} <NavItem to={route.device()}>Devices</NavItem>
return ( <NavItem to={route.messagesTest}>Messages test</NavItem>
<Menu> <Menu.Menu position="right">{loginMenu}</Menu.Menu>
<NavItem to={route.device()}>Devices</NavItem> </Menu>
<NavItem to={route.messagesTest}>Messages test</NavItem> );
<Menu.Menu position="right">
{loginMenu}
</Menu.Menu>
</Menu>
);
} }
export default observer(injectState(NavBar)); export default observer(injectState(NavBar));

330
client/components/ProgramSequenceView.tsx

@ -1,7 +1,12 @@
import classNames = require("classnames"); import classNames = require("classnames");
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "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 { Button, Form, Icon, List } from "semantic-ui-react";
import { DurationView, SectionChooser } from "@client/components/index"; import { DurationView, SectionChooser } from "@client/components/index";
@ -14,177 +19,196 @@ import { action } from "mobx";
type ItemChangeHandler = (index: number, newItem: ProgramItem) => void; type ItemChangeHandler = (index: number, newItem: ProgramItem) => void;
type ItemRemoveHandler = (index: number) => 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 @observer
class ProgramSequenceItem extends React.Component<{ class ProgramSequenceItem extends React.Component<{
sequenceItem: ProgramItem, sequenceItem: ProgramItem;
idx: number, idx: number;
sections: Section[], sections: Section[];
editing: boolean, editing: boolean;
onChange: ItemChangeHandler, onChange: ItemChangeHandler;
onRemove: ItemRemoveHandler, onRemove: ItemRemoveHandler;
}> { }> {
renderContent() { renderContent() {
const { editing, sequenceItem, sections } = this.props; const { editing, sequenceItem, sections } = this.props;
const section = sections[sequenceItem.section]; const section = sections[sequenceItem.section];
const duration = Duration.fromSeconds(sequenceItem.duration); const duration = Duration.fromSeconds(sequenceItem.duration);
if (editing) { if (editing) {
return ( return (
<Form.Group> <Form.Group>
<Button icon negative onClick={this.onRemove}> <Button icon negative onClick={this.onRemove}>
<Icon name="cancel" /> <Icon name="cancel" />
</Button> </Button>
<SectionChooser <SectionChooser
label="Section" label="Section"
sections={sections} sections={sections}
sectionId={section.id} sectionId={section.id}
onChange={this.onSectionChange} onChange={this.onSectionChange}
/> />
<DurationView <DurationView
label="Duration" label="Duration"
duration={duration} duration={duration}
onDurationChange={this.onDurationChange} onDurationChange={this.onDurationChange}
/> />
</Form.Group> </Form.Group>
); );
} else { } else {
return ( return (
<React.Fragment> <React.Fragment>
<List.Header>{section.toString()}</List.Header> <List.Header>{section.toString()}</List.Header>
<List.Description>for {duration.toString()}</List.Description> <List.Description>for {duration.toString()}</List.Description>
</React.Fragment> </React.Fragment>
); );
}
}
render() {
const { editing } = this.props;
return (
<li className="programSequence-item ui form">
{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,
}));
}
private onDurationChange = (newDuration: Duration) => {
this.props.onChange(this.props.idx, new ProgramItem({
...this.props.sequenceItem, duration: newDuration.toSeconds(),
}));
}
private onRemove = () => {
this.props.onRemove(this.props.idx);
} }
}
render() {
const { editing } = this.props;
return (
<li className="programSequence-item ui form">
{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
})
);
};
private onDurationChange = (newDuration: Duration) => {
this.props.onChange(
this.props.idx,
new ProgramItem({
...this.props.sequenceItem,
duration: newDuration.toSeconds()
})
);
};
private onRemove = () => {
this.props.onRemove(this.props.idx);
};
} }
const ProgramSequenceItemD = SortableElement(ProgramSequenceItem); const ProgramSequenceItemD = SortableElement(ProgramSequenceItem);
const ProgramSequenceList = SortableContainer(observer((props: { const ProgramSequenceList = SortableContainer(
className: string, observer(
list: ProgramItem[], (props: {
sections: Section[], className: string;
editing: boolean, list: ProgramItem[];
onChange: ItemChangeHandler, sections: Section[];
onRemove: ItemRemoveHandler, editing: boolean;
}) => { onChange: ItemChangeHandler;
const { className, list, sections, ...rest } = props; onRemove: ItemRemoveHandler;
const listItems = list.map((item, index) => { }) => {
const { className, list, sections, ...rest } = props;
const listItems = list.map((item, index) => {
const key = `item-${index}`; const key = `item-${index}`;
return ( return (
<ProgramSequenceItemD <ProgramSequenceItemD
{...rest} {...rest}
key={key} key={key}
sequenceItem={item} sequenceItem={item}
index={index} index={index}
idx={index} idx={index}
sections={sections} sections={sections}
/> />
); );
}); });
return <ul className={className}>{listItems}</ul>; return <ul className={className}>{listItems}</ul>;
}), { withRef: true }); }
),
{ withRef: true }
);
@observer @observer
class ProgramSequenceView extends React.Component<{ class ProgramSequenceView extends React.Component<{
sequence: ProgramItem[], sections: Section[], editing?: boolean, sequence: ProgramItem[];
sections: Section[];
editing?: boolean;
}> { }> {
render() { render() {
const { sequence, sections } = this.props; const { sequence, sections } = this.props;
const editing = this.props.editing || false; const editing = this.props.editing || false;
const className = classNames("programSequence", { editing }); const className = classNames("programSequence", { editing });
let addButton: React.ReactNode = null; let addButton: React.ReactNode = null;
if (editing) { if (editing) {
addButton = ( addButton = (
<Button onClick={this.addItem}> <Button onClick={this.addItem}>
<Icon name="add"/> <Icon name="add" />
Add item Add item
</Button> </Button>
); );
}
return (
<div>
<ProgramSequenceList
className={className}
useDragHandle
helperClass="dragging"
list={sequence}
sections={sections}
editing={editing}
onChange={this.changeItem}
onRemove={this.removeItem}
onSortEnd={this.onSortEnd}
/>
{addButton}
</div>
);
}
@action.bound
private changeItem: ItemChangeHandler = (index, newItem) => {
this.props.sequence[index] = newItem;
} }
return (
@action.bound <div>
private removeItem: ItemRemoveHandler = (index) => { <ProgramSequenceList
this.props.sequence.splice(index, 1); className={className}
} useDragHandle
helperClass="dragging"
@action.bound list={sequence}
private addItem() { sections={sections}
let sectionId = 0; editing={editing}
for (const section of this.props.sections) { onChange={this.changeItem}
const sectionNotIncluded = this.props.sequence onRemove={this.removeItem}
.every((sequenceItem) => onSortEnd={this.onSortEnd}
sequenceItem.section !== section.id); />
if (sectionNotIncluded) { {addButton}
sectionId = section.id; </div>
break; );
} }
}
const item = new ProgramItem({ @action.bound
section: sectionId, private changeItem: ItemChangeHandler = (index, newItem) => {
duration: new Duration(5, 0).toSeconds(), this.props.sequence[index] = newItem;
}); };
this.props.sequence.push(item);
@action.bound
private removeItem: ItemRemoveHandler = index => {
this.props.sequence.splice(index, 1);
};
@action.bound
private addItem() {
let sectionId = 0;
for (const section of this.props.sections) {
const sectionNotIncluded = this.props.sequence.every(
sequenceItem => sequenceItem.section !== section.id
);
if (sectionNotIncluded) {
sectionId = section.id;
break;
}
} }
const item = new ProgramItem({
@action.bound section: sectionId,
private onSortEnd({oldIndex, newIndex}: SortEnd) { duration: new Duration(5, 0).toSeconds()
const { sequence: array } = this.props; });
if (newIndex >= array.length) { this.props.sequence.push(item);
return; }
}
array.splice(newIndex, 0, array.splice(oldIndex, 1)[0]); @action.bound
private onSortEnd({ oldIndex, newIndex }: SortEnd) {
const { sequence: array } = this.props;
if (newIndex >= array.length) {
return;
} }
array.splice(newIndex, 0, array.splice(oldIndex, 1)[0]);
}
} }
const ProgramSequenceViewD = SortableContainer(ProgramSequenceView); const ProgramSequenceViewD = SortableContainer(ProgramSequenceView);

260
client/components/ProgramTable.tsx

@ -11,143 +11,161 @@ import { Program, SprinklersDevice } from "@common/sprinklersRpc";
@observer @observer
class ProgramRows extends React.Component<{ class ProgramRows extends React.Component<{
program: Program, program: Program;
iDevice: ISprinklersDevice, iDevice: ISprinklersDevice;
device: SprinklersDevice, device: SprinklersDevice;
routerStore: RouterStore, routerStore: RouterStore;
expanded: boolean, toggleExpanded: (program: Program) => void, expanded: boolean;
toggleExpanded: (program: Program) => void;
}> { }> {
render() { render() {
const { program, iDevice, device, expanded } = this.props; const { program, iDevice, device, expanded } = this.props;
const { sections } = device; const { sections } = device;
const { name, running, enabled, schedule, sequence } = program; const { name, running, enabled, schedule, sequence } = program;
const buttonStyle: ButtonProps = { size: "small", compact: false }; const buttonStyle: ButtonProps = { size: "small", compact: false };
const detailUrl = route.program(iDevice.id, program.id); const detailUrl = route.program(iDevice.id, program.id);
const stopStartButton = ( const stopStartButton = (
<Button onClick={this.cancelOrRun} {...buttonStyle} positive={!running} negative={running}> <Button
<Icon name={running ? "stop" : "play"} /> onClick={this.cancelOrRun}
{running ? "Stop" : "Run"} {...buttonStyle}
</Button> positive={!running}
); negative={running}
>
<Icon name={running ? "stop" : "play"} />
{running ? "Stop" : "Run"}
</Button>
);
const mainRow = ( const mainRow = (
<Table.Row> <Table.Row>
<Table.Cell className="program--number">{"" + program.id}</Table.Cell> <Table.Cell className="program--number">{"" + program.id}</Table.Cell>
<Table.Cell className="program--name">{name}</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">
<Table.Cell className="program--running"> {enabled ? "Enabled" : "Not enabled"}
<span>{running ? "Running" : "Not running"}</span> </Table.Cell>
</Table.Cell> <Table.Cell className="program--running">
<Table.Cell> <span>{running ? "Running" : "Not running"}</span>
{stopStartButton} </Table.Cell>
<Button as={Link} to={detailUrl} {...buttonStyle} primary> <Table.Cell>
<Icon name="edit" /> {stopStartButton}
Open <Button as={Link} to={detailUrl} {...buttonStyle} primary>
</Button> <Icon name="edit" />
<Button onClick={this.toggleExpanded} {...buttonStyle}> Open
<Icon name="list" /> </Button>
{expanded ? "Hide Details" : "Show Details"} <Button onClick={this.toggleExpanded} {...buttonStyle}>
</Button> <Icon name="list" />
</Table.Cell> {expanded ? "Hide Details" : "Show Details"}
</Table.Row> </Button>
); </Table.Cell>
const detailRow = expanded && ( </Table.Row>
<Table.Row> );
<Table.Cell className="program--sequence" colSpan="5"> const detailRow = expanded && (
<Form> <Table.Row>
<h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={sections} /> <Table.Cell className="program--sequence" colSpan="5">
<ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} /> <Form>
</Form> <h4>Sequence: </h4>{" "}
</Table.Cell> <ProgramSequenceView sequence={sequence} sections={sections} />
</Table.Row> <ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} />
); </Form>
return ( </Table.Cell>
<React.Fragment> </Table.Row>
{mainRow} );
{detailRow} return (
</React.Fragment> <React.Fragment>
); {mainRow}
} {detailRow}
</React.Fragment>
);
}
private cancelOrRun = () => { private cancelOrRun = () => {
const { program } = this.props; const { program } = this.props;
program.running ? program.cancel() : program.run(); program.running ? program.cancel() : program.run();
} };
private toggleExpanded = () => { private toggleExpanded = () => {
this.props.toggleExpanded(this.props.program); this.props.toggleExpanded(this.props.program);
} };
} }
type ProgramId = Program["id"]; type ProgramId = Program["id"];
@observer @observer
export default class ProgramTable extends React.Component<{ export default class ProgramTable extends React.Component<
iDevice: ISprinklersDevice, device: SprinklersDevice, routerStore: RouterStore, {
}, { iDevice: ISprinklersDevice;
expandedPrograms: ProgramId[], device: SprinklersDevice;
}> { routerStore: RouterStore;
constructor(p: any) { },
super(p); {
this.state = { expandedPrograms: [] }; expandedPrograms: ProgramId[];
} }
> {
constructor(p: any) {
super(p);
this.state = { expandedPrograms: [] };
}
render() { render() {
const { programs } = this.props.device; const { programs } = this.props.device;
const programRows = programs.map(this.renderRows); const programRows = programs.map(this.renderRows);
return ( return (
<Table celled> <Table celled>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan="7">Programs</Table.HeaderCell> <Table.HeaderCell colSpan="7">Programs</Table.HeaderCell>
</Table.Row> </Table.Row>
<Table.Row> <Table.Row>
<Table.HeaderCell className="program--number">#</Table.HeaderCell> <Table.HeaderCell className="program--number">#</Table.HeaderCell>
<Table.HeaderCell className="program--name">Name</Table.HeaderCell> <Table.HeaderCell className="program--name">Name</Table.HeaderCell>
<Table.HeaderCell className="program--enabled">Enabled?</Table.HeaderCell> <Table.HeaderCell className="program--enabled">
<Table.HeaderCell className="program--running">Running?</Table.HeaderCell> Enabled?
<Table.HeaderCell className="program--actions">Actions</Table.HeaderCell> </Table.HeaderCell>
</Table.Row> <Table.HeaderCell className="program--running">
</Table.Header> Running?
<Table.Body> </Table.HeaderCell>
{programRows} <Table.HeaderCell className="program--actions">
</Table.Body> Actions
</Table> </Table.HeaderCell>
); </Table.Row>
} </Table.Header>
<Table.Body>{programRows}</Table.Body>
</Table>
);
}
private renderRows = (program: Program, i: number): JSX.Element | null => { private renderRows = (program: Program, i: number): JSX.Element | null => {
if (!program) { if (!program) {
return null; return null;
}
const expanded = this.state.expandedPrograms.indexOf(program.id) !== -1;
return (
<ProgramRows
program={program}
iDevice={this.props.iDevice}
device={this.props.device}
routerStore={this.props.routerStore}
expanded={expanded}
toggleExpanded={this.toggleExpanded}
key={i}
/>
);
} }
const expanded = this.state.expandedPrograms.indexOf(program.id) !== -1;
return (
<ProgramRows
program={program}
iDevice={this.props.iDevice}
device={this.props.device}
routerStore={this.props.routerStore}
expanded={expanded}
toggleExpanded={this.toggleExpanded}
key={i}
/>
);
};
private toggleExpanded = (program: Program) => { private toggleExpanded = (program: Program) => {
const { expandedPrograms } = this.state; const { expandedPrograms } = this.state;
const idx = expandedPrograms.indexOf(program.id); const idx = expandedPrograms.indexOf(program.id);
if (idx !== -1) { if (idx !== -1) {
expandedPrograms.splice(idx, 1); expandedPrograms.splice(idx, 1);
} else { } else {
expandedPrograms.push(program.id); expandedPrograms.push(program.id);
}
this.setState({
expandedPrograms,
});
} }
this.setState({
expandedPrograms
});
};
} }

155
client/components/RunSectionForm.tsx

@ -10,88 +10,91 @@ import { Section, SprinklersDevice } from "@common/sprinklersRpc";
import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests"; import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests";
@observer @observer
export default class RunSectionForm extends React.Component<{ export default class RunSectionForm extends React.Component<
device: SprinklersDevice, {
uiStore: UiStore, device: SprinklersDevice;
}, { uiStore: UiStore;
duration: Duration, },
sectionId: number | undefined, {
}> { duration: Duration;
constructor(props: any, context?: any) { sectionId: number | undefined;
super(props, context); }
this.state = { > {
duration: new Duration(0, 0), constructor(props: any, context?: any) {
sectionId: undefined, super(props, context);
}; this.state = {
} duration: new Duration(0, 0),
sectionId: undefined
};
}
render() { render() {
const { sectionId, duration } = this.state; const { sectionId, duration } = this.state;
return ( return (
<Segment> <Segment>
<Header>Run Section</Header> <Header>Run Section</Header>
<Form> <Form>
<SectionChooser <SectionChooser
label="Section" label="Section"
sections={this.props.device.sections} sections={this.props.device.sections}
sectionId={sectionId} sectionId={sectionId}
onChange={this.onSectionChange} onChange={this.onSectionChange}
/> />
<DurationView <DurationView
label="Duration" label="Duration"
duration={duration} duration={duration}
onDurationChange={this.onDurationChange} onDurationChange={this.onDurationChange}
/> />
<Form.Button <Form.Button primary onClick={this.run} disabled={!this.isValid}>
primary <Icon name="play" />
onClick={this.run} Run
disabled={!this.isValid} </Form.Button>
> </Form>
<Icon name="play"/> </Segment>
Run );
</Form.Button> }
</Form>
</Segment>
);
}
private onSectionChange = (newSectionId: number) => { private onSectionChange = (newSectionId: number) => {
this.setState({ sectionId: newSectionId }); this.setState({ sectionId: newSectionId });
} };
private onDurationChange = (newDuration: Duration) => { private onDurationChange = (newDuration: Duration) => {
this.setState({ duration: newDuration }); this.setState({ duration: newDuration });
} };
private run = (e: React.SyntheticEvent<HTMLElement>) => { private run = (e: React.SyntheticEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
const { sectionId, duration } = this.state; const { sectionId, duration } = this.state;
if (sectionId == null) { if (sectionId == null) {
return; return;
}
const section = this.props.device.sections[sectionId];
section.run(duration.toSeconds())
.then(this.onRunSuccess)
.catch(this.onRunError);
} }
const section = this.props.device.sections[sectionId];
section
.run(duration.toSeconds())
.then(this.onRunSuccess)
.catch(this.onRunError);
};
private onRunSuccess = (result: RunSectionResponse) => { private onRunSuccess = (result: RunSectionResponse) => {
log.debug({ result }, "requested section run"); log.debug({ result }, "requested section run");
this.props.uiStore.addMessage({ this.props.uiStore.addMessage({
success: true, header: "Section running", success: true,
content: result.message, timeout: 2000, header: "Section running",
}); content: result.message,
} timeout: 2000
});
};
private onRunError = (err: RunSectionResponse) => { private onRunError = (err: RunSectionResponse) => {
log.error(err, "error running section"); log.error(err, "error running section");
this.props.uiStore.addMessage({ this.props.uiStore.addMessage({
error: true, header: "Error running section", error: true,
content: err.message, header: "Error running section",
}); content: err.message
} });
};
private get isValid(): boolean { private get isValid(): boolean {
return this.state.sectionId != null && this.state.duration.toSeconds() > 0; return this.state.sectionId != null && this.state.duration.toSeconds() > 0;
} }
} }

142
client/components/ScheduleView/ScheduleDate.tsx

@ -7,75 +7,101 @@ import { DateOfYear } from "@common/sprinklersRpc";
const HTML_DATE_INPUT_FORMAT = "YYYY-MM-DD"; const HTML_DATE_INPUT_FORMAT = "YYYY-MM-DD";
export interface ScheduleDateProps { export interface ScheduleDateProps {
date: DateOfYear | null | undefined; date: DateOfYear | null | undefined;
label: string | React.ReactNode | undefined; label: string | React.ReactNode | undefined;
editing: boolean | undefined; editing: boolean | undefined;
onChange: (newDate: DateOfYear | null) => void; onChange: (newDate: DateOfYear | null) => void;
} }
interface ScheduleDateState { interface ScheduleDateState {
rawValue: string | ""; rawValue: string | "";
lastDate: DateOfYear | null | undefined; lastDate: DateOfYear | null | undefined;
} }
export default class ScheduleDate extends React.Component<ScheduleDateProps, ScheduleDateState> { export default class ScheduleDate extends React.Component<
static getDerivedStateFromProps(props: ScheduleDateProps, state: ScheduleDateState): Partial<ScheduleDateState> { ScheduleDateProps,
if (!DateOfYear.equals(props.date, state.lastDate)) { ScheduleDateState
const thisYear = moment().year(); > {
const rawValue = props.date == null ? "" : static getDerivedStateFromProps(
moment(props.date).year(thisYear).format(HTML_DATE_INPUT_FORMAT); props: ScheduleDateProps,
return { lastDate: props.date, rawValue }; state: ScheduleDateState
} ): Partial<ScheduleDateState> {
return {}; if (!DateOfYear.equals(props.date, state.lastDate)) {
const thisYear = moment().year();
const rawValue =
props.date == null
? ""
: moment(props.date)
.year(thisYear)
.format(HTML_DATE_INPUT_FORMAT);
return { lastDate: props.date, rawValue };
} }
return {};
}
constructor(p: ScheduleDateProps) { constructor(p: ScheduleDateProps) {
super(p); super(p);
this.state = { rawValue: "", lastDate: undefined }; this.state = { rawValue: "", lastDate: undefined };
} }
render() {
const { date, label, editing } = this.props;
let dayNode: React.ReactNode;
if (editing) { // tslint:disable-line:prefer-conditional-expression
let clearIcon: React.ReactNode | undefined;
if (date) {
clearIcon = <Icon name="ban" link onClick={this.onClear} />;
}
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";
dayString = m.format(format);
} else {
dayString = "N/A";
}
dayNode = <span>{dayString}</span>;
}
let labelNode: React.ReactNode = null; render() {
if (typeof label === "string") { const { date, label, editing } = this.props;
labelNode = <label>{label}</label>;
} else if (label != null) {
labelNode = label;
}
return <Form.Field inline>{labelNode}{dayNode}</Form.Field>; let dayNode: React.ReactNode;
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}
/>
);
} else {
const m = moment(date || "");
let dayString: string;
if (m.isValid()) {
const format = m.year() === 0 ? "M/D" : "l";
dayString = m.format(format);
} else {
dayString = "N/A";
}
dayNode = <span>{dayString}</span>;
} }
private onChange = (e: React.SyntheticEvent<HTMLInputElement>, data: InputOnChangeData) => { let labelNode: React.ReactNode = null;
const { onChange } = this.props; if (typeof label === "string") {
if (!onChange) return; labelNode = <label>{label}</label>;
const m = moment(data.value, HTML_DATE_INPUT_FORMAT); } else if (label != null) {
onChange(DateOfYear.fromMoment(m).with({ year: 0 })); labelNode = label;
} }
private onClear = () => { return (
const { onChange } = this.props; <Form.Field inline>
if (!onChange) return; {labelNode}
onChange(null); {dayNode}
} </Form.Field>
);
}
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);
};
} }

60
client/components/ScheduleView/ScheduleTimes.tsx

@ -6,37 +6,41 @@ import { TimeOfDay } from "@common/sprinklersRpc";
import TimeInput from "./TimeInput"; import TimeInput from "./TimeInput";
function timeToString(time: TimeOfDay) { function timeToString(time: TimeOfDay) {
return moment(time).format("LTS"); return moment(time).format("LTS");
} }
export default class ScheduleTimes extends React.Component<{ export default class ScheduleTimes extends React.Component<{
times: TimeOfDay[]; times: TimeOfDay[];
onChange: (newTimes: TimeOfDay[]) => void; onChange: (newTimes: TimeOfDay[]) => void;
editing: boolean; editing: boolean;
}> { }> {
render() { render() {
const { times, editing } = this.props; const { times, editing } = this.props;
let timesNode: React.ReactNode; let timesNode: React.ReactNode;
if (editing) { if (editing) {
timesNode = times timesNode = times.map((time, i) => (
.map((time, i) => <TimeInput value={time} key={i} index={i} onChange={this.onTimeChange} />); <TimeInput
} else { value={time}
timesNode = ( key={i}
<span> index={i}
{times.map((time) => timeToString(time)).join(", ")} onChange={this.onTimeChange}
</span> />
); ));
} } else {
return ( timesNode = (
<Form.Field inline className="scheduleTimes"> <span>{times.map(time => timeToString(time)).join(", ")}</span>
<label>At</label> {timesNode} );
</Form.Field>
);
}
private onTimeChange = (newTime: TimeOfDay, index: number) => {
const { times, onChange } = this.props;
const newTimes = times.slice();
newTimes[index] = newTime;
onChange(newTimes);
} }
return (
<Form.Field inline className="scheduleTimes">
<label>At</label> {timesNode}
</Form.Field>
);
}
private onTimeChange = (newTime: TimeOfDay, index: number) => {
const { times, onChange } = this.props;
const newTimes = times.slice();
newTimes[index] = newTime;
onChange(newTimes);
};
} }

81
client/components/ScheduleView/TimeInput.tsx

@ -7,49 +7,68 @@ import { TimeOfDay } from "@common/sprinklersRpc";
const HTML_TIME_INPUT_FORMAT = "HH:mm"; const HTML_TIME_INPUT_FORMAT = "HH:mm";
function timeOfDayToHtmlDateInput(tod: TimeOfDay): string { function timeOfDayToHtmlDateInput(tod: TimeOfDay): string {
return moment(tod).format(HTML_TIME_INPUT_FORMAT); return moment(tod).format(HTML_TIME_INPUT_FORMAT);
} }
export interface TimeInputProps { export interface TimeInputProps {
value: TimeOfDay; value: TimeOfDay;
index: number; index: number;
onChange: (newValue: TimeOfDay, index: number) => void; onChange: (newValue: TimeOfDay, index: number) => void;
} }
export interface TimeInputState { export interface TimeInputState {
rawValue: string; rawValue: string;
lastTime: TimeOfDay | null; lastTime: TimeOfDay | null;
} }
export default class TimeInput extends React.Component<TimeInputProps, TimeInputState> { export default class TimeInput extends React.Component<
static getDerivedStateFromProps(props: TimeInputProps, state: TimeInputState): Partial<TimeInputState> { TimeInputProps,
if (!TimeOfDay.equals(props.value, state.lastTime)) { TimeInputState
return { lastTime: props.value, rawValue: timeOfDayToHtmlDateInput(props.value) }; > {
} static getDerivedStateFromProps(
return {}; props: TimeInputProps,
state: TimeInputState
): Partial<TimeInputState> {
if (!TimeOfDay.equals(props.value, state.lastTime)) {
return {
lastTime: props.value,
rawValue: timeOfDayToHtmlDateInput(props.value)
};
} }
return {};
}
constructor(p: any) { constructor(p: any) {
super(p); super(p);
this.state = { rawValue: "", lastTime: null }; this.state = { rawValue: "", lastTime: null };
} }
render() { 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 = (
this.setState({ e: React.SyntheticEvent<HTMLInputElement>,
rawValue: data.value, data: InputOnChangeData
}); ) => {
} this.setState({
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); const m = moment(this.state.rawValue, HTML_TIME_INPUT_FORMAT);
if (m.isValid()) { if (m.isValid()) {
this.props.onChange(TimeOfDay.fromMoment(m), this.props.index); this.props.onChange(TimeOfDay.fromMoment(m), this.props.index);
} else { } else {
this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) }); this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) });
}
} }
};
} }

85
client/components/ScheduleView/WeekdaysView.tsx

@ -4,51 +4,56 @@ import { Checkbox, CheckboxProps, Form } from "semantic-ui-react";
import { Weekday, WEEKDAYS } from "@common/sprinklersRpc"; import { Weekday, WEEKDAYS } from "@common/sprinklersRpc";
export interface WeekdaysViewProps { export interface WeekdaysViewProps {
weekdays: Weekday[]; weekdays: Weekday[];
editing: boolean; editing: boolean;
onChange?: (newWeekdays: Weekday[]) => void; onChange?: (newWeekdays: Weekday[]) => void;
} }
export default class WeekdaysView extends React.Component<WeekdaysViewProps> { export default class WeekdaysView extends React.Component<WeekdaysViewProps> {
render() { render() {
const { weekdays, editing } = this.props; const { weekdays, editing } = this.props;
let node: React.ReactNode; let node: React.ReactNode;
if (editing) { if (editing) {
node = WEEKDAYS.map((weekday) => { node = WEEKDAYS.map(weekday => {
const checked = weekdays.find((wd) => wd === weekday) != null; const checked = weekdays.find(wd => wd === weekday) != null;
const name = Weekday[weekday]; const name = Weekday[weekday];
return (
<Form.Field
control={Checkbox}
x-weekday={weekday}
label={name}
checked={checked}
key={weekday}
onChange={this.toggleWeekday}
/>
);
});
} else {
node = weekdays.map((weekday) => Weekday[weekday]).join(", ");
}
return ( return (
<Form.Group inline> <Form.Field
<label>On</label> {node} control={Checkbox}
</Form.Group> x-weekday={weekday}
label={name}
checked={checked}
key={weekday}
onChange={this.toggleWeekday}
/>
); );
});
} else {
node = weekdays.map(weekday => Weekday[weekday]).join(", ");
} }
private toggleWeekday = (event: React.FormEvent<HTMLInputElement>, data: CheckboxProps) => { return (
const { weekdays, onChange } = this.props; <Form.Group inline>
if (!onChange) { <label>On</label> {node}
return; </Form.Group>
} );
const weekday: Weekday = Number(event.currentTarget.getAttribute("x-weekday")); }
if (data.checked) { private toggleWeekday = (
const newWeekdays = weekdays.concat([weekday]); event: React.FormEvent<HTMLInputElement>,
newWeekdays.sort(); data: CheckboxProps
onChange(newWeekdays); ) => {
} else { const { weekdays, onChange } = this.props;
onChange(weekdays.filter((wd) => wd !== weekday)); if (!onChange) {
} return;
} }
const weekday: Weekday = Number(
event.currentTarget.getAttribute("x-weekday")
);
if (data.checked) {
const newWeekdays = weekdays.concat([weekday]);
newWeekdays.sort();
onChange(newWeekdays);
} else {
onChange(weekdays.filter(wd => wd !== weekday));
}
};
} }

103
client/components/ScheduleView/index.tsx

@ -2,7 +2,12 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Form } from "semantic-ui-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 ScheduleDate from "./ScheduleDate";
import ScheduleTimes from "./ScheduleTimes"; import ScheduleTimes from "./ScheduleTimes";
import WeekdaysView from "./WeekdaysView"; import WeekdaysView from "./WeekdaysView";
@ -11,52 +16,70 @@ import "@client/styles/ScheduleView";
import { action } from "mobx"; import { action } from "mobx";
export interface ScheduleViewProps { export interface ScheduleViewProps {
label?: string | React.ReactNode | undefined; label?: string | React.ReactNode | undefined;
schedule: Schedule; schedule: Schedule;
editing?: boolean; editing?: boolean;
} }
@observer @observer
export default class ScheduleView extends React.Component<ScheduleViewProps> { export default class ScheduleView extends React.Component<ScheduleViewProps> {
render() { render() {
const { schedule, label } = this.props; const { schedule, label } = this.props;
const editing = this.props.editing || false; const editing = this.props.editing || false;
let labelNode: React.ReactNode;
if (typeof label === "string") {
labelNode = <label>{label}</label>;
} else if (label != null) {
labelNode = label;
}
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} />
</Form.Field>
);
}
@action.bound let labelNode: React.ReactNode;
private updateTimes(newTimes: TimeOfDay[]) { if (typeof label === "string") {
this.props.schedule.times = newTimes; labelNode = <label>{label}</label>;
} else if (label != null) {
labelNode = label;
} }
@action.bound return (
private updateWeekdays(newWeekdays: Weekday[]) { <Form.Field className="scheduleView">
this.props.schedule.weekdays = newWeekdays; {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}
/>
</Form.Field>
);
}
@action.bound @action.bound
private updateFromDate(newFromDate: DateOfYear | null) { private updateTimes(newTimes: TimeOfDay[]) {
this.props.schedule.from = newFromDate; this.props.schedule.times = newTimes;
} }
@action.bound @action.bound
private updateToDate(newToDate: DateOfYear | null) { private updateWeekdays(newWeekdays: Weekday[]) {
this.props.schedule.to = newToDate; this.props.schedule.weekdays = newWeekdays;
} }
@action.bound
private updateFromDate(newFromDate: DateOfYear | null) {
this.props.schedule.from = newFromDate;
}
@action.bound
private updateToDate(newToDate: DateOfYear | null) {
this.props.schedule.to = newToDate;
}
} }

74
client/components/SectionChooser.tsx

@ -9,41 +9,49 @@ import "@client/styles/SectionChooser";
@observer @observer
export default class SectionChooser extends React.Component<{ export default class SectionChooser extends React.Component<{
label?: string, label?: string;
inline?: boolean, inline?: boolean;
sections: Section[], sections: Section[];
sectionId?: number, sectionId?: number;
onChange?: (sectionId: number) => void, onChange?: (sectionId: number) => void;
}> { }> {
render() { render() {
const { label, inline, sections, sectionId, onChange } = this.props; const { label, inline, sections, sectionId, onChange } = this.props;
if (onChange == null) { if (onChange == null) {
const sectionStr = sectionId != null ? sections[sectionId].toString() : ""; const sectionStr =
return <React.Fragment>{label || ""} '{sectionStr}'</React.Fragment>; sectionId != null ? sections[sectionId].toString() : "";
} return (
const section = (sectionId == null) ? "" : sectionId; <React.Fragment>
return ( {label || ""} '{sectionStr}'
<Form.Select </React.Fragment>
className="sectionChooser" );
label={label}
inline={inline}
placeholder="Section"
options={this.sectionOptions}
value={section}
onChange={this.onSectionChange}
/>
);
} }
const section = sectionId == null ? "" : sectionId;
return (
<Form.Select
className="sectionChooser"
label={label}
inline={inline}
placeholder="Section"
options={this.sectionOptions}
value={section}
onChange={this.onSectionChange}
/>
);
}
private onSectionChange = (e: React.SyntheticEvent<HTMLElement>, v: DropdownProps) => { private onSectionChange = (
this.props.onChange!(this.props.sections[v.value as number].id); e: React.SyntheticEvent<HTMLElement>,
} v: DropdownProps
) => {
this.props.onChange!(this.props.sections[v.value as number].id);
};
@computed @computed
private get sectionOptions(): DropdownItemProps[] { private get sectionOptions(): DropdownItemProps[] {
return this.props.sections.map((s, i) => ({ return this.props.sections.map((s, i) => ({
text: s ? `${s.id}: ${s.name}` : null, text: s ? `${s.id}: ${s.name}` : null,
value: i, value: i
})); }));
} }
} }

261
client/components/SectionRunnerView.tsx

@ -10,149 +10,168 @@ import { Section, SectionRun, SectionRunner } from "@common/sprinklersRpc";
import "@client/styles/SectionRunnerView"; import "@client/styles/SectionRunnerView";
interface PausedStateProps { interface PausedStateProps {
paused: boolean; paused: boolean;
togglePaused: () => void; togglePaused: () => void;
} }
function PausedState({ paused, togglePaused }: PausedStateProps) { function PausedState({ paused, togglePaused }: PausedStateProps) {
const classes = classNames({ const classes = classNames({
"sectionRunner--pausedState": true, "sectionRunner--pausedState": true,
"sectionRunner--pausedState-paused": paused, "sectionRunner--pausedState-paused": paused,
"sectionRunner--pausedState-unpaused": !paused, "sectionRunner--pausedState-unpaused": !paused
}); });
return ( return (
<Button className={classes} size="medium" onClick={togglePaused}> <Button className={classes} size="medium" onClick={togglePaused}>
<Icon name={paused ? "pause" : "play"}/> <Icon name={paused ? "pause" : "play"} />
{paused ? "Paused" : "Processing"} {paused ? "Paused" : "Processing"}
</Button> </Button>
); );
} }
class SectionRunView extends React.Component<{ class SectionRunView extends React.Component<
{
run: SectionRun; run: SectionRun;
sections: Section[]; sections: Section[];
}, { },
{
now: number; now: number;
}> { }
animationFrameHandle: number | null = null; > {
startTime: number; animationFrameHandle: number | null = null;
startTime: number;
constructor(p: any) { constructor(p: any) {
super(p); super(p);
const now = performance.now(); const now = performance.now();
this.state = { now }; this.state = { now };
this.startTime = Date.now() - now; this.startTime = Date.now() - now;
} }
componentDidMount() { componentDidMount() {
this.requestAnimationFrame(); this.requestAnimationFrame();
} }
componentDidUpdate() { componentDidUpdate() {
this.requestAnimationFrame(); this.requestAnimationFrame();
} }
componentWillUnmount() { componentWillUnmount() {
this.cancelAnimationFrame(); this.cancelAnimationFrame();
} }
cancelAnimationFrame = () => { cancelAnimationFrame = () => {
if (this.animationFrameHandle != null) { if (this.animationFrameHandle != null) {
cancelAnimationFrame(this.animationFrameHandle); cancelAnimationFrame(this.animationFrameHandle);
this.animationFrameHandle = null; this.animationFrameHandle = null;
}
} }
};
requestAnimationFrame = () => { requestAnimationFrame = () => {
const startTime = this.props.run.startTime; const startTime = this.props.run.startTime;
if (startTime != null) { if (startTime != null) {
if (this.animationFrameHandle == null) { if (this.animationFrameHandle == null) {
this.animationFrameHandle = requestAnimationFrame(this.updateNow); this.animationFrameHandle = requestAnimationFrame(this.updateNow);
} }
} else { } else {
this.cancelAnimationFrame(); this.cancelAnimationFrame();
}
} }
};
updateNow = (now: number) => { updateNow = (now: number) => {
this.animationFrameHandle = null; this.animationFrameHandle = null;
this.setState({ this.setState({
now: this.startTime + now, now: this.startTime + now
}); });
this.requestAnimationFrame(); this.requestAnimationFrame();
} };
render() { render() {
const { run, sections } = this.props; const { run, sections } = this.props;
const startTime = run.unpauseTime ? run.unpauseTime : run.startTime; const startTime = run.unpauseTime ? run.unpauseTime : run.startTime;
const now = this.state.now; const now = this.state.now;
const section = sections[run.section]; const section = sections[run.section];
const duration = Duration.fromSeconds(run.duration); const duration = Duration.fromSeconds(run.duration);
const cancel = run.cancel; const cancel = run.cancel;
let running: boolean = false; // tslint:disable-line:no-unused-variable let running: boolean = false; // tslint:disable-line:no-unused-variable
let paused: boolean = false; let paused: boolean = false;
let progressBar: React.ReactNode | undefined; let progressBar: React.ReactNode | undefined;
if (startTime != null) { if (startTime != null) {
let elapsed = (run.totalDuration - run.duration); let elapsed = run.totalDuration - run.duration;
if (run.pauseTime) { if (run.pauseTime) {
paused = true; paused = true;
} else { } else {
running = true; running = true;
elapsed += (now - startTime.valueOf()) / 1000; elapsed += (now - startTime.valueOf()) / 1000;
} }
const percentage = elapsed / run.totalDuration; const percentage = elapsed / run.totalDuration;
progressBar = progressBar = (
<Progress color={paused ? "yellow" : "blue"} size="tiny" percent={percentage * 100}/>; <Progress
} color={paused ? "yellow" : "blue"}
const description = `'${section.name}' for ${duration.toString()}` + size="tiny"
(paused ? " (paused)" : "") + percent={percentage * 100}
(running ? " (running)" : ""); />
return ( );
<Segment className="sectionRun">
<div className="flex-horizontal-space-between">
{description}
<Button negative onClick={cancel} icon size="mini"><Icon name="remove"/></Button>
</div>
{progressBar}
</Segment>
);
} }
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>
</div>
{progressBar}
</Segment>
);
}
} }
@observer @observer
export default class SectionRunnerView extends React.Component<{ export default class SectionRunnerView extends React.Component<
sectionRunner: SectionRunner, sections: Section[], {
}, {}> { sectionRunner: SectionRunner;
render() { sections: Section[];
const { current, queue, paused } = this.props.sectionRunner; },
const { sections } = this.props; {}
const queueView = queue.map((run) => > {
<SectionRunView key={run.id} run={run} sections={sections}/>); render() {
if (current) { const { current, queue, paused } = this.props.sectionRunner;
queueView.unshift(<SectionRunView key={-1} run={current} sections={sections}/>); const { sections } = this.props;
} const queueView = queue.map(run => (
if (queueView.length === 0) { <SectionRunView key={run.id} run={run} sections={sections} />
queueView.push(<Segment key={0}>No items in queue</Segment>); ));
} if (current) {
return ( queueView.unshift(
<Segment className="sectionRunner"> <SectionRunView key={-1} run={current} sections={sections} />
<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>
<Segment.Group className="queue">
{queueView}
</Segment.Group>
</Segment>
);
} }
if (queueView.length === 0) {
togglePaused = () => { queueView.push(<Segment key={0}>No items in queue</Segment>);
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"));
} }
return (
<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>
<Segment.Group className="queue">{queueView}</Segment.Group>
</Segment>
);
}
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")
);
};
} }

86
client/components/SectionTable.tsx

@ -8,46 +8,52 @@ import { Section } from "@common/sprinklersRpc";
/* tslint:disable:object-literal-sort-keys */ /* tslint:disable:object-literal-sort-keys */
@observer @observer
export default class SectionTable extends React.Component<{ sections: Section[] }> { export default class SectionTable extends React.Component<{
private static renderRow(section: Section, index: number) { sections: Section[];
if (!section) { }> {
return null; private static renderRow(section: Section, index: number) {
} if (!section) {
const { name, state } = section; return null;
const sectionStateClass = classNames({
"section-state": true,
"running": state,
});
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>
<Table.Cell className="section--name">{name}</Table.Cell>
<Table.Cell className={sectionStateClass}>{sectionState}</Table.Cell>
</Table.Row>
);
} }
const { name, state } = section;
const sectionStateClass = classNames({
"section-state": true,
running: state
});
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>
<Table.Cell className="section--name">{name}</Table.Cell>
<Table.Cell className={sectionStateClass}>{sectionState}</Table.Cell>
</Table.Row>
);
}
render() { render() {
const rows = this.props.sections.map(SectionTable.renderRow); const rows = this.props.sections.map(SectionTable.renderRow);
return ( return (
<Table celled striped unstackable compact> <Table celled striped unstackable compact>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan="3">Sections</Table.HeaderCell> <Table.HeaderCell colSpan="3">Sections</Table.HeaderCell>
</Table.Row> </Table.Row>
<Table.Row> <Table.Row>
<Table.HeaderCell className="section--number">#</Table.HeaderCell> <Table.HeaderCell className="section--number">#</Table.HeaderCell>
<Table.HeaderCell className="section--name">Name</Table.HeaderCell> <Table.HeaderCell className="section--name">Name</Table.HeaderCell>
<Table.HeaderCell className="section--state">State</Table.HeaderCell> <Table.HeaderCell className="section--state">
</Table.Row> State
</Table.Header> </Table.HeaderCell>
<Table.Body> </Table.Row>
{rows} </Table.Header>
</Table.Body> <Table.Body>{rows}</Table.Body>
</Table> </Table>
); );
} }
} }

10
client/env.js

@ -59,8 +59,7 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) {
(env, key) => { (env, key) => {
env[key] = process.env[key]; env[key] = process.env[key];
return env; return env;
}, }, {
{
// Useful for determining whether we’re running in production mode. // Useful for determining whether we’re running in production mode.
// Most importantly, it switches React into the correct mode. // Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || "development", NODE_ENV: process.env.NODE_ENV || "development",
@ -79,5 +78,8 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) {
}, {}), }, {}),
}; };
return { raw, stringified }; return {
}; raw,
stringified
};
};

3
client/index.html

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

34
client/index.tsx

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

26
client/pages/DevicePage.tsx

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

40
client/pages/DevicesPage.tsx

@ -6,28 +6,26 @@ import { DeviceView } from "@client/components";
import { AppState, injectState } from "@client/state"; import { AppState, injectState } from "@client/state";
class DevicesPage extends React.Component<{ appState: AppState }> { class DevicesPage extends React.Component<{ appState: AppState }> {
render() { render() {
const { appState } = this.props; const { appState } = this.props;
const { userData } = appState.userStore; const { userData } = appState.userStore;
let deviceNodes: React.ReactNode; let deviceNodes: React.ReactNode;
if (!userData) { if (!userData) {
deviceNodes = <span>Not logged in</span>; deviceNodes = <span>Not logged in</span>;
} else if (!userData.devices || !userData.devices.length) { } else if (!userData.devices || !userData.devices.length) {
deviceNodes = <span>You have no devices</span>; deviceNodes = <span>You have no devices</span>;
} else { } else {
deviceNodes = userData.devices.map((device) => ( deviceNodes = userData.devices.map(device => (
<DeviceView key={device.id} deviceId={device.id} inList /> <DeviceView key={device.id} deviceId={device.id} inList />
)); ));
}
return (
<React.Fragment>
<h1>Devices</h1>
<Item.Group>
{deviceNodes}
</Item.Group>
</React.Fragment>
);
} }
return (
<React.Fragment>
<h1>Devices</h1>
<Item.Group>{deviceNodes}</Item.Group>
</React.Fragment>
);
}
} }
export default injectState(observer(DevicesPage)); export default injectState(observer(DevicesPage));

147
client/pages/LoginPage.tsx

@ -1,7 +1,16 @@
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "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 { AppState, injectState } from "@client/state";
import log from "@common/logger"; import log from "@common/logger";
@ -9,76 +18,94 @@ import log from "@common/logger";
import "@client/styles/LoginPage"; import "@client/styles/LoginPage";
class LoginPageState { class LoginPageState {
@observable username = ""; @observable
@observable password = ""; username = "";
@observable
password = "";
@observable loading: boolean = false; @observable
@observable error: string | null = null; loading: boolean = false;
@observable
error: string | null = null;
@computed get canLogin() { @computed
return this.username.length > 0 && this.password.length > 0; get canLogin() {
} return this.username.length > 0 && this.password.length > 0;
}
@action.bound @action.bound
onUsernameChange(e: any, data: InputOnChangeData) { onUsernameChange(e: any, data: InputOnChangeData) {
this.username = data.value; this.username = data.value;
} }
@action.bound @action.bound
onPasswordChange(e: any, data: InputOnChangeData) { onPasswordChange(e: any, data: InputOnChangeData) {
this.password = data.value; this.password = data.value;
} }
@action.bound @action.bound
login(appState: AppState) { login(appState: AppState) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
appState.httpApi.grantPassword(this.username, this.password) appState.httpApi
.then(action("loginSuccess", () => { .grantPassword(this.username, this.password)
this.loading = false; .then(
log.info("logged in"); action("loginSuccess", () => {
appState.history.push("/"); this.loading = false;
})) log.info("logged in");
.catch(action("loginError", (err: any) => { appState.history.push("/");
this.loading = false; })
this.error = err.message; )
log.error({ err }, "login error"); .catch(
})); action("loginError", (err: any) => {
} this.loading = false;
this.error = err.message;
log.error({ err }, "login error");
})
);
}
} }
class LoginPage extends React.Component<{ appState: AppState }> { class LoginPage extends React.Component<{ appState: AppState }> {
pageState = new LoginPageState(); pageState = new LoginPageState();
render() { render() {
const { username, password, canLogin, loading, error } = this.pageState; const { username, password, canLogin, loading, error } = this.pageState;
return ( return (
<Container className="loginPage"> <Container className="loginPage">
<Segment> <Segment>
<Dimmer inverted active={loading}> <Dimmer inverted active={loading}>
<Loader/> <Loader />
</Dimmer> </Dimmer>
<Header as="h1">Login</Header> <Header as="h1">Login</Header>
<Form> <Form>
<Form.Input label="Username" value={username} onChange={this.pageState.onUsernameChange}/> <Form.Input
<Form.Input label="Username"
label="Password" value={username}
value={password} onChange={this.pageState.onUsernameChange}
type="password" />
onChange={this.pageState.onPasswordChange} <Form.Input
/> label="Password"
<Message error visible={error != null}>{error}</Message> value={password}
<Form.Button disabled={!canLogin} onClick={this.login}>Login</Form.Button> type="password"
</Form> onChange={this.pageState.onPasswordChange}
</Segment> />
</Container> <Message error visible={error != null}>
); {error}
} </Message>
<Form.Button disabled={!canLogin} onClick={this.login}>
Login
</Form.Button>
</Form>
</Segment>
</Container>
);
}
login = () => { login = () => {
this.pageState.login(this.props.appState); this.pageState.login(this.props.appState);
} };
} }
export default injectState(observer(LoginPage)); export default injectState(observer(LoginPage));

14
client/pages/LogoutPage.tsx

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

60
client/pages/MessageTest.tsx

@ -5,36 +5,42 @@ import { AppState, injectState } from "@client/state";
import { getRandomId } from "@common/utils"; import { getRandomId } from "@common/utils";
class MessageTest extends React.Component<{ appState: AppState }> { class MessageTest extends React.Component<{ appState: AppState }> {
render() { render() {
return ( return (
<Segment> <Segment>
<h2>Message Test</h2> <h2>Message Test</h2>
<Button onClick={this.test1}>Add test message</Button> <Button onClick={this.test1}>Add test message</Button>
<Button onClick={this.test2}>Add test message w/ timeout</Button> <Button onClick={this.test2}>Add test message w/ timeout</Button>
<Button onClick={this.test3}>Add test message w/ content</Button> <Button onClick={this.test3}>Add test message w/ content</Button>
</Segment> </Segment>
); );
} }
private test1 = () => { private test1 = () => {
this.props.appState.uiStore.addMessage({ 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 = () => { private test2 = () => {
this.props.appState.uiStore.addMessage({ this.props.appState.uiStore.addMessage({
warning: true, content: "Im gonna dissapear in 5 seconds " + getRandomId(), warning: true,
header: "Header to test message", timeout: 5000, content: "Im gonna dissapear in 5 seconds " + getRandomId(),
}); header: "Header to test message",
} timeout: 5000
});
};
private test3 = () => { private test3 = () => {
this.props.appState.uiStore.addMessage({ this.props.appState.uiStore.addMessage({
color: "brown", content: <div className="ui segment">I Have crazy content!</div>, color: "brown",
header: "Header to test message", timeout: 5000, content: <div className="ui segment">I Have crazy content!</div>,
}); header: "Header to test message",
} timeout: 5000
});
};
} }
export default injectState(MessageTest); export default injectState(MessageTest);

429
client/pages/ProgramPage.tsx

@ -3,7 +3,16 @@ import { observer } from "mobx-react";
import * as qs from "query-string"; import * as qs from "query-string";
import * as React from "react"; import * as React from "react";
import { RouteComponentProps } from "react-router"; 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 { ProgramSequenceView, ScheduleView } from "@client/components";
import * as route from "@client/routePaths"; import * as route from "@client/routePaths";
@ -13,233 +22,253 @@ import log from "@common/logger";
import { Program, SprinklersDevice } from "@common/sprinklersRpc"; import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import { action } from "mobx"; import { action } from "mobx";
interface ProgramPageProps extends RouteComponentProps<{ deviceId: string, programId: string }> { interface ProgramPageProps
appState: AppState; extends RouteComponentProps<{ deviceId: string; programId: string }> {
appState: AppState;
} }
@observer @observer
class ProgramPage extends React.Component<ProgramPageProps> { class ProgramPage extends React.Component<ProgramPageProps> {
get isEditing(): boolean { get isEditing(): boolean {
return qs.parse(this.props.location.search).editing != null; return qs.parse(this.props.location.search).editing != null;
} }
deviceInfo: ISprinklersDevice | null = null; deviceInfo: ISprinklersDevice | null = null;
device: SprinklersDevice | null = null; device: SprinklersDevice | null = null;
program: Program | null = null; program: Program | null = null;
programView: Program | null = null; programView: Program | null = null;
componentWillUnmount() { componentWillUnmount() {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
}
} }
}
updateProgram() { updateProgram() {
const { userStore, sprinklersRpc } = this.props.appState; const { userStore, sprinklersRpc } = this.props.appState;
const devId = Number(this.props.match.params.deviceId); const devId = Number(this.props.match.params.deviceId);
const programId = Number(this.props.match.params.programId); const programId = Number(this.props.match.params.programId);
// tslint:disable-next-line:prefer-conditional-expression // tslint:disable-next-line:prefer-conditional-expression
if (this.deviceInfo == null || this.deviceInfo.id !== devId) { if (this.deviceInfo == null || this.deviceInfo.id !== devId) {
this.deviceInfo = userStore.findDevice(devId); this.deviceInfo = userStore.findDevice(devId);
} }
if (!this.deviceInfo || !this.deviceInfo.deviceId) { if (!this.deviceInfo || !this.deviceInfo.deviceId) {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
this.device = null; this.device = null;
} }
return; return;
} else { } else {
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) { if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
if (this.device) { if (this.device) {
this.device.release(); this.device.release();
}
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
}
}
if (!this.program || this.program.id !== programId) {
if (this.device.programs.length > programId && programId >= 0) {
this.program = this.device.programs[programId];
} else {
return;
}
}
if (this.isEditing) {
if (this.programView == null && this.program) {
// this.programView = createViewModel(this.program);
// this.programView = observable(toJS(this.program));
this.programView = this.program.clone();
}
} else {
if (this.programView != null) {
// this.programView.reset();
this.programView = null;
}
} }
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
}
}
if (!this.program || this.program.id !== programId) {
if (this.device.programs.length > programId && programId >= 0) {
this.program = this.device.programs[programId];
} else {
return;
}
} }
if (this.isEditing) {
if (this.programView == null && this.program) {
// this.programView = createViewModel(this.program);
// this.programView = observable(toJS(this.program));
this.programView = this.program.clone();
}
} else {
if (this.programView != null) {
// this.programView.reset();
this.programView = null;
}
}
}
renderName(program: Program) { renderName(program: Program) {
const { name } = program; const { name } = program;
if (this.isEditing) { if (this.isEditing) {
return ( return (
<Menu.Item header> <Menu.Item header>
<Form> <Form>
<Form.Group inline> <Form.Group inline>
<Form.Field inline> <Form.Field inline>
<label><h4>Program</h4></label> <label>
<Input <h4>Program</h4>
placeholder="Program Name" </label>
type="text" <Input
value={name} placeholder="Program Name"
onChange={this.onNameChange} type="text"
/> value={name}
</Form.Field> onChange={this.onNameChange}
({program.id}) />
</Form.Group> </Form.Field>
</Form> ({program.id})
</Menu.Item> </Form.Group>
); </Form>
} else { </Menu.Item>
return <Menu.Item header as="h4">Program {name} ({program.id})</Menu.Item>; );
} } else {
return (
<Menu.Item header as="h4">
Program {name} ({program.id})
</Menu.Item>
);
} }
}
renderActions(program: Program) { renderActions(program: Program) {
const { running } = program; const { running } = program;
const editing = this.isEditing; const editing = this.isEditing;
let editButtons; let editButtons;
if (editing) { if (editing) {
editButtons = ( editButtons = (
<React.Fragment> <React.Fragment>
<Button primary onClick={this.save}> <Button primary onClick={this.save}>
<Icon name="save" /> <Icon name="save" />
Save Save
</Button> </Button>
<Button negative onClick={this.stopEditing}> <Button negative onClick={this.stopEditing}>
<Icon name="cancel" /> <Icon name="cancel" />
Cancel Cancel
</Button> </Button>
</React.Fragment> </React.Fragment>
); );
} else { } else {
editButtons = ( editButtons = (
<Button primary onClick={this.startEditing}> <Button primary onClick={this.startEditing}>
<Icon name="edit" /> <Icon name="edit" />
Edit Edit
</Button> </Button>
); );
}
const stopStartButton = (
<Button onClick={this.cancelOrRun} positive={!running} negative={running}>
<Icon name={running ? "stop" : "play"} />
{running ? "Stop" : "Run"}
</Button>
);
return (
<Modal.Actions>
{stopStartButton}
{editButtons}
<Button onClick={this.close}>
<Icon name="arrow left" />
Close
</Button>
</Modal.Actions>
);
} }
const stopStartButton = (
<Button onClick={this.cancelOrRun} positive={!running} negative={running}>
<Icon name={running ? "stop" : "play"} />
{running ? "Stop" : "Run"}
</Button>
);
return (
<Modal.Actions>
{stopStartButton}
{editButtons}
<Button onClick={this.close}>
<Icon name="arrow left" />
Close
</Button>
</Modal.Actions>
);
}
render() { render() {
this.updateProgram(); this.updateProgram();
const program = this.programView || this.program; const program = this.programView || this.program;
if (!this.device || !program) { if (!this.device || !program) {
return null; return null;
}
const editing = this.isEditing;
const { running, enabled, schedule, sequence } = program;
return (
<Modal open onClose={this.close} className="programEditor">
<Modal.Header>{this.renderName(program)}</Modal.Header>
<Modal.Content>
<Form>
<Form.Group>
<Form.Checkbox
toggle
label="Enabled"
checked={enabled}
readOnly={!editing}
onChange={this.onEnabledChange}
/>
<Form.Checkbox toggle label="Running" checked={running} readOnly={!editing} />
</Form.Group>
<Form.Field>
<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>} />
</Form>
</Modal.Content>
{this.renderActions(program)}
</Modal>
);
} }
const editing = this.isEditing;
@action.bound const { running, enabled, schedule, sequence } = program;
private cancelOrRun() {
if (!this.program) {
return;
}
this.program.running ? this.program.cancel() : this.program.run();
}
@action.bound return (
private startEditing() { <Modal open onClose={this.close} className="programEditor">
this.props.history.push({ search: qs.stringify({ editing: true }) }); <Modal.Header>{this.renderName(program)}</Modal.Header>
} <Modal.Content>
<Form>
<Form.Group>
<Form.Checkbox
toggle
label="Enabled"
checked={enabled}
readOnly={!editing}
onChange={this.onEnabledChange}
/>
<Form.Checkbox
toggle
label="Running"
checked={running}
readOnly={!editing}
/>
</Form.Group>
<Form.Field>
<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>}
/>
</Form>
</Modal.Content>
{this.renderActions(program)}
</Modal>
);
}
@action.bound @action.bound
private save() { private cancelOrRun() {
if (!this.programView || !this.program) { if (!this.program) {
return; return;
}
assign(this.program, this.programView);
this.program.update()
.then((data) => {
log.info({ data }, "Program updated");
}, (err) => {
log.error({ err }, "error updating Program");
});
this.stopEditing();
} }
this.program.running ? this.program.cancel() : this.program.run();
}
@action.bound @action.bound
private stopEditing() { private startEditing() {
this.props.history.push({ search: "" }); this.props.history.push({ search: qs.stringify({ editing: true }) });
} }
@action.bound @action.bound
private close() { private save() {
const { deviceId } = this.props.match.params; if (!this.programView || !this.program) {
this.props.history.push({ pathname: route.device(deviceId), search: "" }); return;
} }
assign(this.program, this.programView);
this.program.update().then(
data => {
log.info({ data }, "Program updated");
},
err => {
log.error({ err }, "error updating Program");
}
);
this.stopEditing();
}
@action.bound @action.bound
private onNameChange(e: any, p: InputOnChangeData) { private stopEditing() {
if (this.programView) { this.props.history.push({ search: "" });
this.programView.name = p.value; }
}
@action.bound
private close() {
const { deviceId } = this.props.match.params;
this.props.history.push({ pathname: route.device(deviceId), search: "" });
}
@action.bound
private onNameChange(e: any, p: InputOnChangeData) {
if (this.programView) {
this.programView.name = p.value;
} }
}
@action.bound @action.bound
private onEnabledChange(e: any, p: CheckboxProps) { private onEnabledChange(e: any, p: CheckboxProps) {
if (this.programView) { if (this.programView) {
this.programView.enabled = p.checked!; this.programView.enabled = p.checked!;
}
} }
}
} }
const DecoratedProgramPage = injectState(observer(ProgramPage)); const DecoratedProgramPage = injectState(observer(ProgramPage));

2
client/polyfills.js

@ -13,4 +13,4 @@ if (typeof Promise === 'undefined') {
// Object.assign() is commonly used with React. // Object.assign() is commonly used with React.
// It will use the native implementation if it's present and isn't buggy. // It will use the native implementation if it's present and isn't buggy.
Object.assign = require('object-assign'); Object.assign = require('object-assign');

17
client/routePaths.ts

@ -1,11 +1,11 @@
export interface RouteParams { export interface RouteParams {
deviceId: string; deviceId: string;
programId: string; programId: string;
} }
export const routerRouteParams: RouteParams = { export const routerRouteParams: RouteParams = {
deviceId: ":deviceId", deviceId: ":deviceId",
programId: ":programId", programId: ":programId"
}; };
export const home = "/"; export const home = "/";
@ -15,9 +15,12 @@ export const login = "/login";
export const logout = "/logout"; export const logout = "/logout";
export function device(deviceId?: string | number): string { export function device(deviceId?: string | number): string {
return `/devices/${deviceId || ""}`; return `/devices/${deviceId || ""}`;
} }
export function program(deviceId: string | number, programId?: string | number): string { export function program(
return `${device(deviceId)}/programs/${programId}`; deviceId: string | number,
programId?: string | number
): string {
return `${device(deviceId)}/programs/${programId}`;
} }

109
client/sprinklersRpc/WSSprinklersDevice.ts

@ -8,66 +8,71 @@ import { log, WebSocketRpcClient } from "./WebSocketRpcClient";
// tslint:disable:member-ordering // tslint:disable:member-ordering
export class WSSprinklersDevice extends s.SprinklersDevice { export class WSSprinklersDevice extends s.SprinklersDevice {
readonly api: WebSocketRpcClient; readonly api: WebSocketRpcClient;
constructor(api: WebSocketRpcClient, id: string) { constructor(api: WebSocketRpcClient, id: string) {
super(api, id); super(api, id);
this.api = api; this.api = api;
autorun(this.updateConnectionState); autorun(this.updateConnectionState);
this.waitSubscribe(); this.waitSubscribe();
} }
private updateConnectionState = () => { private updateConnectionState = () => {
const { clientToServer, serverToBroker } = this.api.connectionState; const { clientToServer, serverToBroker } = this.api.connectionState;
runInAction("updateConnectionState", () => { runInAction("updateConnectionState", () => {
Object.assign(this.connectionState, { clientToServer, serverToBroker }); Object.assign(this.connectionState, { clientToServer, serverToBroker });
}); });
} };
async subscribe() { async subscribe() {
const subscribeRequest: ws.IDeviceSubscribeRequest = { const subscribeRequest: ws.IDeviceSubscribeRequest = {
deviceId: this.id, deviceId: this.id
}; };
try { try {
await this.api.makeRequest("deviceSubscribe", subscribeRequest); await this.api.makeRequest("deviceSubscribe", subscribeRequest);
runInAction("deviceSubscribeSuccess", () => { runInAction("deviceSubscribeSuccess", () => {
this.connectionState.hasPermission = true; this.connectionState.hasPermission = true;
}); });
} catch (err) { } catch (err) {
runInAction("deviceSubscribeError", () => { runInAction("deviceSubscribeError", () => {
this.connectionState.brokerToDevice = false; this.connectionState.brokerToDevice = false;
if ((err as ws.IError).code === ErrorCode.NoPermission) { if ((err as ws.IError).code === ErrorCode.NoPermission) {
this.connectionState.hasPermission = false; this.connectionState.hasPermission = false;
} else { } else {
log.error({ err }); log.error({ err });
}
});
} }
});
} }
}
async unsubscribe() { async unsubscribe() {
const unsubscribeRequest: ws.IDeviceSubscribeRequest = { const unsubscribeRequest: ws.IDeviceSubscribeRequest = {
deviceId: this.id, deviceId: this.id
}; };
try { try {
await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest); await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest);
runInAction("deviceUnsubscribeSuccess", () => { runInAction("deviceUnsubscribeSuccess", () => {
this.connectionState.brokerToDevice = false; this.connectionState.brokerToDevice = false;
}); });
} catch (err) { } catch (err) {
log.error({ err }, "error unsubscribing from device"); log.error({ err }, "error unsubscribing from device");
}
} }
}
makeRequest(request: deviceRequests.Request): Promise<deviceRequests.Response> { makeRequest(
return this.api.makeDeviceCall(this.id, request); request: deviceRequests.Request
} ): Promise<deviceRequests.Response> {
return this.api.makeDeviceCall(this.id, request);
}
waitSubscribe = () => { waitSubscribe = () => {
when(() => this.api.authenticated, () => { when(
this.subscribe(); () => this.api.authenticated,
when(() => !this.api.authenticated, this.waitSubscribe); () => {
}); this.subscribe();
} when(() => !this.api.authenticated, this.waitSubscribe);
}
);
};
} }

518
client/sprinklersRpc/WebSocketRpcClient.ts

@ -11,7 +11,11 @@ import * as deviceRequests from "@common/sprinklersRpc/deviceRequests";
import * as schema from "@common/sprinklersRpc/schema/"; import * as schema from "@common/sprinklersRpc/schema/";
import { seralizeRequest } from "@common/sprinklersRpc/schema/requests"; import { seralizeRequest } from "@common/sprinklersRpc/schema/requests";
import * as ws from "@common/sprinklersRpc/websocketData"; 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"; import { WSSprinklersDevice } from "./WSSprinklersDevice";
export const log = logger.child({ source: "websocket" }); export const log = logger.child({ source: "websocket" });
@ -20,288 +24,310 @@ const TIMEOUT_MS = 5000;
const RECONNECT_TIMEOUT_MS = 5000; const RECONNECT_TIMEOUT_MS = 5000;
const isDev = process.env.NODE_ENV === "development"; 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 websocketPort = isDev ? 8080 : location.port;
const DEFAULT_URL = `${websocketProtocol}//${location.hostname}:${websocketPort}`; const DEFAULT_URL = `${websocketProtocol}//${
location.hostname
}:${websocketPort}`;
export interface WebSocketRpcClientEvents extends DefaultEvents { export interface WebSocketRpcClientEvents extends DefaultEvents {
newUserData(userData: IUser): void; newUserData(userData: IUser): void;
rpcError(error: s.RpcError): void; rpcError(error: s.RpcError): void;
tokenError(error: s.RpcError): void; tokenError(error: s.RpcError): void;
} }
// tslint:disable:member-ordering // tslint:disable:member-ordering
export interface WebSocketRpcClient extends TypedEventEmitter<WebSocketRpcClientEvents> { export interface WebSocketRpcClient
} extends TypedEventEmitter<WebSocketRpcClientEvents> {}
@typedEventEmitter @typedEventEmitter
export class WebSocketRpcClient extends s.SprinklersRPC { export class WebSocketRpcClient extends s.SprinklersRPC {
@computed @computed
get connected(): boolean { get connected(): boolean {
return this.connectionState.isServerConnected || false; return this.connectionState.isServerConnected || false;
} }
readonly webSocketUrl: string; readonly webSocketUrl: string;
devices: Map<string, WSSprinklersDevice> = new Map(); devices: Map<string, WSSprinklersDevice> = new Map();
@observable connectionState: s.ConnectionState = new s.ConnectionState(); @observable
socket: WebSocket | null = null; connectionState: s.ConnectionState = new s.ConnectionState();
socket: WebSocket | null = null;
@observable
authenticated: boolean = false; @observable
authenticated: boolean = false;
tokenStore: TokenStore;
tokenStore: TokenStore;
private nextRequestId = Math.round(Math.random() * 1000000);
private responseCallbacks: ws.ServerResponseHandlers = {}; private nextRequestId = Math.round(Math.random() * 1000000);
private reconnectTimer: number | null = null; private responseCallbacks: ws.ServerResponseHandlers = {};
private reconnectTimer: number | null = null;
@action
private onDisconnect = action(() => { @action
this.connectionState.serverToBroker = null; private onDisconnect = action(() => {
this.connectionState.clientToServer = false; this.connectionState.serverToBroker = null;
this.authenticated = false; this.connectionState.clientToServer = false;
this.authenticated = false;
});
private notificationHandlers = new WSClientNotificationHandlers(this);
constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) {
super();
this.webSocketUrl = webSocketUrl;
this.tokenStore = tokenStore;
this.connectionState.clientToServer = false;
this.connectionState.serverToBroker = false;
this.on("rpcError", (err: s.RpcError) => {
if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err);
}
}); });
}
private notificationHandlers = new WSClientNotificationHandlers(this); start() {
this._connect();
constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) { }
super();
this.webSocketUrl = webSocketUrl;
this.tokenStore = tokenStore;
this.connectionState.clientToServer = false;
this.connectionState.serverToBroker = false;
this.on("rpcError", (err: s.RpcError) => {
if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err);
}
});
}
start() {
this._connect();
}
stop() {
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket != null) {
this.socket.close();
this.socket = null;
}
}
acquireDevice = s.SprinklersRPC.prototype.acquireDevice;
protected getDevice(id: string): s.SprinklersDevice {
let device = this.devices.get(id);
if (!device) {
device = new WSSprinklersDevice(this, id);
this.devices.set(id, device);
}
return device;
}
releaseDevice(id: string): void { stop() {
const device = this.devices.get(id); if (this.reconnectTimer != null) {
if (!device) return; clearTimeout(this.reconnectTimer);
device.unsubscribe() this.reconnectTimer = null;
.then(() => {
log.debug({ id }, "released device");
this.devices.delete(id);
});
} }
if (this.socket != null) {
async authenticate(accessToken: string): Promise<ws.IAuthenticateResponse> { this.socket.close();
return this.makeRequest("authenticate", { accessToken }); this.socket = null;
}
async tryAuthenticate() {
when(() => this.connectionState.clientToServer === true
&& this.tokenStore.accessToken.isValid, async () => {
try {
const res = await this.authenticate(this.tokenStore.accessToken.token!);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
runInAction("authenticateError", () => {
this.authenticated = false;
});
}
});
} }
}
// args must all be JSON serializable acquireDevice = s.SprinklersRPC.prototype.acquireDevice;
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",
};
throw new s.RpcError("the server is not connected", ErrorCode.ServerDisconnected);
}
const requestData = seralizeRequest(request);
const data: ws.IDeviceCallRequest = { deviceId, data: requestData };
const resData = await this.makeRequest("deviceCall", data);
if (resData.data.result === "error") {
throw new s.RpcError(resData.data.message, resData.data.code, resData.data);
} else {
return resData.data;
}
}
makeRequest<Method extends ws.ClientRequestMethods>(method: Method, params: ws.IClientRequestTypes[Method]): protected getDevice(id: string): s.SprinklersDevice {
Promise<ws.IServerResponseTypes[Method]> { let device = this.devices.get(id);
const id = this.nextRequestId++; if (!device) {
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => { device = new WSSprinklersDevice(this, id);
let timeoutHandle: number; this.devices.set(id, device);
this.responseCallbacks[id] = (response) => {
clearTimeout(timeoutHandle);
delete this.responseCallbacks[id];
if (response.result === "success") {
resolve(response.data);
} else {
const { error } = response;
reject(new s.RpcError(error.message, error.code, error.data));
}
};
timeoutHandle = window.setTimeout(() => {
delete this.responseCallbacks[id];
reject(new s.RpcError("the request timed out", ErrorCode.Timeout));
}, TIMEOUT_MS);
this.sendRequest(id, method, params);
})
.catch((err) => {
if (err instanceof s.RpcError) {
this.emit("rpcError", err);
}
throw err;
});
} }
return device;
private sendMessage(data: ws.ClientMessage) { }
if (!this.socket) {
throw new Error("WebSocketApiClient is not connected"); releaseDevice(id: string): void {
const device = this.devices.get(id);
if (!device) return;
device.unsubscribe().then(() => {
log.debug({ id }, "released device");
this.devices.delete(id);
});
}
async authenticate(accessToken: string): Promise<ws.IAuthenticateResponse> {
return this.makeRequest("authenticate", { accessToken });
}
async tryAuthenticate() {
when(
() =>
this.connectionState.clientToServer === true &&
this.tokenStore.accessToken.isValid,
async () => {
try {
const res = await this.authenticate(
this.tokenStore.accessToken.token!
);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
runInAction("authenticateError", () => {
this.authenticated = false;
});
} }
this.socket.send(JSON.stringify(data)); }
);
}
// args must all be JSON serializable
async makeDeviceCall(
deviceId: string,
request: deviceRequests.Request
): Promise<deviceRequests.Response> {
if (this.socket == null) {
const error: ws.IError = {
code: ErrorCode.ServerDisconnected,
message: "the server is not connected"
};
throw new s.RpcError(
"the server is not connected",
ErrorCode.ServerDisconnected
);
} }
const requestData = seralizeRequest(request);
private sendRequest<Method extends ws.ClientRequestMethods>( const data: ws.IDeviceCallRequest = { deviceId, data: requestData };
id: number, method: Method, params: ws.IClientRequestTypes[Method], const resData = await this.makeRequest("deviceCall", data);
) { if (resData.data.result === "error") {
this.sendMessage({ type: "request", id, method, params }); throw new s.RpcError(
} resData.data.message,
resData.data.code,
private _reconnect = () => { resData.data
this._connect(); );
} else {
return resData.data;
} }
}
private _connect() {
if (this.socket != null && makeRequest<Method extends ws.ClientRequestMethods>(
(this.socket.readyState === WebSocket.OPEN)) { method: Method,
this.tryAuthenticate(); params: ws.IClientRequestTypes[Method]
return; ): Promise<ws.IServerResponseTypes[Method]> {
const id = this.nextRequestId++;
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
let timeoutHandle: number;
this.responseCallbacks[id] = response => {
clearTimeout(timeoutHandle);
delete this.responseCallbacks[id];
if (response.result === "success") {
resolve(response.data);
} else {
const { error } = response;
reject(new s.RpcError(error.message, error.code, error.data));
} }
log.debug({ url: this.webSocketUrl }, "connecting to websocket"); };
this.socket = new WebSocket(this.webSocketUrl); timeoutHandle = window.setTimeout(() => {
this.socket.onopen = this.onOpen.bind(this); delete this.responseCallbacks[id];
this.socket.onclose = this.onClose.bind(this); reject(new s.RpcError("the request timed out", ErrorCode.Timeout));
this.socket.onerror = this.onError.bind(this); }, TIMEOUT_MS);
this.socket.onmessage = this.onMessage.bind(this); this.sendRequest(id, method, params);
} }).catch(err => {
if (err instanceof s.RpcError) {
this.emit("rpcError", err);
}
throw err;
});
}
@action private sendMessage(data: ws.ClientMessage) {
private onOpen() { if (!this.socket) {
log.info("established websocket connection"); throw new Error("WebSocketApiClient is not connected");
this.connectionState.clientToServer = true;
this.authenticated = false;
this.tryAuthenticate();
} }
this.socket.send(JSON.stringify(data));
private onClose(event: CloseEvent) { }
log.info({ event },
"disconnected from websocket"); private sendRequest<Method extends ws.ClientRequestMethods>(
this.onDisconnect(); id: number,
this.reconnectTimer = window.setTimeout(this._reconnect, RECONNECT_TIMEOUT_MS); method: Method,
params: ws.IClientRequestTypes[Method]
) {
this.sendMessage({ type: "request", id, method, params });
}
private _reconnect = () => {
this._connect();
};
private _connect() {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.tryAuthenticate();
return;
} }
log.debug({ url: this.webSocketUrl }, "connecting to websocket");
@action this.socket = new WebSocket(this.webSocketUrl);
private onError(event: Event) { this.socket.onopen = this.onOpen.bind(this);
log.error({ event }, "websocket error"); this.socket.onclose = this.onClose.bind(this);
this.connectionState.serverToBroker = null; this.socket.onerror = this.onError.bind(this);
this.connectionState.clientToServer = false; this.socket.onmessage = this.onMessage.bind(this);
this.onDisconnect(); }
@action
private onOpen() {
log.info("established websocket connection");
this.connectionState.clientToServer = true;
this.authenticated = false;
this.tryAuthenticate();
}
private onClose(event: CloseEvent) {
log.info({ event }, "disconnected from websocket");
this.onDisconnect();
this.reconnectTimer = window.setTimeout(
this._reconnect,
RECONNECT_TIMEOUT_MS
);
}
@action
private onError(event: Event) {
log.error({ event }, "websocket error");
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
this.onDisconnect();
}
private onMessage(event: MessageEvent) {
let data: ws.ServerMessage;
try {
data = JSON.parse(event.data);
} catch (err) {
return log.error({ event, err }, "received invalid websocket message");
} }
log.trace({ data }, "websocket message");
private onMessage(event: MessageEvent) { switch (data.type) {
let data: ws.ServerMessage; case "notification":
try { this.onNotification(data);
data = JSON.parse(event.data); break;
} catch (err) { case "response":
return log.error({ event, err }, "received invalid websocket message"); this.onResponse(data);
} break;
log.trace({ data }, "websocket message"); default:
switch (data.type) { log.warn({ data }, "unsupported event type received");
case "notification":
this.onNotification(data);
break;
case "response":
this.onResponse(data);
break;
default:
log.warn({ data }, "unsupported event type received");
}
} }
}
private onNotification(data: ws.ServerNotification) { private onNotification(data: ws.ServerNotification) {
try { try {
rpc.handleNotification(this.notificationHandlers, data); rpc.handleNotification(this.notificationHandlers, data);
} catch (err) { } catch (err) {
logger.error(err, "error handling server notification"); logger.error(err, "error handling server notification");
}
} }
}
private onResponse(data: ws.ServerResponse) { private onResponse(data: ws.ServerResponse) {
try { try {
rpc.handleResponse(this.responseCallbacks, data); rpc.handleResponse(this.responseCallbacks, data);
} catch (err) { } catch (err) {
log.error({ err }, "error handling server response"); log.error({ err }, "error handling server response");
}
} }
}
} }
class WSClientNotificationHandlers implements ws.ServerNotificationHandlers { class WSClientNotificationHandlers implements ws.ServerNotificationHandlers {
client: WebSocketRpcClient; client: WebSocketRpcClient;
constructor(client: WebSocketRpcClient) { constructor(client: WebSocketRpcClient) {
this.client = client; this.client = client;
} }
@action.bound @action.bound
brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) { brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) {
this.client.connectionState.serverToBroker = data.brokerConnected; this.client.connectionState.serverToBroker = data.brokerConnected;
} }
@action.bound @action.bound
deviceUpdate(data: ws.IDeviceUpdate) { deviceUpdate(data: ws.IDeviceUpdate) {
const device = this.client.devices.get(data.deviceId); const device = this.client.devices.get(data.deviceId);
if (!device) { if (!device) {
return log.warn({ data }, "invalid deviceUpdate received"); return log.warn({ data }, "invalid deviceUpdate received");
}
update(schema.sprinklersDevice, device, data.data);
} }
update(schema.sprinklersDevice, device, data.data);
}
error(data: ws.IError) { error(data: ws.IError) {
log.warn({ err: data }, "server error"); log.warn({ err: data }, "server error");
} }
} }

129
client/state/AppState.ts

@ -12,81 +12,84 @@ import log from "@common/logger";
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter"; import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
interface AppEvents extends DefaultEvents { interface AppEvents extends DefaultEvents {
checkToken(): void; checkToken(): void;
hasToken(): void; hasToken(): void;
} }
export default class AppState extends TypedEventEmitter<AppEvents> { export default class AppState extends TypedEventEmitter<AppEvents> {
history: History = createBrowserHistory(); history: History = createBrowserHistory();
routerStore = new RouterStore(); routerStore = new RouterStore();
uiStore = new UiStore(); uiStore = new UiStore();
userStore = new UserStore(); userStore = new UserStore();
httpApi = new HttpApi(); httpApi = new HttpApi();
tokenStore = this.httpApi.tokenStore; tokenStore = this.httpApi.tokenStore;
sprinklersRpc = new WebSocketRpcClient(this.tokenStore); sprinklersRpc = new WebSocketRpcClient(this.tokenStore);
constructor() { constructor() {
super(); super();
this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData); this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData);
this.sprinklersRpc.on("tokenError", this.clearToken); this.sprinklersRpc.on("tokenError", this.clearToken);
this.httpApi.on("tokenGranted", () => this.emit("hasToken")); this.httpApi.on("tokenGranted", () => this.emit("hasToken"));
this.httpApi.on("tokenError", this.clearToken); this.httpApi.on("tokenError", this.clearToken);
this.on("checkToken", this.doCheckToken); this.on("checkToken", this.doCheckToken);
this.on("hasToken", () => { this.on("hasToken", () => {
when(() => !this.tokenStore.accessToken.isValid, this.checkToken); when(() => !this.tokenStore.accessToken.isValid, this.checkToken);
this.sprinklersRpc.start(); this.sprinklersRpc.start();
}); });
} }
@computed get isLoggedIn() { @computed
return this.tokenStore.accessToken.isValid; get isLoggedIn() {
} return this.tokenStore.accessToken.isValid;
}
async start() { async start() {
configure({ configure({
enforceActions: true, enforceActions: true
}); });
syncHistoryWithStore(this.history, this.routerStore); syncHistoryWithStore(this.history, this.routerStore);
await this.tokenStore.loadLocalStorage(); await this.tokenStore.loadLocalStorage();
await this.checkToken(); await this.checkToken();
} }
clearToken = (err?: any) => { clearToken = (err?: any) => {
this.tokenStore.clearAccessToken(); this.tokenStore.clearAccessToken();
this.checkToken(); this.checkToken();
} };
checkToken = () => { checkToken = () => {
this.emit("checkToken"); this.emit("checkToken");
} };
private doCheckToken = async () => { private doCheckToken = async () => {
const { accessToken, refreshToken } = this.tokenStore; const { accessToken, refreshToken } = this.tokenStore;
accessToken.updateCurrentTime(); accessToken.updateCurrentTime();
if (accessToken.isValid) { // if the access token is valid, we are good if (accessToken.isValid) {
this.emit("hasToken"); // if the access token is valid, we are good
return; this.emit("hasToken");
} return;
if (!refreshToken.isValid) { // if the refresh token is not valid, need to login again }
this.history.push("/login"); if (!refreshToken.isValid) {
return; // if the refresh token is not valid, need to login again
} this.history.push("/login");
try { return;
await this.httpApi.grantRefresh(); }
this.emit("hasToken"); try {
} catch (err) { await this.httpApi.grantRefresh();
if (err instanceof ApiError && err.code === ErrorCode.BadToken) { this.emit("hasToken");
log.warn({ err }, "refresh is bad for some reason, erasing"); } catch (err) {
this.tokenStore.clearAll(); if (err instanceof ApiError && err.code === ErrorCode.BadToken) {
this.history.push("/login"); log.warn({ err }, "refresh is bad for some reason, erasing");
} else { this.tokenStore.clearAll();
log.error({ err }, "could not refresh access token"); this.history.push("/login");
// TODO: some kind of error page? } else {
} log.error({ err }, "could not refresh access token");
} // TODO: some kind of error page?
}
} }
};
} }

207
client/state/HttpApi.ts

@ -3,114 +3,145 @@ import { action } from "mobx";
import { TokenStore } from "@client/state/TokenStore"; import { TokenStore } from "@client/state/TokenStore";
import ApiError from "@common/ApiError"; import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode"; 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 log from "@common/logger";
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter"; import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
export { ApiError }; export { ApiError };
interface HttpApiEvents extends DefaultEvents { interface HttpApiEvents extends DefaultEvents {
tokenGranted(response: TokenGrantResponse): void; tokenGranted(response: TokenGrantResponse): void;
error(err: ApiError): void; error(err: ApiError): void;
tokenError(err: ApiError): void; tokenError(err: ApiError): void;
} }
export default class HttpApi extends TypedEventEmitter<HttpApiEvents> { export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
baseUrl: string; baseUrl: string;
tokenStore: TokenStore; tokenStore: TokenStore;
private get authorizationHeader(): {} | { "Authorization": string } { private get authorizationHeader(): {} | { Authorization: string } {
if (!this.tokenStore.accessToken) { if (!this.tokenStore.accessToken) {
return {}; return {};
}
return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` };
} }
return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` };
}
constructor(baseUrl: string = `${location.protocol}//${location.hostname}:${location.port}/api`) { constructor(
super(); baseUrl: string = `${location.protocol}//${location.hostname}:${
while (baseUrl.charAt(baseUrl.length - 1) === "/") { location.port
baseUrl = baseUrl.substring(0, baseUrl.length - 1); }/api`
} ) {
this.baseUrl = baseUrl; super();
while (baseUrl.charAt(baseUrl.length - 1) === "/") {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
this.baseUrl = baseUrl;
this.tokenStore = new TokenStore(); this.tokenStore = new TokenStore();
this.on("error", (err: ApiError) => { this.on("error", (err: ApiError) => {
if (err.code === ErrorCode.BadToken) { if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err); this.emit("tokenError", err);
} }
}); });
this.on("tokenGranted", this.onTokenGranted); this.on("tokenGranted", this.onTokenGranted);
} }
async makeRequest(url: string, options?: RequestInit, body?: any): Promise<any> { async makeRequest(
try { url: string,
options = options || {}; options?: RequestInit,
options = { body?: any
headers: { ): Promise<any> {
"Content-Type": "application/json", try {
...this.authorizationHeader, options = options || {};
...options.headers || {}, options = {
}, headers: {
body: JSON.stringify(body), "Content-Type": "application/json",
...options, ...this.authorizationHeader,
}; ...(options.headers || {})
let response: Response; },
try { body: JSON.stringify(body),
response = await fetch(this.baseUrl + url, options); ...options
} catch (err) { };
throw new ApiError("Http request error", ErrorCode.Internal, err); let response: Response;
} try {
let responseBody: any; response = await fetch(this.baseUrl + url, options);
try { } catch (err) {
responseBody = await response.json() || {}; throw new ApiError("Http request error", ErrorCode.Internal, err);
} catch (e) { }
throw new ApiError("Invalid JSON response", ErrorCode.Internal, e); let responseBody: any;
} try {
if (!response.ok) { responseBody = (await response.json()) || {};
throw new ApiError(responseBody.message || response.statusText, responseBody.code, responseBody.data); } catch (e) {
} throw new ApiError("Invalid JSON response", ErrorCode.Internal, e);
return responseBody; }
} catch (err) { if (!response.ok) {
this.emit("error", err); throw new ApiError(
throw err; responseBody.message || response.statusText,
} responseBody.code,
responseBody.data
);
}
return responseBody;
} catch (err) {
this.emit("error", err);
throw err;
} }
}
async grantPassword(username: string, password: string) { async grantPassword(username: string, password: string) {
const request: TokenGrantPasswordRequest = { const request: TokenGrantPasswordRequest = {
grant_type: "password", username, password, grant_type: "password",
}; username,
const response: TokenGrantResponse = await this.makeRequest("/token/grant", { password
method: "POST", };
}, request); const response: TokenGrantResponse = await this.makeRequest(
this.emit("tokenGranted", response); "/token/grant",
} {
method: "POST"
},
request
);
this.emit("tokenGranted", response);
}
async grantRefresh() { async grantRefresh() {
const { refreshToken } = this.tokenStore; const { refreshToken } = this.tokenStore;
if (!refreshToken.isValid) { if (!refreshToken.isValid) {
throw new ApiError("can not grant refresh with invalid refresh_token"); throw new ApiError("can not grant refresh with invalid refresh_token");
}
const request: TokenGrantRefreshRequest = {
grant_type: "refresh", refresh_token: refreshToken.token!,
};
const response: TokenGrantResponse = await this.makeRequest("/token/grant", {
method: "POST",
}, request);
this.emit("tokenGranted", response);
} }
const request: TokenGrantRefreshRequest = {
grant_type: "refresh",
refresh_token: refreshToken.token!
};
const response: TokenGrantResponse = await this.makeRequest(
"/token/grant",
{
method: "POST"
},
request
);
this.emit("tokenGranted", response);
}
@action.bound @action.bound
private onTokenGranted(response: TokenGrantResponse) { private onTokenGranted(response: TokenGrantResponse) {
this.tokenStore.accessToken.token = response.access_token; this.tokenStore.accessToken.token = response.access_token;
this.tokenStore.refreshToken.token = response.refresh_token; this.tokenStore.refreshToken.token = response.refresh_token;
this.tokenStore.saveLocalStorage(); this.tokenStore.saveLocalStorage();
const { accessToken, refreshToken } = this.tokenStore; const { accessToken, refreshToken } = this.tokenStore;
log.debug({ log.debug(
accessToken: accessToken.claims, refreshToken: refreshToken.claims, {
}, "got new tokens"); accessToken: accessToken.claims,
} refreshToken: refreshToken.claims
},
"got new tokens"
);
}
} }

105
client/state/Token.ts

@ -3,67 +3,76 @@ import * as jwt from "jsonwebtoken";
import { computed, createAtom, IAtom, observable } from "mobx"; import { computed, createAtom, IAtom, observable } from "mobx";
export class Token<TClaims extends TokenClaims = TokenClaims> { export class Token<TClaims extends TokenClaims = TokenClaims> {
@observable token: string | null; @observable
token: string | null;
@computed get claims(): TClaims | null { @computed
if (this.token == null) { get claims(): TClaims | null {
return null; if (this.token == null) {
} return null;
return jwt.decode(this.token) as any;
} }
return jwt.decode(this.token) as any;
}
private isExpiredAtom: IAtom; private isExpiredAtom: IAtom;
private currentTime!: number; private currentTime!: number;
private expirationTimer: number | undefined; private expirationTimer: number | undefined;
constructor(token: string | null = null) { constructor(token: string | null = null) {
this.token = token; this.token = token;
this.isExpiredAtom = createAtom("Token.isExpired", this.isExpiredAtom = createAtom(
this.startUpdating, this.stopUpdating); "Token.isExpired",
this.updateCurrentTime(); this.startUpdating,
} this.stopUpdating
);
this.updateCurrentTime();
}
toJSON() { toJSON() {
return this.token; return this.token;
} }
updateCurrentTime = (reportChanged: boolean = true) => { updateCurrentTime = (reportChanged: boolean = true) => {
if (reportChanged) { if (reportChanged) {
this.isExpiredAtom.reportChanged(); this.isExpiredAtom.reportChanged();
}
this.currentTime = Date.now() / 1000;
} }
this.currentTime = Date.now() / 1000;
};
get remainingTime(): number { get remainingTime(): number {
if (!this.isExpiredAtom.reportObserved()) { if (!this.isExpiredAtom.reportObserved()) {
this.updateCurrentTime(false); this.updateCurrentTime(false);
}
if (this.claims == null || this.claims.exp == null) {
return Number.NEGATIVE_INFINITY;
}
return this.claims.exp - this.currentTime;
} }
if (this.claims == null || this.claims.exp == null) {
private startUpdating = () => { return Number.NEGATIVE_INFINITY;
this.stopUpdating();
const remaining = this.remainingTime;
if (remaining > 0) {
this.expirationTimer = setTimeout(this.updateCurrentTime, this.remainingTime);
}
} }
return this.claims.exp - this.currentTime;
}
private stopUpdating = () => { private startUpdating = () => {
if (this.expirationTimer != null) { this.stopUpdating();
clearTimeout(this.expirationTimer); const remaining = this.remainingTime;
this.expirationTimer = undefined; if (remaining > 0) {
} this.expirationTimer = setTimeout(
this.updateCurrentTime,
this.remainingTime
);
} }
};
get isExpired() { private stopUpdating = () => {
return this.remainingTime <= 0; if (this.expirationTimer != null) {
clearTimeout(this.expirationTimer);
this.expirationTimer = undefined;
} }
};
@computed get isValid() { get isExpired() {
return this.token != null && !this.isExpired; return this.remainingTime <= 0;
} }
@computed
get isValid() {
return this.token != null && !this.isExpired;
}
} }

84
client/state/TokenStore.ts

@ -6,43 +6,51 @@ import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims";
const LOCAL_STORAGE_KEY = "TokenStore"; const LOCAL_STORAGE_KEY = "TokenStore";
export class TokenStore { export class TokenStore {
@observable accessToken: Token<AccessToken & BaseClaims> = new Token(); @observable
@observable refreshToken: Token<RefreshToken & BaseClaims> = new Token(); accessToken: Token<AccessToken & BaseClaims> = new Token();
@observable
@action refreshToken: Token<RefreshToken & BaseClaims> = new Token();
clearAccessToken() {
this.accessToken.token = null; @action
this.saveLocalStorage(); clearAccessToken() {
} this.accessToken.token = null;
this.saveLocalStorage();
@action }
clearAll() {
this.accessToken.token = null; @action
this.refreshToken.token = null; clearAll() {
this.saveLocalStorage(); this.accessToken.token = null;
} this.refreshToken.token = null;
this.saveLocalStorage();
@action }
saveLocalStorage() {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.toJSON())); @action
} saveLocalStorage() {
window.localStorage.setItem(
@action LOCAL_STORAGE_KEY,
loadLocalStorage() { JSON.stringify(this.toJSON())
const data = window.localStorage.getItem(LOCAL_STORAGE_KEY); );
if (data) { }
const data2 = JSON.parse(data);
this.updateFromJson(data2); @action
} loadLocalStorage() {
} const data = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (data) {
toJSON() { const data2 = JSON.parse(data);
return { accessToken: this.accessToken.toJSON(), refreshToken: this.refreshToken.toJSON() }; this.updateFromJson(data2);
}
@action
updateFromJson(json: any) {
this.accessToken.token = json.accessToken;
this.refreshToken.token = json.refreshToken;
} }
}
toJSON() {
return {
accessToken: this.accessToken.toJSON(),
refreshToken: this.refreshToken.toJSON()
};
}
@action
updateFromJson(json: any) {
this.accessToken.token = json.accessToken;
this.refreshToken.token = json.refreshToken;
}
} }

42
client/state/UiStore.ts

@ -4,34 +4,34 @@ import { MessageProps } from "semantic-ui-react";
import { getRandomId } from "@common/utils"; import { getRandomId } from "@common/utils";
export interface UiMessage extends MessageProps { export interface UiMessage extends MessageProps {
id: number; id: number;
} }
export interface UiMessageProps extends MessageProps { export interface UiMessageProps extends MessageProps {
timeout?: number; timeout?: number;
} }
export class UiStore { export class UiStore {
messages: IObservableArray<UiMessage> = observable.array(); messages: IObservableArray<UiMessage> = observable.array();
@action @action
addMessage(message: UiMessageProps): UiMessage { addMessage(message: UiMessageProps): UiMessage {
const { timeout, ...otherProps } = message; const { timeout, ...otherProps } = message;
const msg = observable({ const msg = observable({
...otherProps, ...otherProps,
id: getRandomId(), id: getRandomId()
}); });
this.messages.push(msg); this.messages.push(msg);
if (timeout) { if (timeout) {
setTimeout(() => { setTimeout(() => {
this.removeMessage(msg); this.removeMessage(msg);
}, timeout); }, timeout);
}
return msg;
} }
return msg;
}
@action @action
removeMessage(message: UiMessage) { removeMessage(message: UiMessage) {
return this.messages.remove(message); return this.messages.remove(message);
} }
} }

24
client/state/UserStore.ts

@ -2,16 +2,20 @@ import { ISprinklersDevice, IUser } from "@common/httpApi";
import { action, observable } from "mobx"; import { action, observable } from "mobx";
export class UserStore { export class UserStore {
@observable userData: IUser | null = null; @observable
userData: IUser | null = null;
@action.bound @action.bound
receiveUserData(userData: IUser) { receiveUserData(userData: IUser) {
this.userData = userData; this.userData = userData;
} }
findDevice(id: number): ISprinklersDevice | null { findDevice(id: number): ISprinklersDevice | null {
return this.userData && return (
this.userData.devices && (this.userData &&
this.userData.devices.find((dev) => dev.id === id) || null; this.userData.devices &&
} this.userData.devices.find(dev => dev.id === id)) ||
null
);
}
} }

67
client/state/reactContext.tsx

@ -5,47 +5,52 @@ import { AppState } from "@client/state";
const StateContext = React.createContext<AppState | null>(null); const StateContext = React.createContext<AppState | null>(null);
export interface ProvideStateProps { export interface ProvideStateProps {
state: AppState; state: AppState;
children: React.ReactNode; children: React.ReactNode;
} }
export function ProvideState({ state, children }: ProvideStateProps) { export function ProvideState({ state, children }: ProvideStateProps) {
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>{children}</StateContext.Provider>
{children} );
</StateContext.Provider>
);
} }
export interface ConsumeStateProps { export interface ConsumeStateProps {
children: (state: AppState) => React.ReactNode; children: (state: AppState) => React.ReactNode;
} }
export function ConsumeState({ children }: ConsumeStateProps) { export function ConsumeState({ children }: ConsumeStateProps) {
const consumeState = (state: AppState | null) => { const consumeState = (state: AppState | null) => {
if (state == 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>; return children(state);
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
} }
type Diff<T extends string | number | symbol, U extends string | number | symbol> = type Diff<
({[P in T]: P } & {[P in U]: never } & { [x: string]: never })[T]; T extends string | number | symbol,
type Omit<T, K extends keyof T> = {[P in Diff<keyof T, K>]: T[P]}; U extends string | number | symbol
> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T];
export function injectState<P extends { appState: AppState }>(Component: React.ComponentType<P>): type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };
React.ComponentClass<Omit<P, "appState">> {
return class extends React.Component<Omit<P, "appState">> { export function injectState<P extends { appState: AppState }>(
render() { Component: React.ComponentType<P>
const consumeState = (state: AppState | null) => { ): React.ComponentClass<Omit<P, "appState">> {
if (state == null) { return class extends React.Component<Omit<P, "appState">> {
throw new Error("Component with injectState must be mounted inside ProvideState"); render() {
} const consumeState = (state: AppState | null) => {
return <Component {...this.props} appState={state}/>; if (state == null) {
}; throw new Error(
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>; "Component with injectState must be mounted inside ProvideState"
);
} }
}; return <Component {...this.props} appState={state} />;
};
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
}
};
} }

13
client/styles/DeviceView.scss

@ -11,35 +11,34 @@
} }
.connectionState { .connectionState {
@media only screen and (min-width: 768px) { @media only screen and (min-width: 768px) {
margin-left: .75em; margin-left: 0.75em;
} }
font-size: .75em; font-size: 0.75em;
font-weight: lighter; font-weight: lighter;
&.connected { &.connected {
color: #13D213; color: #13d213;
} }
&.disconnected { &.disconnected {
color: #D20000; color: #d20000;
} }
} }
} }
.section--number, .section--number,
.program--number { .program--number {
width: 2em width: 2em;
} }
.section--name /*, .section--name /*,
.program--name*/ .program--name*/
{ {
width: 10em; width: 10em;
white-space: nowrap; white-space: nowrap;
} }
.section--state { .section--state {
} }
.section-state.running { .section-state.running {

10
client/styles/DurationView.scss

@ -1,4 +1,4 @@
$durationInput-spacing: 1.0em; $durationInput-spacing: 1em;
$durationInput-inputWidth: 4em; $durationInput-inputWidth: 4em;
$durationInput-labelWidth: 2.5em; $durationInput-labelWidth: 2.5em;
@ -10,7 +10,7 @@ $durationInput-labelWidth: 2.5em;
width: auto; width: auto;
.ui.input.durationInput>input { .ui.input.durationInput > input {
width: 0 !important; width: 0 !important;
} }
@ -22,7 +22,7 @@ $durationInput-labelWidth: 2.5em;
flex-shrink: 0; flex-shrink: 0;
flex-grow: 1; flex-grow: 1;
>input { > input {
min-width: $durationInput-inputWidth; min-width: $durationInput-inputWidth;
width: auto; width: auto;
flex-basis: $durationInput-inputWidth; flex-basis: $durationInput-inputWidth;
@ -30,7 +30,7 @@ $durationInput-labelWidth: 2.5em;
flex-shrink: 0; flex-shrink: 0;
} }
>.label { > .label {
min-width: $durationInput-labelWidth; min-width: $durationInput-labelWidth;
width: $durationInput-labelWidth; width: $durationInput-labelWidth;
flex: $durationInput-labelWidth; flex: $durationInput-labelWidth;
@ -39,4 +39,4 @@ $durationInput-labelWidth: 2.5em;
text-align: center; text-align: center;
} }
} }
} }

2
client/styles/ProgramSequenceView.scss

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

15
client/styles/ScheduleView.scss

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

6
client/styles/SectionRunnerView.scss

@ -5,13 +5,13 @@
} }
.sectionRunner--pausedState { .sectionRunner--pausedState {
padding-left: .75em; padding-left: 0.75em;
font-size: .75em; font-size: 0.75em;
font-weight: lighter; font-weight: lighter;
} }
.sectionRunner--pausedState > .fa { .sectionRunner--pausedState > .fa {
padding-right: .2em; padding-right: 0.2em;
} }
.sectionRunner--pausedState-unpaused { .sectionRunner--pausedState-unpaused {

8
client/tsconfig.json

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

98
client/webpack.config.js

@ -11,7 +11,9 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HappyPack = require("happypack"); const HappyPack = require("happypack");
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
const {getClientEnvironment} = require("./env"); const {
getClientEnvironment
} = require("./env");
const paths = require("../paths"); const paths = require("../paths");
// Webpack uses `publicPath` to determine where the app is being served from. // Webpack uses `publicPath` to determine where the app is being served from.
@ -83,49 +85,48 @@ const rules = (env) => {
sassConfig, sassConfig,
], ],
}; };
return [ return [{
{ // "oneOf" will traverse all following loaders until one will
// "oneOf" will traverse all following loaders until one will // match the requirements. when no loader matches it will fall
// match the requirements. when no loader matches it will fall // back to the "file" loader at the end of the loader list.
// back to the "file" loader at the end of the loader list. oneOf: [
oneOf: [ // "url" loader works like "file" loader except that it embeds assets
// "url" loader works like "file" loader except that it embeds assets // smaller than specified limit in bytes as data urls to avoid requests.
// smaller than specified limit in bytes as data urls to avoid requests. // a missing `test` is equivalent to a match.
// a missing `test` is equivalent to a match. {
{ test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], loader: require.resolve("url-loader"),
loader: require.resolve("url-loader"), options: {
options: { limit: (env === "prod") ? 10000 : 0,
limit: (env === "prod") ? 10000 : 0, name: "static/media/[name].[hash:8].[ext]",
name: "static/media/[name].[hash:8].[ext]",
},
},
cssRule,
sassRule,
// Process TypeScript with TSC through HappyPack.
{
test: /\.tsx?$/, use: "happypack/loader?id=ts",
include: [ paths.clientDir, paths.commonDir ],
}, },
// "file" loader makes sure those assets get served by WebpackDevServer. },
// When you `import` an asset, you get its (virtual) filename. cssRule,
// In production, they would get copied to the `build` folder. sassRule,
// This loader doesn"t use a "test" so it will catch all modules // Process TypeScript with TSC through HappyPack.
// that fall through the other loaders. {
{ test: /\.tsx?$/,
// Exclude `js` files to keep "css" loader working as it injects use: "happypack/loader?id=ts",
// it"s runtime that would otherwise processed through "file" loader. include: [paths.clientDir, paths.commonDir],
// Also exclude `html` and `json` extensions so they get processed },
// by webpacks internal loaders. // "file" loader makes sure those assets get served by WebpackDevServer.
exclude: [/\.js$/, /\.html$/, /\.json$/], // When you `import` an asset, you get its (virtual) filename.
loader: require.resolve("file-loader"), // In production, they would get copied to the `build` folder.
options: { // This loader doesn"t use a "test" so it will catch all modules
name: "static/media/[name].[hash:8].[ext]", // that fall through the other loaders.
}, {
// Exclude `js` files to keep "css" loader working as it injects
// it"s runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.js$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/media/[name].[hash:8].[ext]",
}, },
], },
}, ],
]; }, ];
} }
@ -200,8 +201,7 @@ const getConfig = module.exports = (env) => {
mode: isProd ? "production" : "development", mode: isProd ? "production" : "development",
bail: isProd, bail: isProd,
devtool: shouldUseSourceMap ? devtool: shouldUseSourceMap ?
isProd ? "source-map" : "inline-source-map" : isProd ? "source-map" : "inline-source-map" : false,
false,
entry: [ entry: [
isDev && require.resolve("react-hot-loader/patch"), isDev && require.resolve("react-hot-loader/patch"),
isDev && require.resolve("react-dev-utils/webpackHotDevClient"), isDev && require.resolve("react-dev-utils/webpackHotDevClient"),
@ -212,15 +212,13 @@ const getConfig = module.exports = (env) => {
path: paths.clientBuildDir, path: paths.clientBuildDir,
pathinfo: isDev, pathinfo: isDev,
filename: isProd ? filename: isProd ?
'static/js/[name].[chunkhash:8].js' : 'static/js/[name].[chunkhash:8].js' : "static/js/bundle.js",
"static/js/bundle.js",
chunkFilename: isProd ? chunkFilename: isProd ?
'static/js/[name].[chunkhash:8].chunk.js' : 'static/js/[name].[chunkhash:8].chunk.js' : "static/js/[name].chunk.js",
"static/js/[name].chunk.js",
publicPath: publicPath, publicPath: publicPath,
devtoolModuleFilenameTemplate: isDev ? devtoolModuleFilenameTemplate: isDev ?
(info) => (info) =>
"webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined, "webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined,
}, },
resolve: { resolve: {
extensions: [".ts", ".tsx", ".js", ".json", ".scss"], extensions: [".ts", ".tsx", ".js", ".json", ".scss"],
@ -247,4 +245,4 @@ const getConfig = module.exports = (env) => {
}], }],
}, },
} }
}; };

44
common/ApiError.ts

@ -1,26 +1,32 @@
import { ErrorCode, toHttpStatus } from "@common/ErrorCode"; import { ErrorCode, toHttpStatus } from "@common/ErrorCode";
export default class ApiError extends Error { export default class ApiError extends Error {
name = "ApiError"; name = "ApiError";
statusCode: number; statusCode: number;
code: ErrorCode; code: ErrorCode;
data: any; data: any;
constructor(message: string, code: ErrorCode = ErrorCode.BadRequest, data: any = {}) { constructor(
super(message); message: string,
this.statusCode = toHttpStatus(code); code: ErrorCode = ErrorCode.BadRequest,
this.code = code; data: any = {}
// tslint:disable-next-line:prefer-conditional-expression ) {
if (data instanceof Error) { super(message);
this.data = data.toString(); this.statusCode = toHttpStatus(code);
} else { this.code = code;
this.data = data; // tslint:disable-next-line:prefer-conditional-expression
} if (data instanceof Error) {
this.data = data.toString();
} else {
this.data = data;
} }
}
toJSON() { toJSON() {
return { return {
message: this.message, code: this.code, data: this.data, message: this.message,
}; code: this.code,
} data: this.data
};
}
} }

62
common/Duration.ts

@ -1,41 +1,41 @@
export class Duration { export class Duration {
static fromSeconds(seconds: number): Duration { static fromSeconds(seconds: number): Duration {
return new Duration(Math.floor(seconds / 60), seconds % 60); return new Duration(Math.floor(seconds / 60), seconds % 60);
} }
minutes: number = 0; minutes: number = 0;
seconds: number = 0; seconds: number = 0;
constructor(minutes: number = 0, seconds: number = 0) { constructor(minutes: number = 0, seconds: number = 0) {
this.minutes = minutes; this.minutes = minutes;
this.seconds = seconds; this.seconds = seconds;
} }
toSeconds(): number { toSeconds(): number {
return this.minutes * 60 + this.seconds; return this.minutes * 60 + this.seconds;
} }
withSeconds(newSeconds: number): Duration { withSeconds(newSeconds: number): Duration {
let newMinutes = this.minutes; let newMinutes = this.minutes;
if (newSeconds >= 60) { if (newSeconds >= 60) {
newMinutes++; newMinutes++;
newSeconds = 0; newSeconds = 0;
}
if (newSeconds < 0) {
newMinutes = Math.max(0, newMinutes - 1);
newSeconds = 59;
}
return new Duration(newMinutes, newSeconds);
} }
if (newSeconds < 0) {
withMinutes(newMinutes: number): Duration { newMinutes = Math.max(0, newMinutes - 1);
if (newMinutes < 0) { newSeconds = 59;
newMinutes = 0;
}
return new Duration(newMinutes, this.seconds);
} }
return new Duration(newMinutes, newSeconds);
}
toString(): string { withMinutes(newMinutes: number): Duration {
return `${this.minutes}M ${this.seconds.toFixed(1)}S`; if (newMinutes < 0) {
newMinutes = 0;
} }
return new Duration(newMinutes, this.seconds);
}
toString(): string {
return `${this.minutes}M ${this.seconds.toFixed(1)}S`;
}
} }

72
common/ErrorCode.ts

@ -1,41 +1,41 @@
export enum ErrorCode { export enum ErrorCode {
BadRequest = 100, BadRequest = 100,
NotSpecified = 101, NotSpecified = 101,
Parse = 102, Parse = 102,
Range = 103, Range = 103,
InvalidData = 104, InvalidData = 104,
BadToken = 105, BadToken = 105,
Unauthorized = 106, Unauthorized = 106,
NoPermission = 107, NoPermission = 107,
NotImplemented = 108, NotImplemented = 108,
NotFound = 109, NotFound = 109,
Internal = 200, Internal = 200,
Timeout = 300, Timeout = 300,
ServerDisconnected = 301, ServerDisconnected = 301,
BrokerDisconnected = 302, BrokerDisconnected = 302
} }
export function toHttpStatus(errorCode: ErrorCode): number { export function toHttpStatus(errorCode: ErrorCode): number {
switch (errorCode) { switch (errorCode) {
case ErrorCode.BadRequest: case ErrorCode.BadRequest:
case ErrorCode.NotSpecified: case ErrorCode.NotSpecified:
case ErrorCode.Parse: case ErrorCode.Parse:
case ErrorCode.Range: case ErrorCode.Range:
case ErrorCode.InvalidData: case ErrorCode.InvalidData:
return 400; // Bad request return 400; // Bad request
case ErrorCode.Unauthorized: case ErrorCode.Unauthorized:
case ErrorCode.BadToken: case ErrorCode.BadToken:
return 401; // Unauthorized return 401; // Unauthorized
case ErrorCode.NoPermission: case ErrorCode.NoPermission:
return 403; // Forbidden return 403; // Forbidden
case ErrorCode.NotFound: case ErrorCode.NotFound:
return 404; return 404;
case ErrorCode.NotImplemented: case ErrorCode.NotImplemented:
return 501; return 501;
case ErrorCode.Internal: case ErrorCode.Internal:
case ErrorCode.ServerDisconnected: case ErrorCode.ServerDisconnected:
case ErrorCode.BrokerDisconnected: case ErrorCode.BrokerDisconnected:
default: default:
return 500; return 500;
} }
} }

33
common/TokenClaims.ts

@ -1,34 +1,39 @@
export interface BaseClaims { export interface BaseClaims {
iss: string; iss: string;
exp?: number; exp?: number;
} }
export interface AccessToken { export interface AccessToken {
type: "access"; type: "access";
aud: number; aud: number;
name: string; name: string;
} }
export interface RefreshToken { export interface RefreshToken {
type: "refresh"; type: "refresh";
aud: number; aud: number;
name: string; name: string;
} }
export interface DeviceRegistrationToken { export interface DeviceRegistrationToken {
type: "device_reg"; type: "device_reg";
} }
export interface DeviceToken { export interface DeviceToken {
type: "device"; type: "device";
aud: string; aud: string;
id: number; id: number;
} }
export interface SuperuserToken { export interface SuperuserToken {
type: "superuser"; type: "superuser";
} }
export type TokenClaimTypes = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken; export type TokenClaimTypes =
| AccessToken
| RefreshToken
| DeviceRegistrationToken
| DeviceToken
| SuperuserToken;
export type TokenClaims = TokenClaimTypes & BaseClaims; export type TokenClaims = TokenClaimTypes & BaseClaims;

96
common/TypedEventEmitter.ts

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

34
common/httpApi/index.ts

@ -1,31 +1,33 @@
export interface TokenGrantPasswordRequest { export interface TokenGrantPasswordRequest {
grant_type: "password"; grant_type: "password";
username: string; username: string;
password: string; password: string;
} }
export interface TokenGrantRefreshRequest { export interface TokenGrantRefreshRequest {
grant_type: "refresh"; grant_type: "refresh";
refresh_token: string; refresh_token: string;
} }
export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshRequest; export type TokenGrantRequest =
| TokenGrantPasswordRequest
| TokenGrantRefreshRequest;
export interface TokenGrantResponse { export interface TokenGrantResponse {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
} }
export interface IUser { export interface IUser {
id: number; id: number;
username: string; username: string;
name: string; name: string;
devices: ISprinklersDevice[] | undefined; devices: ISprinklersDevice[] | undefined;
} }
export interface ISprinklersDevice { export interface ISprinklersDevice {
id: number; id: number;
deviceId: string | null; deviceId: string | null;
name: string; name: string;
users: IUser[] | undefined; users: IUser[] | undefined;
} }

239
common/jsonRpc/index.ts

@ -2,9 +2,9 @@
export type DefaultRequestTypes = {}; export type DefaultRequestTypes = {};
export type DefaultResponseTypes = {}; export type DefaultResponseTypes = {};
export type DefaultErrorType = { export type DefaultErrorType = {
code: number; code: number;
message: string; message: string;
data?: any; data?: any;
}; };
export type DefaultNotificationTypes = {}; export type DefaultNotificationTypes = {};
// tslint:enable:interface-over-type-literal // tslint:enable:interface-over-type-literal
@ -16,145 +16,196 @@ export type DefaultNotificationTypes = {};
// ErrorType: DefaultErrorType; // ErrorType: DefaultErrorType;
// } // }
export interface Request<RequestTypes = DefaultRequestTypes, export interface Request<
Method extends keyof RequestTypes = keyof RequestTypes> { RequestTypes = DefaultRequestTypes,
type: "request"; Method extends keyof RequestTypes = keyof RequestTypes
id: number; > {
method: Method; type: "request";
params: RequestTypes[Method]; id: number;
method: Method;
params: RequestTypes[Method];
} }
export interface ResponseBase<Method> { export interface ResponseBase<Method> {
type: "response"; type: "response";
id: number; id: number;
method: Method; method: Method;
} }
export interface SuccessData<ResponseType> { export interface SuccessData<ResponseType> {
result: "success"; result: "success";
data: ResponseType; data: ResponseType;
} }
export interface ErrorData<ErrorType> { export interface ErrorData<ErrorType> {
result: "error"; result: "error";
error: ErrorType; error: ErrorType;
} }
export type ResponseData<ResponseTypes, ErrorType, export type ResponseData<
Method extends keyof ResponseTypes = keyof ResponseTypes> = ResponseTypes,
SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>; ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes
export type Response<ResponseTypes, > = SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>;
ErrorType = DefaultErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes> = export type Response<
ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>; ResponseTypes,
ErrorType = DefaultErrorType,
export interface Notification<NotificationTypes = DefaultNotificationTypes, Method extends keyof ResponseTypes = keyof ResponseTypes
Method extends keyof NotificationTypes = keyof NotificationTypes> { > = ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>;
type: "notification";
method: Method; export interface Notification<
data: NotificationTypes[Method]; NotificationTypes = DefaultNotificationTypes,
Method extends keyof NotificationTypes = keyof NotificationTypes
> {
type: "notification";
method: Method;
data: NotificationTypes[Method];
} }
export type Message<RequestTypes = DefaultRequestTypes, export type Message<
ResponseTypes = DefaultResponseTypes, RequestTypes = DefaultRequestTypes,
ErrorType = DefaultErrorType, ResponseTypes = DefaultResponseTypes,
NotificationTypes = DefaultNotificationTypes> = ErrorType = DefaultErrorType,
Request<RequestTypes> | NotificationTypes = DefaultNotificationTypes
Response<ResponseTypes, ErrorType> | > =
Notification<NotificationTypes>; | Request<RequestTypes>
| Response<ResponseTypes, ErrorType>
| Notification<NotificationTypes>;
// export type TypesMessage<Types extends RpcTypes = RpcTypes> = // export type TypesMessage<Types extends RpcTypes = RpcTypes> =
// Message<Types["RequestTypes"], Types["ResponseTypes"], Types["ErrorType"], Types["NotificationTypes"]>; // Message<Types["RequestTypes"], Types["ResponseTypes"], Types["ErrorType"], Types["NotificationTypes"]>;
export function isRequestMethod<Method extends keyof RequestTypes, RequestTypes>( export function isRequestMethod<
message: Request<RequestTypes>, method: Method, Method extends keyof RequestTypes,
RequestTypes
>(
message: Request<RequestTypes>,
method: Method
): message is Request<RequestTypes, Method> { ): message is Request<RequestTypes, Method> {
return message.method === method; return message.method === method;
} }
export function isResponseMethod<Method extends keyof ResponseTypes, ErrorType, ResponseTypes>( export function isResponseMethod<
message: Response<ResponseTypes, ErrorType>, method: Method, Method extends keyof ResponseTypes,
ErrorType,
ResponseTypes
>(
message: Response<ResponseTypes, ErrorType>,
method: Method
): message is Response<ResponseTypes, ErrorType, Method> { ): message is Response<ResponseTypes, ErrorType, Method> {
return message.method === method; return message.method === method;
} }
export function isNotificationMethod<Method extends keyof NotificationTypes, NotificationTypes = any>( export function isNotificationMethod<
message: Notification<NotificationTypes>, method: Method, Method extends keyof NotificationTypes,
NotificationTypes = any
>(
message: Notification<NotificationTypes>,
method: Method
): message is Notification<NotificationTypes, Method> { ): message is Notification<NotificationTypes, Method> {
return message.method === method; return message.method === method;
} }
export type IRequestHandler<RequestTypes, ResponseTypes extends { [M in Method]: any }, ErrorType, export type IRequestHandler<
Method extends keyof RequestTypes> = RequestTypes,
(request: RequestTypes[Method]) => Promise<ResponseData<ResponseTypes, ErrorType, Method>>; ResponseTypes extends { [M in Method]: any },
ErrorType,
export type RequestHandlers<RequestTypes, ResponseTypes extends { [M in keyof RequestTypes]: any }, ErrorType> = { Method extends keyof RequestTypes
[Method in keyof RequestTypes]: > = (
IRequestHandler<RequestTypes, ResponseTypes, ErrorType, Method>; 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, export type IResponseHandler<
Method extends keyof ResponseTypes = keyof ResponseTypes> = ResponseTypes,
(response: ResponseData<ResponseTypes, ErrorType, Method>) => void; ErrorType,
Method extends keyof ResponseTypes = keyof ResponseTypes
export interface ResponseHandlers<ResponseTypes = DefaultResponseTypes, ErrorType = DefaultErrorType> { > = (response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
[id: number]: IResponseHandler<ResponseTypes, ErrorType>;
export interface ResponseHandlers<
ResponseTypes = DefaultResponseTypes,
ErrorType = DefaultErrorType
> {
[id: number]: IResponseHandler<ResponseTypes, ErrorType>;
} }
export type NotificationHandler<NotificationTypes, Method extends keyof NotificationTypes> = export type NotificationHandler<
(notification: NotificationTypes[Method]) => void; NotificationTypes,
Method extends keyof NotificationTypes
> = (notification: NotificationTypes[Method]) => void;
export type NotificationHandlers<NotificationTypes> = { export type NotificationHandlers<NotificationTypes> = {
[Method in keyof NotificationTypes]: NotificationHandler<NotificationTypes, Method>; [Method in keyof NotificationTypes]: NotificationHandler<
NotificationTypes,
Method
>
}; };
export function listRequestHandlerMethods<RequestTypes, export function listRequestHandlerMethods<
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>( RequestTypes,
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>, ResponseTypes extends { [Method in keyof RequestTypes]: any },
ErrorType
>(
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>
): Array<keyof RequestTypes> { ): Array<keyof RequestTypes> {
return Object.keys(handlers) as any; return Object.keys(handlers) as any;
} }
export function listNotificationHandlerMethods<NotificationTypes>( export function listNotificationHandlerMethods<NotificationTypes>(
handlers: NotificationHandlers<NotificationTypes>, handlers: NotificationHandlers<NotificationTypes>
): Array<keyof NotificationTypes> { ): Array<keyof NotificationTypes> {
return Object.keys(handlers) as any; return Object.keys(handlers) as any;
} }
export async function handleRequest<RequestTypes, export async function handleRequest<
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>( RequestTypes,
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>, ResponseTypes extends { [Method in keyof RequestTypes]: any },
message: Request<RequestTypes>, ErrorType
thisParam?: any, >(
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
message: Request<RequestTypes>,
thisParam?: any
): Promise<ResponseData<ResponseTypes, ErrorType>> { ): Promise<ResponseData<ResponseTypes, ErrorType>> {
const handler = handlers[message.method]; const handler = handlers[message.method];
if (!handler) { if (!handler) {
throw new Error("No handler for request method " + message.method); throw new Error("No handler for request method " + message.method);
} }
return handler.call(thisParam, message.params); return handler.call(thisParam, message.params);
} }
export function handleResponse<ResponseTypes, ErrorType>( export function handleResponse<ResponseTypes, ErrorType>(
handlers: ResponseHandlers<ResponseTypes, ErrorType>, handlers: ResponseHandlers<ResponseTypes, ErrorType>,
message: Response<ResponseTypes, ErrorType>, message: Response<ResponseTypes, ErrorType>,
thisParam?: any, thisParam?: any
) { ) {
const handler = handlers[message.id]; const handler = handlers[message.id];
if (!handler) { if (!handler) {
return; return;
} }
return handler.call(thisParam, message); return handler.call(thisParam, message);
} }
export function handleNotification<NotificationTypes>( export function handleNotification<NotificationTypes>(
handlers: NotificationHandlers<NotificationTypes>, handlers: NotificationHandlers<NotificationTypes>,
message: Notification<NotificationTypes>, message: Notification<NotificationTypes>,
thisParam?: any, thisParam?: any
) { ) {
const handler = handlers[message.method]; const handler = handlers[message.method];
if (!handler) { if (!handler) {
throw new Error("No handler for notification method " + message.method); throw new Error("No handler for notification method " + message.method);
} }
return handler.call(thisParam, message.data); return handler.call(thisParam, message.data);
} }

166
common/logger.ts

@ -4,112 +4,124 @@ import * as pino from "pino";
type Level = "default" | "60" | "50" | "40" | "30" | "20" | "10"; type Level = "default" | "60" | "50" | "40" | "30" | "20" | "10";
const levels: {[level in Level]: string } = { const levels: { [level in Level]: string } = {
default: "USERLVL", default: "USERLVL",
60: "FATAL", 60: "FATAL",
50: "ERROR", 50: "ERROR",
40: "WARN", 40: "WARN",
30: "INFO", 30: "INFO",
20: "DEBUG", 20: "DEBUG",
10: "TRACE", 10: "TRACE"
}; };
const levelColors: {[level in Level]: string } = { const levelColors: { [level in Level]: string } = {
default: "text-decoration: underline; color: #000000;", default: "text-decoration: underline; color: #000000;",
60: "text-decoration: underline; background-color: #FF0000;", 60: "text-decoration: underline; background-color: #FF0000;",
50: "text-decoration: underline; color: #FF0000;", 50: "text-decoration: underline; color: #FF0000;",
40: "text-decoration: underline; color: #FFFF00;", 40: "text-decoration: underline; color: #FFFF00;",
30: "text-decoration: underline; color: #00FF00;", 30: "text-decoration: underline; color: #00FF00;",
20: "text-decoration: underline; color: #0000FF;", 20: "text-decoration: underline; color: #0000FF;",
10: "text-decoration: underline; color: #AAAAAA;", 10: "text-decoration: underline; color: #AAAAAA;"
}; };
interface ColoredString { interface ColoredString {
str: string; str: string;
args: any[]; args: any[];
} }
function makeColored(str: string = ""): ColoredString { function makeColored(str: string = ""): ColoredString {
return { str, args: [] }; return { str, args: [] };
} }
function concatColored(...coloredStrings: ColoredString[]): ColoredString { function concatColored(...coloredStrings: ColoredString[]): ColoredString {
return coloredStrings.reduce((prev, cur) => ({ return coloredStrings.reduce(
str: prev.str + cur.str, (prev, cur) => ({
args: prev.args.concat(cur.args), str: prev.str + cur.str,
}), 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) { function write(value: any) {
let line = concatColored( let line = concatColored(
// makeColored(formatTime(value, " ")), // makeColored(formatTime(value, " ")),
formatSource(value), formatSource(value),
formatLevel(value), formatLevel(value),
makeColored(": "), makeColored(": ")
); );
if (value.msg) { if (value.msg) {
line = concatColored(line, { 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([ const args = [line.str]
(value.type === "Error") ? value.stack : filter(value), .concat(line.args)
]); .concat([value.type === "Error" ? value.stack : filter(value)]);
let fn; let fn;
if (value.level >= 50) { if (value.level >= 50) {
fn = console.error; fn = console.error;
} else if (value.level >= 40) { } else if (value.level >= 40) {
fn = console.warn; fn = console.warn;
} else { } else {
fn = console.log; fn = console.log;
} }
fn.apply(null, args); fn.apply(null, args);
} }
function filter(value: any) { function filter(value: any) {
const keys = Object.keys(value); const keys = Object.keys(value);
const result: any = {}; const result: any = {};
for (const key of keys) { for (const key of keys) {
if (standardKeys.indexOf(key) < 0) { if (standardKeys.indexOf(key) < 0) {
result[key] = value[key]; result[key] = value[key];
}
} }
}
return result; return result;
} }
function formatSource(value: any): { str: string, args: any[] } { function formatSource(value: any): { str: string; args: any[] } {
if (value.source) { if (value.source) {
return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] }; return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] };
} else { } else {
return { str: "", args: [] }; return { str: "", args: [] };
} }
} }
function formatLevel(value: any): ColoredString { function formatLevel(value: any): ColoredString {
const level = value.level as Level; const level = value.level as Level;
if (levelColors.hasOwnProperty(level)) { if (levelColors.hasOwnProperty(level)) {
return { return {
str: "%c" + levels[level] + "%c", str: "%c" + levels[level] + "%c",
args: [levelColors[level], ""], args: [levelColors[level], ""]
}; };
} else { } else {
return { return {
str: levels.default, str: levels.default,
args: [levelColors.default], args: [levelColors.default]
}; };
} }
} }
const logger: pino.Logger = pino({ const logger: pino.Logger = pino({
serializers: pino.stdSerializers, serializers: pino.stdSerializers,
browser: { serialize: true, write }, browser: { serialize: true, write },
level: "trace", level: "trace"
}); });
export default logger; export default logger;

126
common/sprinklersRpc/ConnectionState.ts

@ -1,73 +1,81 @@
import { computed, observable } from "mobx"; import { computed, observable } from "mobx";
export class ConnectionState { export class ConnectionState {
/** /**
* Represents if a client is connected to the sprinklers3 server (eg. via websocket) * Represents if a client is connected to the sprinklers3 server (eg. via websocket)
* Can be null if there is no client involved * 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) * Represents if the sprinklers3 server is connected to the broker (eg. via mqtt)
* Can be null if there is no broker involved * 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) * 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 * 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. * Represents if whoever is trying to access this device has permission to access it.
* Is null if there is no concept of access involved. * Is null if there is no concept of access involved.
*/ */
@observable hasPermission: boolean | null = null; @observable
hasPermission: boolean | null = null;
@computed get noPermission() { @computed
return this.hasPermission === false; get noPermission() {
} return this.hasPermission === false;
}
@computed get isAvailable(): boolean { @computed
if (this.hasPermission === false) { get isAvailable(): boolean {
return false; if (this.hasPermission === false) {
} return false;
if (this.brokerToDevice != null) { }
return true; if (this.brokerToDevice != null) {
} return true;
if (this.serverToBroker != null) { }
return this.serverToBroker; if (this.serverToBroker != null) {
} return this.serverToBroker;
if (this.clientToServer != null) { }
return this.clientToServer; if (this.clientToServer != null) {
} return this.clientToServer;
return false;
} }
return false;
}
@computed get isDeviceConnected(): boolean | null { @computed
if (this.hasPermission === false) { get isDeviceConnected(): boolean | null {
return false; if (this.hasPermission === false) {
} return false;
if (this.serverToBroker === false || this.clientToServer === false) {
return null;
}
if (this.brokerToDevice != null) {
return this.brokerToDevice;
}
return null;
} }
if (this.serverToBroker === false || this.clientToServer === false) {
return null;
}
if (this.brokerToDevice != null) {
return this.brokerToDevice;
}
return null;
}
@computed get isServerConnected(): boolean | null { @computed
if (this.hasPermission === false) { get isServerConnected(): boolean | null {
return false; if (this.hasPermission === false) {
} return false;
if (this.serverToBroker != null) { }
return this.serverToBroker; if (this.serverToBroker != null) {
} return this.serverToBroker;
if (this.clientToServer != null) { }
return this.brokerToDevice; if (this.clientToServer != null) {
} return this.brokerToDevice;
return null;
} }
return null;
}
} }

94
common/sprinklersRpc/Program.ts

@ -6,59 +6,69 @@ import * as schema from "./schema";
import { SprinklersDevice } from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
export class ProgramItem { export class ProgramItem {
// the section number // the section number
readonly section!: number; readonly section!: number;
// duration of the run, in seconds // duration of the run, in seconds
readonly duration!: number; readonly duration!: number;
constructor(data?: Partial<ProgramItem>) { constructor(data?: Partial<ProgramItem>) {
if (data) { if (data) {
Object.assign(this, data); Object.assign(this, data);
}
} }
}
} }
export class Program { export class Program {
readonly device: SprinklersDevice; readonly device: SprinklersDevice;
readonly id: number; readonly id: number;
@observable name: string = ""; @observable
@observable enabled: boolean = false; name: string = "";
@observable schedule: Schedule = new Schedule(); @observable
@observable.shallow sequence: ProgramItem[] = []; enabled: boolean = false;
@observable running: boolean = false; @observable
schedule: Schedule = new Schedule();
@observable.shallow
sequence: ProgramItem[] = [];
@observable
running: boolean = false;
constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) { constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) {
this.device = device; this.device = device;
this.id = id; this.id = id;
if (data) { if (data) {
Object.assign(this, data); Object.assign(this, data);
}
} }
}
run() { run() {
return this.device.runProgram({ programId: this.id }); return this.device.runProgram({ programId: this.id });
} }
cancel() { cancel() {
return this.device.cancelProgram({ programId: this.id }); return this.device.cancelProgram({ programId: this.id });
} }
update() { update() {
const data = serialize(schema.program, this); const data = serialize(schema.program, this);
return this.device.updateProgram({ programId: this.id, data }); return this.device.updateProgram({ programId: this.id, data });
} }
clone(): Program { clone(): Program {
return new Program(this.device, this.id, { return new Program(this.device, this.id, {
name: this.name, enabled: this.enabled, running: this.running, name: this.name,
schedule: this.schedule.clone(), enabled: this.enabled,
sequence: this.sequence.slice(), running: this.running,
}); schedule: this.schedule.clone(),
} sequence: this.sequence.slice()
});
}
toString(): string { toString(): string {
return `Program{name="${this.name}", enabled=${this.enabled}, schedule=${this.schedule}, ` + return (
`sequence=${this.sequence}, running=${this.running}}`; `Program{name="${this.name}", enabled=${this.enabled}, schedule=${
} this.schedule
}, ` + `sequence=${this.sequence}, running=${this.running}}`
);
}
} }

30
common/sprinklersRpc/RpcError.ts

@ -2,20 +2,24 @@ import { ErrorCode } from "@common/ErrorCode";
import { IError } from "./websocketData"; import { IError } from "./websocketData";
export class RpcError extends Error implements IError { export class RpcError extends Error implements IError {
name = "RpcError"; name = "RpcError";
code: number; code: number;
data: any; data: any;
constructor(message: string, code: number = ErrorCode.BadRequest, data: any = {}) { constructor(
super(message); message: string,
this.code = code; code: number = ErrorCode.BadRequest,
if (data instanceof Error) { data: any = {}
this.data = data.toString(); ) {
} super(message);
this.data = data; this.code = code;
if (data instanceof Error) {
this.data = data.toString();
} }
this.data = data;
}
toJSON(): IError { toJSON(): IError {
return { code: this.code, message: this.message, data: this.data }; return { code: this.code, message: this.message, data: this.data };
} }
} }

38
common/sprinklersRpc/Section.ts

@ -2,27 +2,29 @@ import { observable } from "mobx";
import { SprinklersDevice } from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
export class Section { export class Section {
readonly device: SprinklersDevice; readonly device: SprinklersDevice;
readonly id: number; readonly id: number;
@observable name: string = ""; @observable
@observable state: boolean = false; name: string = "";
@observable
state: boolean = false;
constructor(device: SprinklersDevice, id: number) { constructor(device: SprinklersDevice, id: number) {
this.device = device; this.device = device;
this.id = id; this.id = id;
} }
/** duration is in seconds */ /** duration is in seconds */
run(duration: number) { run(duration: number) {
return this.device.runSection({ sectionId: this.id, duration }); return this.device.runSection({ sectionId: this.id, duration });
} }
cancel() { cancel() {
return this.device.cancelSection({ sectionId: this.id }); return this.device.cancelSection({ sectionId: this.id });
} }
toString(): string { toString(): string {
return `Section ${this.id}: '${this.name}'`; return `Section ${this.id}: '${this.name}'`;
} }
} }

98
common/sprinklersRpc/SectionRunner.ts

@ -2,57 +2,69 @@ import { observable } from "mobx";
import { SprinklersDevice } from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
export class SectionRun { export class SectionRun {
readonly sectionRunner: SectionRunner; readonly sectionRunner: SectionRunner;
readonly id: number; readonly id: number;
section: number; section: number;
totalDuration: number = 0; totalDuration: number = 0;
duration: number = 0; duration: number = 0;
startTime: Date | null = null; startTime: Date | null = null;
pauseTime: Date | null = null; pauseTime: Date | null = null;
unpauseTime: Date | null = null; unpauseTime: Date | null = null;
constructor(sectionRunner: SectionRunner, id: number = 0, section: number = 0) { constructor(
this.sectionRunner = sectionRunner; sectionRunner: SectionRunner,
this.id = id; id: number = 0,
this.section = section; section: number = 0
} ) {
this.sectionRunner = sectionRunner;
cancel = () => this.sectionRunner.cancelRunById(this.id); this.id = id;
this.section = section;
toString() { }
return `SectionRun{id=${this.id}, section=${this.section}, duration=${this.duration},` +
` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`; cancel = () => this.sectionRunner.cancelRunById(this.id);
}
toString() {
return (
`SectionRun{id=${this.id}, section=${this.section}, duration=${
this.duration
},` + ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`
);
}
} }
export class SectionRunner { export class SectionRunner {
readonly device: SprinklersDevice; readonly device: SprinklersDevice;
@observable queue: SectionRun[] = []; @observable
@observable current: SectionRun | null = null; queue: SectionRun[] = [];
@observable paused: boolean = false; @observable
current: SectionRun | null = null;
@observable
paused: boolean = false;
constructor(device: SprinklersDevice) { constructor(device: SprinklersDevice) {
this.device = device; this.device = device;
} }
cancelRunById(runId: number) { cancelRunById(runId: number) {
return this.device.cancelSectionRunId({ runId }); return this.device.cancelSectionRunId({ runId });
} }
setPaused(paused: boolean) { setPaused(paused: boolean) {
return this.device.pauseSectionRunner({ paused }); return this.device.pauseSectionRunner({ paused });
} }
pause() { pause() {
return this.setPaused(true); return this.setPaused(true);
} }
unpause() { unpause() {
return this.setPaused(false); return this.setPaused(false);
} }
toString(): string { toString(): string {
return `SectionRunner{queue="${this.queue}", current="${this.current}", paused=${this.paused}}`; return `SectionRunner{queue="${this.queue}", current="${
} this.current
}", paused=${this.paused}}`;
}
} }

169
common/sprinklersRpc/SprinklersDevice.ts

@ -7,85 +7,94 @@ import { SectionRunner } from "./SectionRunner";
import { SprinklersRPC } from "./SprinklersRPC"; import { SprinklersRPC } from "./SprinklersRPC";
export abstract class SprinklersDevice { export abstract class SprinklersDevice {
readonly rpc: SprinklersRPC; readonly rpc: SprinklersRPC;
readonly id: string; readonly id: string;
@observable connectionState: ConnectionState = new ConnectionState(); @observable
@observable sections: Section[] = []; connectionState: ConnectionState = new ConnectionState();
@observable programs: Program[] = []; @observable
@observable sectionRunner: SectionRunner; sections: Section[] = [];
@observable
@computed get connected(): boolean { programs: Program[] = [];
return this.connectionState.isDeviceConnected || false; @observable
} sectionRunner: SectionRunner;
sectionConstructor: typeof Section = Section; @computed
sectionRunnerConstructor: typeof SectionRunner = SectionRunner; get connected(): boolean {
programConstructor: typeof Program = Program; return this.connectionState.isDeviceConnected || false;
}
private references: number = 0;
sectionConstructor: typeof Section = Section;
protected constructor(rpc: SprinklersRPC, id: string) { sectionRunnerConstructor: typeof SectionRunner = SectionRunner;
this.rpc = rpc; programConstructor: typeof Program = Program;
this.id = id;
this.sectionRunner = new (this.sectionRunnerConstructor)(this); private references: number = 0;
}
protected constructor(rpc: SprinklersRPC, id: string) {
abstract makeRequest(request: req.Request): Promise<req.Response>; this.rpc = rpc;
this.id = id;
/** this.sectionRunner = new this.sectionRunnerConstructor(this);
* Increase the reference count for this sprinklers device }
* @returns The new reference count
*/ abstract makeRequest(request: req.Request): Promise<req.Response>;
acquire(): number {
return ++this.references; /**
} * Increase the reference count for this sprinklers device
* @returns The new reference count
/** */
* Releases one reference to this device. When the reference count reaches 0, the device acquire(): number {
* will be released and no longer updated. return ++this.references;
* @returns The reference count after being updated }
*/
release(): number { /**
this.references--; * Releases one reference to this device. When the reference count reaches 0, the device
if (this.references <= 0) { * will be released and no longer updated.
this.rpc.releaseDevice(this.id); * @returns The reference count after being updated
} */
return this.references; release(): number {
} this.references--;
if (this.references <= 0) {
runProgram(opts: req.WithProgram) { this.rpc.releaseDevice(this.id);
return this.makeRequest({ ...opts, type: "runProgram" });
}
cancelProgram(opts: req.WithProgram) {
return this.makeRequest({ ...opts, type: "cancelProgram" });
}
updateProgram(opts: req.UpdateProgramData): Promise<req.UpdateProgramResponse> {
return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise<any>;
}
runSection(opts: req.RunSectionData): Promise<req.RunSectionResponse> {
return this.makeRequest({ ...opts, type: "runSection" }) as Promise<any>;
}
cancelSection(opts: req.WithSection) {
return this.makeRequest({ ...opts, type: "cancelSection" });
}
cancelSectionRunId(opts: req.CancelSectionRunIdData) {
return this.makeRequest({ ...opts, type: "cancelSectionRunId" });
}
pauseSectionRunner(opts: req.PauseSectionRunnerData) {
return this.makeRequest({ ...opts, type: "pauseSectionRunner" });
}
toString(): string {
return `SprinklersDevice{id="${this.id}", connected=${this.connected}, ` +
`sections=[${this.sections}], ` +
`programs=[${this.programs}], ` +
`sectionRunner=${this.sectionRunner} }`;
} }
return this.references;
}
runProgram(opts: req.WithProgram) {
return this.makeRequest({ ...opts, type: "runProgram" });
}
cancelProgram(opts: req.WithProgram) {
return this.makeRequest({ ...opts, type: "cancelProgram" });
}
updateProgram(
opts: req.UpdateProgramData
): Promise<req.UpdateProgramResponse> {
return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise<any>;
}
runSection(opts: req.RunSectionData): Promise<req.RunSectionResponse> {
return this.makeRequest({ ...opts, type: "runSection" }) as Promise<any>;
}
cancelSection(opts: req.WithSection) {
return this.makeRequest({ ...opts, type: "cancelSection" });
}
cancelSectionRunId(opts: req.CancelSectionRunIdData) {
return this.makeRequest({ ...opts, type: "cancelSectionRunId" });
}
pauseSectionRunner(opts: req.PauseSectionRunnerData) {
return this.makeRequest({ ...opts, type: "pauseSectionRunner" });
}
toString(): string {
return (
`SprinklersDevice{id="${this.id}", connected=${this.connected}, ` +
`sections=[${this.sections}], ` +
`programs=[${this.programs}], ` +
`sectionRunner=${this.sectionRunner} }`
);
}
} }

44
common/sprinklersRpc/SprinklersRPC.ts

@ -2,30 +2,30 @@ import { ConnectionState } from "./ConnectionState";
import { SprinklersDevice } from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
export abstract class SprinklersRPC { export abstract class SprinklersRPC {
abstract readonly connectionState: ConnectionState; abstract readonly connectionState: ConnectionState;
abstract readonly connected: boolean; abstract readonly connected: boolean;
abstract start(): void; abstract start(): void;
/** /**
* Acquires a reference to a device. This reference must be released by calling * Acquires a reference to a device. This reference must be released by calling
* SprinklersDevice#release for every time this method was called * SprinklersDevice#release for every time this method was called
* @param id The id of the device * @param id The id of the device
*/ */
acquireDevice(id: string): SprinklersDevice { acquireDevice(id: string): SprinklersDevice {
const device = this.getDevice(id); const device = this.getDevice(id);
device.acquire(); device.acquire();
return device; return device;
} }
/** /**
* Forces a device to be released. The device will no longer be updated. * Forces a device to be released. The device will no longer be updated.
* *
* This should not be used normally, instead SprinklersDevice#release should be called to manage * This should not be used normally, instead SprinklersDevice#release should be called to manage
* each reference to a device. * each reference to a device.
* @param id The id of the device to remove * @param id The id of the device to remove
*/ */
abstract releaseDevice(id: string): void; abstract releaseDevice(id: string): void;
protected abstract getDevice(id: string): SprinklersDevice; protected abstract getDevice(id: string): SprinklersDevice;
} }

61
common/sprinklersRpc/deviceRequests.ts

@ -1,17 +1,22 @@
export interface WithType<Type extends string = string> { export interface WithType<Type extends string = string> {
type: Type; type: Type;
} }
export interface WithProgram { programId: number; } export interface WithProgram {
programId: number;
}
export type RunProgramRequest = WithProgram & WithType<"runProgram">; export type RunProgramRequest = WithProgram & WithType<"runProgram">;
export type CancelProgramRequest = WithProgram & WithType<"cancelProgram">; export type CancelProgramRequest = WithProgram & WithType<"cancelProgram">;
export type UpdateProgramData = WithProgram & { data: any }; 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 type UpdateProgramResponse = Response<"updateProgram", { data: any }>;
export interface WithSection { sectionId: number; } export interface WithSection {
sectionId: number;
}
export type RunSectionData = WithSection & { duration: number }; export type RunSectionData = WithSection & { duration: number };
export type RunSectionRequest = RunSectionData & WithType<"runSection">; export type RunSectionRequest = RunSectionData & WithType<"runSection">;
@ -19,30 +24,44 @@ export type RunSectionResponse = Response<"runSection", { runId: number }>;
export type CancelSectionRequest = WithSection & WithType<"cancelSection">; export type CancelSectionRequest = WithSection & WithType<"cancelSection">;
export interface CancelSectionRunIdData { runId: number; } export interface CancelSectionRunIdData {
export type CancelSectionRunIdRequest = CancelSectionRunIdData & WithType<"cancelSectionRunId">; runId: number;
}
export type CancelSectionRunIdRequest = CancelSectionRunIdData &
WithType<"cancelSectionRunId">;
export interface PauseSectionRunnerData { paused: boolean; } export interface PauseSectionRunnerData {
export type PauseSectionRunnerRequest = PauseSectionRunnerData & WithType<"pauseSectionRunner">; paused: boolean;
}
export type PauseSectionRunnerRequest = PauseSectionRunnerData &
WithType<"pauseSectionRunner">;
export type Request = RunProgramRequest | CancelProgramRequest | UpdateProgramRequest | export type Request =
RunSectionRequest | CancelSectionRequest | CancelSectionRunIdRequest | PauseSectionRunnerRequest; | RunProgramRequest
| CancelProgramRequest
| UpdateProgramRequest
| RunSectionRequest
| CancelSectionRequest
| CancelSectionRunIdRequest
| PauseSectionRunnerRequest;
export type RequestType = Request["type"]; export type RequestType = Request["type"];
export interface SuccessResponseData<Type extends string = string> extends WithType<Type> { export interface SuccessResponseData<Type extends string = string>
result: "success"; extends WithType<Type> {
message: string; result: "success";
message: string;
} }
export interface ErrorResponseData<Type extends string = string> extends WithType<Type> { export interface ErrorResponseData<Type extends string = string>
result: "error"; extends WithType<Type> {
message: string; result: "error";
code: number; message: string;
name?: string; code: number;
cause?: any; name?: string;
cause?: any;
} }
export type Response<Type extends string = string, Res = {}> = export type Response<Type extends string = string, Res = {}> =
(SuccessResponseData<Type> & Res) | | (SuccessResponseData<Type> & Res)
(ErrorResponseData<Type>); | (ErrorResponseData<Type>);

18
common/sprinklersRpc/mqtt/MqttProgram.ts

@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc";
import * as schema from "@common/sprinklersRpc/schema"; import * as schema from "@common/sprinklersRpc/schema";
export class MqttProgram extends s.Program { export class MqttProgram extends s.Program {
onMessage(payload: string, topic: string | undefined) { onMessage(payload: string, topic: string | undefined) {
if (topic === "running") { if (topic === "running") {
this.running = (payload === "true"); this.running = payload === "true";
} else if (topic == null) { } else if (topic == null) {
this.updateFromJSON(JSON.parse(payload)); this.updateFromJSON(JSON.parse(payload));
}
} }
}
updateFromJSON(json: any) { updateFromJSON(json: any) {
update(schema.program, this, json); update(schema.program, this, json);
} }
} }

18
common/sprinklersRpc/mqtt/MqttSection.ts

@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc";
import * as schema from "@common/sprinklersRpc/schema"; import * as schema from "@common/sprinklersRpc/schema";
export class MqttSection extends s.Section { export class MqttSection extends s.Section {
onMessage(payload: string, topic: string | undefined) { onMessage(payload: string, topic: string | undefined) {
if (topic === "state") { if (topic === "state") {
this.state = (payload === "true"); this.state = payload === "true";
} else if (topic == null) { } else if (topic == null) {
this.updateFromJSON(JSON.parse(payload)); this.updateFromJSON(JSON.parse(payload));
}
} }
}
updateFromJSON(json: any) { updateFromJSON(json: any) {
update(schema.section, this, json); update(schema.section, this, json);
} }
} }

12
common/sprinklersRpc/mqtt/MqttSectionRunner.ts

@ -4,11 +4,11 @@ import * as s from "@common/sprinklersRpc";
import * as schema from "@common/sprinklersRpc/schema"; import * as schema from "@common/sprinklersRpc/schema";
export class MqttSectionRunner extends s.SectionRunner { export class MqttSectionRunner extends s.SectionRunner {
onMessage(payload: string) { onMessage(payload: string) {
this.updateFromJSON(JSON.parse(payload)); this.updateFromJSON(JSON.parse(payload));
} }
updateFromJSON(json: any) { updateFromJSON(json: any) {
update(schema.sectionRunner, this, json); update(schema.sectionRunner, this, json);
} }
} }

549
common/sprinklersRpc/mqtt/index.ts

@ -16,296 +16,337 @@ import { MqttSectionRunner } from "./MqttSectionRunner";
const log = logger.child({ source: "mqtt" }); const log = logger.child({ source: "mqtt" });
interface WithRid { interface WithRid {
rid: number; rid: number;
} }
export const DEVICE_PREFIX = "devices"; export const DEVICE_PREFIX = "devices";
const REQUEST_TIMEOUT = 5000; const REQUEST_TIMEOUT = 5000;
export interface MqttRpcClientOptions { export interface MqttRpcClientOptions {
mqttUri: string; mqttUri: string;
username?: string; username?: string;
password?: string; password?: string;
} }
export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptions { export class MqttRpcClient extends s.SprinklersRPC
get connected(): boolean { implements MqttRpcClientOptions {
return this.connectionState.isServerConnected || false; get connected(): boolean {
return this.connectionState.isServerConnected || false;
}
private static newClientId() {
return "sprinklers3-MqttApiClient-" + getRandomId();
}
mqttUri!: string;
username?: string;
password?: string;
client!: mqtt.Client;
@observable
connectionState: s.ConnectionState = new s.ConnectionState();
devices: Map<string, MqttSprinklersDevice> = new Map();
constructor(opts: MqttRpcClientOptions) {
super();
Object.assign(this, opts);
this.connectionState.serverToBroker = false;
}
start() {
const clientId = MqttRpcClient.newClientId();
const mqttUri = this.mqttUri;
log.info({ mqttUri, clientId }, "connecting to mqtt broker with client id");
this.client = mqtt.connect(
mqttUri,
{
clientId,
connectTimeout: 5000,
reconnectPeriod: 5000,
username: this.username,
password: this.password
}
);
this.client.on("message", this.onMessageArrived.bind(this));
this.client.on("close", () => {
logger.warn("mqtt disconnected");
this.connectionState.serverToBroker = false;
});
this.client.on("error", err => {
log.error({ err }, "mqtt error");
});
this.client.on("connect", () => {
log.info("mqtt connected");
this.connectionState.serverToBroker = true;
});
}
releaseDevice(id: string) {
const device = this.devices.get(id);
if (!device) {
return;
} }
device.doUnsubscribe();
this.devices.delete(id);
}
private static newClientId() { protected getDevice(id: string): s.SprinklersDevice {
return "sprinklers3-MqttApiClient-" + getRandomId(); if (/\//.test(id)) {
throw new Error("Device id cannot contain a /");
} }
let device = this.devices.get(id);
mqttUri!: string; if (!device) {
username?: string; this.devices.set(id, (device = new MqttSprinklersDevice(this, id)));
password?: string; if (this.connected) {
device.doSubscribe();
client!: mqtt.Client; }
@observable connectionState: s.ConnectionState = new s.ConnectionState();
devices: Map<string, MqttSprinklersDevice> = new Map();
constructor(opts: MqttRpcClientOptions) {
super();
Object.assign(this, opts);
this.connectionState.serverToBroker = false;
}
start() {
const clientId = MqttRpcClient.newClientId();
const mqttUri = this.mqttUri;
log.info({ mqttUri, clientId }, "connecting to mqtt broker with client id");
this.client = mqtt.connect(mqttUri, {
clientId, connectTimeout: 5000, reconnectPeriod: 5000,
username: this.username, password: this.password,
});
this.client.on("message", this.onMessageArrived.bind(this));
this.client.on("close", () => {
logger.warn("mqtt disconnected");
this.connectionState.serverToBroker = false;
});
this.client.on("error", (err) => {
log.error({ err }, "mqtt error");
});
this.client.on("connect", () => {
log.info("mqtt connected");
this.connectionState.serverToBroker = true;
});
} }
return device;
releaseDevice(id: string) { }
const device = this.devices.get(id);
if (!device) { private onMessageArrived(
return; topic: string,
} payload: Buffer,
device.doUnsubscribe(); packet: mqtt.Packet
this.devices.delete(id); ) {
} try {
this.processMessage(topic, payload, packet);
protected getDevice(id: string): s.SprinklersDevice { } catch (err) {
if (/\//.test(id)) { log.error({ err }, "error while processing mqtt message");
throw new Error("Device id cannot contain a /");
}
let device = this.devices.get(id);
if (!device) {
this.devices.set(id, device = new MqttSprinklersDevice(this, id));
if (this.connected) {
device.doSubscribe();
}
}
return device;
} }
}
private onMessageArrived(topic: string, payload: Buffer, packet: mqtt.Packet) {
try { private processMessage(
this.processMessage(topic, payload, packet); topic: string,
} catch (err) { payloadBuf: Buffer,
log.error({ err }, "error while processing mqtt message"); packet: mqtt.Packet
} ) {
const payload = payloadBuf.toString("utf8");
log.trace({ topic, payload }, "message arrived: ");
const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`);
const matches = regexp.exec(topic);
if (!matches) {
return log.warn({ topic }, "received message on invalid topic");
} }
const id = matches[1];
private processMessage(topic: string, payloadBuf: Buffer, packet: mqtt.Packet) { const topicSuffix = matches[2];
const payload = payloadBuf.toString("utf8"); const device = this.devices.get(id);
log.trace({ topic, payload }, "message arrived: "); if (!device) {
const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`); log.debug({ id }, "received message for unknown device");
const matches = regexp.exec(topic); return;
if (!matches) {
return log.warn({ topic }, "received message on invalid topic");
}
const id = matches[1];
const topicSuffix = matches[2];
const device = this.devices.get(id);
if (!device) {
log.debug({ id }, "received message for unknown device");
return;
}
device.onMessage(topicSuffix, payload);
} }
device.onMessage(topicSuffix, payload);
}
} }
type ResponseCallback = (response: requests.Response) => void; type ResponseCallback = (response: requests.Response) => void;
const subscriptions = [ const subscriptions = [
"/connected", "/connected",
"/sections", "/sections",
"/sections/+/#", "/sections/+/#",
"/programs", "/programs",
"/programs/+/#", "/programs/+/#",
"/responses", "/responses",
"/section_runner", "/section_runner"
]; ];
type IHandler = (payload: any, ...matches: string[]) => void; type IHandler = (payload: any, ...matches: string[]) => void;
interface IHandlerEntry { interface IHandlerEntry {
test: RegExp; test: RegExp;
handler: IHandler; handler: IHandler;
} }
const handler = (test: RegExp) => const handler = (test: RegExp) => (
(target: MqttSprinklersDevice, propertyKey: string, descriptor: TypedPropertyDescriptor<IHandler>) => { target: MqttSprinklersDevice,
if (typeof descriptor.value === "function") { propertyKey: string,
const entry = { descriptor: TypedPropertyDescriptor<IHandler>
test, handler: descriptor.value, ) => {
}; if (typeof descriptor.value === "function") {
(target.handlers || (target.handlers = [])).push(entry); const entry = {
} test,
handler: descriptor.value
}; };
(target.handlers || (target.handlers = [])).push(entry);
}
};
class MqttSprinklersDevice extends s.SprinklersDevice { class MqttSprinklersDevice extends s.SprinklersDevice {
readonly apiClient: MqttRpcClient; readonly apiClient: MqttRpcClient;
handlers!: IHandlerEntry[]; handlers!: IHandlerEntry[];
private subscriptions: string[]; private subscriptions: string[];
private nextRequestId: number = Math.floor(Math.random() * 1000000000); private nextRequestId: number = Math.floor(Math.random() * 1000000000);
private responseCallbacks: Map<number, ResponseCallback> = new Map(); private responseCallbacks: Map<number, ResponseCallback> = new Map();
constructor(apiClient: MqttRpcClient, id: string) { constructor(apiClient: MqttRpcClient, id: string) {
super(apiClient, id); super(apiClient, id);
this.sectionConstructor = MqttSection; this.sectionConstructor = MqttSection;
this.sectionRunnerConstructor = MqttSectionRunner; this.sectionRunnerConstructor = MqttSectionRunner;
this.programConstructor = MqttProgram; this.programConstructor = MqttProgram;
this.apiClient = apiClient; this.apiClient = apiClient;
this.sectionRunner = new MqttSectionRunner(this); this.sectionRunner = new MqttSectionRunner(this);
this.subscriptions = subscriptions.map((filter) => this.prefix + filter); this.subscriptions = subscriptions.map(filter => this.prefix + filter);
autorun(() => { autorun(() => {
const brokerConnected = apiClient.connected; const brokerConnected = apiClient.connected;
this.connectionState.serverToBroker = brokerConnected; this.connectionState.serverToBroker = brokerConnected;
if (brokerConnected) { if (brokerConnected) {
if (this.connectionState.brokerToDevice == null) { if (this.connectionState.brokerToDevice == null) {
this.connectionState.brokerToDevice = false; this.connectionState.brokerToDevice = false;
}
this.doSubscribe();
} else {
this.connectionState.brokerToDevice = false;
}
});
}
get prefix(): string {
return DEVICE_PREFIX + "/" + this.id;
}
doSubscribe() {
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, (err) => {
if (err) {
log.error({ err, id: this.id }, "error subscribing to device");
} else {
log.debug({ id: this.id }, "subscribed to device");
}
});
}
doUnsubscribe() {
this.apiClient.client.unsubscribe(this.subscriptions, (err) => {
if (err) {
log.error({ err, id: this.id }, "error unsubscribing to device");
} else {
log.debug({ id: this.id }, "unsubscribed to device");
}
});
}
onMessage(topic: string, payload: string) {
for (const { test, handler: hndlr } of this.handlers) {
const matches = topic.match(test);
if (!matches) {
continue;
}
matches.shift();
hndlr.call(this, payload, ...matches);
return;
} }
log.warn({ topic }, "MqttSprinklersDevice recieved message on invalid topic"); this.doSubscribe();
} else {
this.connectionState.brokerToDevice = false;
}
});
}
get prefix(): string {
return DEVICE_PREFIX + "/" + this.id;
}
doSubscribe() {
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, err => {
if (err) {
log.error({ err, id: this.id }, "error subscribing to device");
} else {
log.debug({ id: this.id }, "subscribed to device");
}
});
}
doUnsubscribe() {
this.apiClient.client.unsubscribe(this.subscriptions, err => {
if (err) {
log.error({ err, id: this.id }, "error unsubscribing to device");
} else {
log.debug({ id: this.id }, "unsubscribed to device");
}
});
}
onMessage(topic: string, payload: string) {
for (const { test, handler: hndlr } of this.handlers) {
const matches = topic.match(test);
if (!matches) {
continue;
}
matches.shift();
hndlr.call(this, payload, ...matches);
return;
} }
log.warn(
makeRequest(request: requests.Request): Promise<requests.Response> { { topic },
return new Promise<requests.Response>((resolve, reject) => { "MqttSprinklersDevice recieved message on invalid topic"
const topic = this.prefix + "/requests"; );
const json = seralizeRequest(request); }
const requestId = json.rid = this.getRequestId();
const payloadStr = JSON.stringify(json); makeRequest(request: requests.Request): Promise<requests.Response> {
return new Promise<requests.Response>((resolve, reject) => {
let timeoutHandle: any; const topic = this.prefix + "/requests";
const callback: ResponseCallback = (data) => { const json = seralizeRequest(request);
if (data.result === "error") { const requestId = (json.rid = this.getRequestId());
reject(new RpcError(data.message, data.code, data)); const payloadStr = JSON.stringify(json);
} else {
resolve(data); let timeoutHandle: any;
} const callback: ResponseCallback = data => {
this.responseCallbacks.delete(requestId); if (data.result === "error") {
clearTimeout(timeoutHandle); reject(new RpcError(data.message, data.code, data));
};
timeoutHandle = setTimeout(() => {
reject(new RpcError("the request has timed out", ErrorCode.Timeout));
this.responseCallbacks.delete(requestId);
clearTimeout(timeoutHandle);
}, REQUEST_TIMEOUT);
this.responseCallbacks.set(requestId, callback);
this.apiClient.client.publish(topic, payloadStr, { qos: 1 });
});
}
private getRequestId(): number {
return this.nextRequestId++;
}
/* tslint:disable:no-unused-variable */
@handler(/^connected$/)
private handleConnected(payload: string) {
this.connectionState.brokerToDevice = (payload === "true");
log.trace(`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`);
return;
}
@handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/)
private handleSectionsUpdate(payload: string, secNumStr?: string, subTopic?: string) {
log.trace({ section: secNumStr, topic: subTopic, payload }, "handling section update");
if (!secNumStr) { // new number of sections
this.sections.length = Number(payload);
} else { } else {
const secNum = Number(secNumStr); resolve(data);
let section = this.sections[secNum];
if (!section) {
this.sections[secNum] = section = new MqttSection(this, secNum);
}
(section as MqttSection).onMessage(payload, subTopic);
} }
this.responseCallbacks.delete(requestId);
clearTimeout(timeoutHandle);
};
timeoutHandle = setTimeout(() => {
reject(new RpcError("the request has timed out", ErrorCode.Timeout));
this.responseCallbacks.delete(requestId);
clearTimeout(timeoutHandle);
}, REQUEST_TIMEOUT);
this.responseCallbacks.set(requestId, callback);
this.apiClient.client.publish(topic, payloadStr, { qos: 1 });
});
}
private getRequestId(): number {
return this.nextRequestId++;
}
/* tslint:disable:no-unused-variable */
@handler(/^connected$/)
private handleConnected(payload: string) {
this.connectionState.brokerToDevice = payload === "true";
log.trace(
`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`
);
return;
}
@handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/)
private handleSectionsUpdate(
payload: string,
secNumStr?: string,
subTopic?: string
) {
log.trace(
{ section: secNumStr, topic: subTopic, payload },
"handling section update"
);
if (!secNumStr) {
// new number of sections
this.sections.length = Number(payload);
} else {
const secNum = Number(secNumStr);
let section = this.sections[secNum];
if (!section) {
this.sections[secNum] = section = new MqttSection(this, secNum);
}
(section as MqttSection).onMessage(payload, subTopic);
} }
}
@handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/)
private handleProgramsUpdate(payload: string, progNumStr?: string, subTopic?: string) { @handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/)
log.trace({ program: progNumStr, topic: subTopic, payload }, "handling program update"); private handleProgramsUpdate(
if (!progNumStr) { // new number of programs payload: string,
this.programs.length = Number(payload); progNumStr?: string,
} else { subTopic?: string
const progNum = Number(progNumStr); ) {
let program = this.programs[progNum]; log.trace(
if (!program) { { program: progNumStr, topic: subTopic, payload },
this.programs[progNum] = program = new MqttProgram(this, progNum); "handling program update"
} );
(program as MqttProgram).onMessage(payload, subTopic); if (!progNumStr) {
} // new number of programs
this.programs.length = Number(payload);
} else {
const progNum = Number(progNumStr);
let program = this.programs[progNum];
if (!program) {
this.programs[progNum] = program = new MqttProgram(this, progNum);
}
(program as MqttProgram).onMessage(payload, subTopic);
} }
}
@handler(/^section_runner$/)
private handleSectionRunnerUpdate(payload: string) { @handler(/^section_runner$/)
(this.sectionRunner as MqttSectionRunner).onMessage(payload); private handleSectionRunnerUpdate(payload: string) {
} (this.sectionRunner as MqttSectionRunner).onMessage(payload);
}
@handler(/^responses$/)
private handleResponse(payload: string) { @handler(/^responses$/)
const data = JSON.parse(payload) as requests.Response & WithRid; private handleResponse(payload: string) {
log.trace({ rid: data.rid }, "handling request response"); const data = JSON.parse(payload) as requests.Response & WithRid;
const cb = this.responseCallbacks.get(data.rid); log.trace({ rid: data.rid }, "handling request response");
if (typeof cb === "function") { const cb = this.responseCallbacks.get(data.rid);
delete data.rid; if (typeof cb === "function") {
cb(data); delete data.rid;
} cb(data);
} }
}
/* tslint:enable:no-unused-variable */ /* tslint:enable:no-unused-variable */
} }

201
common/sprinklersRpc/schedule.ts

@ -2,101 +2,140 @@ import { observable } from "mobx";
import { Moment } from "moment"; import { Moment } from "moment";
export class TimeOfDay { export class TimeOfDay {
static fromMoment(m: Moment): TimeOfDay { static fromMoment(m: Moment): TimeOfDay {
return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond()); return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond());
} }
static fromDate(date: Date): TimeOfDay { static fromDate(date: Date): TimeOfDay {
return new TimeOfDay(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); return new TimeOfDay(
} date.getHours(),
date.getMinutes(),
static equals(a: TimeOfDay | null | undefined, b: TimeOfDay | null | undefined): boolean { date.getSeconds(),
return (a === b) || ((a != null && b != null) && a.hour === b.hour && date.getMilliseconds()
a.minute === b.minute && );
a.second === b.second && }
a.millisecond === b.millisecond);
} static equals(
a: TimeOfDay | null | undefined,
readonly hour: number; b: TimeOfDay | null | undefined
readonly minute: number; ): boolean {
readonly second: number; return (
readonly millisecond: number; a === b ||
(a != null &&
constructor(hour: number = 0, minute: number = 0, second: number = 0, millisecond: number = 0) { b != null &&
this.hour = hour; a.hour === b.hour &&
this.minute = minute; a.minute === b.minute &&
this.second = second; a.second === b.second &&
this.millisecond = millisecond; a.millisecond === b.millisecond)
} );
}
readonly hour: number;
readonly minute: number;
readonly second: number;
readonly millisecond: number;
constructor(
hour: number = 0,
minute: number = 0,
second: number = 0,
millisecond: number = 0
) {
this.hour = hour;
this.minute = minute;
this.second = second;
this.millisecond = millisecond;
}
} }
export enum Weekday { export enum Weekday {
Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
} }
export const WEEKDAYS: Weekday[] = Object.keys(Weekday) export const WEEKDAYS: Weekday[] = Object.keys(Weekday)
.map((weekday) => Number(weekday)) .map(weekday => Number(weekday))
.filter((weekday) => !isNaN(weekday)); .filter(weekday => !isNaN(weekday));
export enum Month { export enum Month {
January = 1, January = 1,
February = 2, February = 2,
March = 3, March = 3,
April = 4, April = 4,
May = 5, May = 5,
June = 6, June = 6,
July = 7, July = 7,
August = 8, August = 8,
September = 9, September = 9,
October = 10, October = 10,
November = 11, November = 11,
December = 12, December = 12
} }
export class DateOfYear { export class DateOfYear {
static readonly DEFAULT = new DateOfYear({ day: 1, month: Month.January, year: 0 }); static readonly DEFAULT = new DateOfYear({
day: 1,
static equals(a: DateOfYear | null | undefined, b: DateOfYear | null | undefined): boolean { month: Month.January,
return (a === b) || ((a instanceof DateOfYear && b instanceof DateOfYear) && year: 0
a.day === b.day && });
a.month === b.month &&
a.year === b.year); static equals(
} a: DateOfYear | null | undefined,
b: DateOfYear | null | undefined
static fromMoment(m: Moment): DateOfYear { ): boolean {
return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() }); return (
} a === b ||
(a instanceof DateOfYear &&
readonly day!: number; b instanceof DateOfYear &&
readonly month!: Month; a.day === b.day &&
readonly year!: number; a.month === b.month &&
a.year === b.year)
constructor(data?: Partial<DateOfYear>) { );
Object.assign(this, DateOfYear.DEFAULT, data); }
}
static fromMoment(m: Moment): DateOfYear {
with(data: Partial<DateOfYear>): DateOfYear { return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() });
return new DateOfYear(Object.assign({}, this, data)); }
}
readonly day!: number;
toString() { readonly month!: Month;
return `${Month[this.month]} ${this.day}, ${this.year}`; readonly year!: number;
}
constructor(data?: Partial<DateOfYear>) {
Object.assign(this, DateOfYear.DEFAULT, data);
}
with(data: Partial<DateOfYear>): DateOfYear {
return new DateOfYear(Object.assign({}, this, data));
}
toString() {
return `${Month[this.month]} ${this.day}, ${this.year}`;
}
} }
export class Schedule { export class Schedule {
@observable times: TimeOfDay[] = []; @observable
@observable weekdays: Weekday[] = []; times: TimeOfDay[] = [];
@observable from: DateOfYear | null = null; @observable
@observable to: DateOfYear | null = null; weekdays: Weekday[] = [];
@observable
constructor(data?: Partial<Schedule>) { from: DateOfYear | null = null;
if (typeof data === "object") { @observable
Object.assign(this, data); to: DateOfYear | null = null;
}
constructor(data?: Partial<Schedule>) {
if (typeof data === "object") {
Object.assign(this, data);
} }
}
clone(): Schedule { clone(): Schedule {
return new Schedule(this); return new Schedule(this);
} }
} }

54
common/sprinklersRpc/schema/common.ts

@ -1,40 +1,38 @@
import { import { ModelSchema, primitive, PropSchema } from "serializr";
ModelSchema, primitive, PropSchema,
} from "serializr";
import * as s from ".."; import * as s from "..";
export const duration: PropSchema = primitive(); export const duration: PropSchema = primitive();
export const date: PropSchema = { export const date: PropSchema = {
serializer: (jsDate: Date | null) => jsDate != null ? serializer: (jsDate: Date | null) =>
jsDate.toISOString() : null, jsDate != null ? jsDate.toISOString() : null,
deserializer: (json: any, done) => { deserializer: (json: any, done) => {
if (json === null) { if (json === null) {
return done(null, null); return done(null, null);
} }
try { try {
done(null, new Date(json)); done(null, new Date(json));
} catch (e) { } catch (e) {
done(e, undefined); done(e, undefined);
} }
}, }
}; };
export const dateOfYear: ModelSchema<s.DateOfYear> = { export const dateOfYear: ModelSchema<s.DateOfYear> = {
factory: () => new s.DateOfYear(), factory: () => new s.DateOfYear(),
props: { props: {
year: primitive(), year: primitive(),
month: primitive(), // this only works if it is represented as a # from 0-12 month: primitive(), // this only works if it is represented as a # from 0-12
day: primitive(), day: primitive()
}, }
}; };
export const timeOfDay: ModelSchema<s.TimeOfDay> = { export const timeOfDay: ModelSchema<s.TimeOfDay> = {
factory: () => new s.TimeOfDay(), factory: () => new s.TimeOfDay(),
props: { props: {
hour: primitive(), hour: primitive(),
minute: primitive(), minute: primitive(),
second: primitive(), second: primitive(),
millisecond: primitive(), millisecond: primitive()
}, }
}; };

124
common/sprinklersRpc/schema/index.ts

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

108
common/sprinklersRpc/schema/list.ts

@ -1,65 +1,75 @@
import { primitive, PropSchema } from "serializr"; import { primitive, PropSchema } from "serializr";
function invariant(cond: boolean, message?: string) { function invariant(cond: boolean, message?: string) {
if (!cond) { if (!cond) {
throw new Error("[serializr] " + (message || "Illegal ServerState")); throw new Error("[serializr] " + (message || "Illegal ServerState"));
} }
} }
function isPropSchema(thing: any) { function isPropSchema(thing: any) {
return thing && thing.serializer && thing.deserializer; return thing && thing.serializer && thing.deserializer;
} }
function isAliasedPropSchema(propSchema: any) { function isAliasedPropSchema(propSchema: any) {
return typeof propSchema === "object" && !!propSchema.jsonname; return typeof propSchema === "object" && !!propSchema.jsonname;
} }
function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any) { function parallel(
if (ar.length === 0) { ar: any[],
return void cb(null, []); processor: (item: any, done: any) => void,
cb: any
) {
if (ar.length === 0) {
return void cb(null, []);
}
let left = ar.length;
const resultArray: any[] = [];
let failed = false;
const processorCb = (idx: number, err: any, result: any) => {
if (err) {
if (!failed) {
failed = true;
cb(err);
}
} else if (!failed) {
resultArray[idx] = result;
if (--left === 0) {
cb(null, resultArray);
}
} }
let left = ar.length; };
const resultArray: any[] = []; ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx)));
let failed = false;
const processorCb = (idx: number, err: any, result: any) => {
if (err) {
if (!failed) {
failed = true;
cb(err);
}
} else if (!failed) {
resultArray[idx] = result;
if (--left === 0) {
cb(null, resultArray);
}
}
};
ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx)));
} }
export default function list(propSchema: PropSchema): PropSchema { export default function list(propSchema: PropSchema): PropSchema {
propSchema = propSchema || primitive(); propSchema = propSchema || primitive();
invariant(isPropSchema(propSchema), "expected prop schema as first argument"); invariant(isPropSchema(propSchema), "expected prop schema as first argument");
invariant(!isAliasedPropSchema(propSchema), "provided prop is aliased, please put aliases first"); invariant(
return { !isAliasedPropSchema(propSchema),
serializer(ar) { "provided prop is aliased, please put aliases first"
invariant(ar && typeof ar.length === "number" && typeof ar.map === "function", );
"expected array (like) object"); return {
return ar.map(propSchema.serializer); serializer(ar) {
}, invariant(
deserializer(jsonArray, done, context) { ar && typeof ar.length === "number" && typeof ar.map === "function",
if (jsonArray === null) { // sometimes go will return null in place of empty array "expected array (like) object"
return void done(null, []); );
} return ar.map(propSchema.serializer);
if (!Array.isArray(jsonArray)) { },
return void done("[serializr] expected JSON array", null); deserializer(jsonArray, done, context) {
} if (jsonArray === null) {
parallel( // sometimes go will return null in place of empty array
jsonArray, return void done(null, []);
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) => }
propSchema.deserializer(item, itemDone, context, undefined), if (!Array.isArray(jsonArray)) {
done, return void done("[serializr] expected JSON array", null);
); }
}, parallel(
}; jsonArray,
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) =>
propSchema.deserializer(item, itemDone, context, undefined),
done
);
}
};
} }

104
common/sprinklersRpc/schema/requests.ts

@ -1,69 +1,89 @@
import { createSimpleSchema, deserialize, ModelSchema, primitive, serialize } from "serializr"; import {
createSimpleSchema,
deserialize,
ModelSchema,
primitive,
serialize
} from "serializr";
import * as requests from "@common/sprinklersRpc/deviceRequests"; import * as requests from "@common/sprinklersRpc/deviceRequests";
import * as common from "./common"; import * as common from "./common";
export const withType: ModelSchema<requests.WithType> = createSimpleSchema({ export const withType: ModelSchema<requests.WithType> = createSimpleSchema({
type: primitive(), type: primitive()
}); });
export const withProgram: ModelSchema<requests.WithProgram> = createSimpleSchema({ export const withProgram: ModelSchema<
...withType.props, requests.WithProgram
programId: primitive(), > = createSimpleSchema({
...withType.props,
programId: primitive()
}); });
export const withSection: ModelSchema<requests.WithSection> = createSimpleSchema({ export const withSection: ModelSchema<
...withType.props, requests.WithSection
sectionId: primitive(), > = createSimpleSchema({
...withType.props,
sectionId: primitive()
}); });
export const updateProgram: ModelSchema<requests.UpdateProgramData> = createSimpleSchema({ export const updateProgram: ModelSchema<
...withProgram.props, requests.UpdateProgramData
data: { > = createSimpleSchema({
serializer: (data) => data, ...withProgram.props,
deserializer: (json, done) => { done(null, json); }, data: {
}, serializer: data => data,
deserializer: (json, done) => {
done(null, json);
}
}
}); });
export const runSection: ModelSchema<requests.RunSectionData> = createSimpleSchema({ export const runSection: ModelSchema<
...withSection.props, requests.RunSectionData
duration: common.duration, > = createSimpleSchema({
...withSection.props,
duration: common.duration
}); });
export const cancelSectionRunId: ModelSchema<requests.CancelSectionRunIdData> = createSimpleSchema({ export const cancelSectionRunId: ModelSchema<
...withType.props, requests.CancelSectionRunIdData
runId: primitive(), > = createSimpleSchema({
...withType.props,
runId: primitive()
}); });
export const pauseSectionRunner: ModelSchema<requests.PauseSectionRunnerData> = createSimpleSchema({ export const pauseSectionRunner: ModelSchema<
...withType.props, requests.PauseSectionRunnerData
paused: primitive(), > = createSimpleSchema({
...withType.props,
paused: primitive()
}); });
export function getRequestSchema(request: requests.WithType): ModelSchema<any> { export function getRequestSchema(request: requests.WithType): ModelSchema<any> {
switch (request.type as requests.RequestType) { switch (request.type as requests.RequestType) {
case "runProgram": case "runProgram":
case "cancelProgram": case "cancelProgram":
return withProgram; return withProgram;
case "updateProgram": case "updateProgram":
return updateProgram; return updateProgram;
case "runSection": case "runSection":
return runSection; return runSection;
case "cancelSection": case "cancelSection":
return withSection; return withSection;
case "cancelSectionRunId": case "cancelSectionRunId":
return cancelSectionRunId; return cancelSectionRunId;
case "pauseSectionRunner": case "pauseSectionRunner":
return pauseSectionRunner; return pauseSectionRunner;
default: default:
throw new Error(`Cannot serialize request with type "${request.type}"`); throw new Error(`Cannot serialize request with type "${request.type}"`);
} }
} }
export function seralizeRequest(request: requests.Request): any { export function seralizeRequest(request: requests.Request): any {
return serialize(getRequestSchema(request), request); return serialize(getRequestSchema(request), request);
} }
export function deserializeRequest(json: any): requests.Request { export function deserializeRequest(json: any): requests.Request {
return deserialize(getRequestSchema(json), json); return deserialize(getRequestSchema(json), json);
} }

78
common/sprinklersRpc/websocketData.ts

@ -3,76 +3,92 @@ import * as rpc from "@common/jsonRpc/index";
import { Response as ResponseData } from "@common/sprinklersRpc/deviceRequests"; import { Response as ResponseData } from "@common/sprinklersRpc/deviceRequests";
export interface IAuthenticateRequest { export interface IAuthenticateRequest {
accessToken: string; accessToken: string;
} }
export interface IDeviceSubscribeRequest { export interface IDeviceSubscribeRequest {
deviceId: string; deviceId: string;
} }
export interface IDeviceCallRequest { export interface IDeviceCallRequest {
deviceId: string; deviceId: string;
data: any; data: any;
} }
export interface IClientRequestTypes { export interface IClientRequestTypes {
"authenticate": IAuthenticateRequest; authenticate: IAuthenticateRequest;
"deviceSubscribe": IDeviceSubscribeRequest; deviceSubscribe: IDeviceSubscribeRequest;
"deviceUnsubscribe": IDeviceSubscribeRequest; deviceUnsubscribe: IDeviceSubscribeRequest;
"deviceCall": IDeviceCallRequest; deviceCall: IDeviceCallRequest;
} }
export interface IAuthenticateResponse { export interface IAuthenticateResponse {
authenticated: boolean; authenticated: boolean;
message: string; message: string;
user: IUser; user: IUser;
} }
export interface IDeviceSubscribeResponse { export interface IDeviceSubscribeResponse {
deviceId: string; deviceId: string;
} }
export interface IDeviceCallResponse { export interface IDeviceCallResponse {
data: ResponseData; data: ResponseData;
} }
export interface IServerResponseTypes { export interface IServerResponseTypes {
"authenticate": IAuthenticateResponse; authenticate: IAuthenticateResponse;
"deviceSubscribe": IDeviceSubscribeResponse; deviceSubscribe: IDeviceSubscribeResponse;
"deviceUnsubscribe": IDeviceSubscribeResponse; deviceUnsubscribe: IDeviceSubscribeResponse;
"deviceCall": IDeviceCallResponse; deviceCall: IDeviceCallResponse;
} }
export type ClientRequestMethods = keyof IClientRequestTypes; export type ClientRequestMethods = keyof IClientRequestTypes;
export interface IBrokerConnectionUpdate { export interface IBrokerConnectionUpdate {
brokerConnected: boolean; brokerConnected: boolean;
} }
export interface IDeviceUpdate { export interface IDeviceUpdate {
deviceId: string; deviceId: string;
data: any; data: any;
} }
export interface IServerNotificationTypes { export interface IServerNotificationTypes {
"brokerConnectionUpdate": IBrokerConnectionUpdate; brokerConnectionUpdate: IBrokerConnectionUpdate;
"deviceUpdate": IDeviceUpdate; deviceUpdate: IDeviceUpdate;
"error": IError; error: IError;
} }
export type ServerNotificationMethod = keyof IServerNotificationTypes; export type ServerNotificationMethod = keyof IServerNotificationTypes;
export type IError = rpc.DefaultErrorType; export type IError = rpc.DefaultErrorType;
export type ErrorData = rpc.ErrorData<IError>; 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 ServerNotification = rpc.Notification<IServerNotificationTypes>;
export type ServerResponse = rpc.Response<IServerResponseTypes, IError>; export type ServerResponse = rpc.Response<IServerResponseTypes, IError>;
export type ServerResponseData<Method extends keyof IServerResponseTypes = keyof IServerResponseTypes> = export type ServerResponseData<
rpc.ResponseData<IServerResponseTypes, IError, Method>; Method extends keyof IServerResponseTypes = keyof IServerResponseTypes
export type ServerResponseHandlers = rpc.ResponseHandlers<IServerResponseTypes, IError>; > = rpc.ResponseData<IServerResponseTypes, IError, Method>;
export type ServerNotificationHandlers = rpc.NotificationHandlers<IServerNotificationTypes>; export type ServerResponseHandlers = rpc.ResponseHandlers<
IServerResponseTypes,
IError
>;
export type ServerNotificationHandlers = rpc.NotificationHandlers<
IServerNotificationTypes
>;
export type ClientRequest<Method extends keyof IClientRequestTypes = keyof IClientRequestTypes> = export type ClientRequest<
rpc.Request<IClientRequestTypes, Method>; Method extends keyof IClientRequestTypes = keyof IClientRequestTypes
> = rpc.Request<IClientRequestTypes, Method>;
export type ClientMessage = rpc.Message<IClientRequestTypes, {}, IError, {}>; export type ClientMessage = rpc.Message<IClientRequestTypes, {}, IError, {}>;
export type ClientRequestHandlers = rpc.RequestHandlers<IClientRequestTypes, IServerResponseTypes, IError>; export type ClientRequestHandlers = rpc.RequestHandlers<
IClientRequestTypes,
IServerResponseTypes,
IError
>;

40
common/utils.ts

@ -1,26 +1,30 @@
export function checkedIndexOf<T>(o: T | number, arr: T[], type: string = "object"): number { export function checkedIndexOf<T>(
let idx: number; o: T | number,
if (typeof o === "number") { arr: T[],
idx = o; type: string = "object"
} else if (typeof (o as any).id === "number") { ): number {
idx = (o as any).id; let idx: number;
} else { if (typeof o === "number") {
idx = arr.indexOf(o); idx = o;
} } else if (typeof (o as any).id === "number") {
if (idx < 0 || idx > arr.length) { idx = (o as any).id;
throw new Error(`Invalid ${type} specified: ${o}`); } else {
} idx = arr.indexOf(o);
return idx; }
if (idx < 0 || idx > arr.length) {
throw new Error(`Invalid ${type} specified: ${o}`);
}
return idx;
} }
export function getRandomId() { export function getRandomId() {
return Math.floor(Math.random() * 1000000000); return Math.floor(Math.random() * 1000000000);
} }
export function applyMixins(derivedCtor: any, baseCtors: any[]) { export function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach((baseCtor) => { baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
derivedCtor.prototype[name] = baseCtor.prototype[name]; derivedCtor.prototype[name] = baseCtor.prototype[name];
});
}); });
});
} }

2
docker-compose.dev.yml

@ -39,4 +39,4 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV - POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV

2
package.json

@ -124,6 +124,8 @@
"style-loader": "^0.22.1", "style-loader": "^0.22.1",
"ts-loader": "^4.5.0", "ts-loader": "^4.5.0",
"tslint": "^5.11.0", "tslint": "^5.11.0",
"tslint-config-prettier": "^1.15.0",
"tslint-consistent-codestyle": "^1.13.3",
"tslint-react": "^3.6.0", "tslint-react": "^3.6.0",
"typescript": "^3.0.1", "typescript": "^3.0.1",
"uglify-es": "^3.3.9", "uglify-es": "^3.3.9",

2
paths.js

@ -33,4 +33,4 @@ exports.clientBuildDir = resolveRoot("public");
exports.publicDir = exports.clientBuildDir; exports.publicDir = exports.clientBuildDir;
exports.serverDir = resolveRoot("server"); exports.serverDir = resolveRoot("server");
exports.serverBuildDir = resolveRoot("dist"); exports.serverBuildDir = resolveRoot("dist");

140
server/Database.ts

@ -1,5 +1,11 @@
import * as path from "path"; 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"; import logger from "@common/logger";
@ -7,80 +13,84 @@ import { SprinklersDevice, User } from "./entities";
import { SprinklersDeviceRepository, UserRepository } from "./repositories/"; import { SprinklersDeviceRepository, UserRepository } from "./repositories/";
export class Database { export class Database {
users!: UserRepository; users!: UserRepository;
sprinklersDevices!: SprinklersDeviceRepository; sprinklersDevices!: SprinklersDeviceRepository;
private _conn: Connection | null = null; private _conn: Connection | null = null;
get conn(): Connection { get conn(): Connection {
if (this._conn == null) { if (this._conn == null) {
throw new Error("Not connected to rethinkDB"); throw new Error("Not connected to rethinkDB");
}
return this._conn;
} }
return this._conn;
}
async connect() { async connect() {
const options = await getConnectionOptions(); const options = await getConnectionOptions();
Object.assign(options, { Object.assign(options, {
entities: [ entities: [path.resolve(__dirname, "entities", "*.js")]
path.resolve(__dirname, "entities", "*.js"), });
], this._conn = await createConnection(options);
}); this.users = this._conn.getCustomRepository(UserRepository);
this._conn = await createConnection(options); this.sprinklersDevices = this._conn.getCustomRepository(
this.users = this._conn.getCustomRepository(UserRepository); SprinklersDeviceRepository
this.sprinklersDevices = this._conn.getCustomRepository(SprinklersDeviceRepository); );
} }
async disconnect() { async disconnect() {
if (this._conn) { if (this._conn) {
return this._conn.close(); return this._conn.close();
}
} }
}
async createAll() { async createAll() {
await this.conn.synchronize(); await this.conn.synchronize();
if (process.env.INSERT_TEST_DATA) { if (process.env.INSERT_TEST_DATA) {
await this.insertData(); await this.insertData();
}
} }
}
async insertData() { async insertData() {
const NUM = 100; const NUM = 100;
const users: User[] = []; const users: User[] = [];
for (let i = 0; i < NUM; i++) { for (let i = 0; i < NUM; i++) {
const username = "alex" + i; const username = "alex" + i;
let user = await this.users.findByUsername(username); let user = await this.users.findByUsername(username);
if (!user) { if (!user) {
user = await this.users.create({ user = await this.users.create({
name: "Alex Mikhalev" + i, name: "Alex Mikhalev" + i,
username, username
}); });
} }
await user.setPassword("kakashka" + i); await user.setPassword("kakashka" + i);
users.push(user); users.push(user);
} }
for (let i = 0; i < NUM; i++) {
const name = "Test" + i;
let device = await this.sprinklersDevices.findByName(name);
if (!device) {
device = await this.sprinklersDevices.create();
}
Object.assign(device, { name, deviceId: "grinklers" + (i === 1 ? "" : i) });
await this.sprinklersDevices.save(device);
for (let j = 0; j < 5; j++) {
const userIdx = (i + j * 10) % NUM;
const user = users[userIdx];
user.devices = (user.devices || []).concat([device]);
}
}
logger.info("inserted/updated devices");
await this.users.save(users); for (let i = 0; i < NUM; i++) {
logger.info("inserted/updated users"); const name = "Test" + i;
let device = await this.sprinklersDevices.findByName(name);
if (!device) {
device = await this.sprinklersDevices.create();
}
Object.assign(device, {
name,
deviceId: "grinklers" + (i === 1 ? "" : i)
});
await this.sprinklersDevices.save(device);
for (let j = 0; j < 5; j++) {
const userIdx = (i + j * 10) % NUM;
const user = users[userIdx];
user.devices = (user.devices || []).concat([device]);
}
}
logger.info("inserted/updated devices");
const alex2 = await this.users.findOne({ username: "alex0" }); await this.users.save(users);
logger.info("password valid: " + await alex2!.comparePassword("kakashka0")); logger.info("inserted/updated users");
} const alex2 = await this.users.findOne({ username: "alex0" });
logger.info(
"password valid: " + (await alex2!.comparePassword("kakashka0"))
);
}
} }

165
server/authentication.ts

@ -5,10 +5,10 @@ import * as jwt from "jsonwebtoken";
import ApiError from "@common/ApiError"; import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode"; import { ErrorCode } from "@common/ErrorCode";
import { import {
TokenGrantPasswordRequest, TokenGrantPasswordRequest,
TokenGrantRefreshRequest, TokenGrantRefreshRequest,
TokenGrantRequest, TokenGrantRequest,
TokenGrantResponse, TokenGrantResponse
} from "@common/httpApi"; } from "@common/httpApi";
import * as tok from "@common/TokenClaims"; import * as tok from "@common/TokenClaims";
import { User } from "@server/entities"; import { User } from "@server/entities";
@ -16,96 +16,117 @@ import { ServerState } from "@server/state";
const JWT_SECRET = process.env.JWT_SECRET!; const JWT_SECRET = process.env.JWT_SECRET!;
if (!JWT_SECRET) { if (!JWT_SECRET) {
throw new Error("Must specify JWT_SECRET environment variable"); throw new Error("Must specify JWT_SECRET environment variable");
} }
const ISSUER = "sprinklers3"; const ISSUER = "sprinklers3";
const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes const ACCESS_TOKEN_LIFETIME = 30 * 60; // 30 minutes
const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours const REFRESH_TOKEN_LIFETIME = 24 * 60 * 60; // 24 hours
function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise<string> { function signToken(
const options: jwt.SignOptions = { claims: tok.TokenClaimTypes,
issuer: ISSUER, opts?: jwt.SignOptions
...opts, ): Promise<string> {
}; const options: jwt.SignOptions = {
return new Promise((resolve, reject) => { issuer: ISSUER,
jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => { ...opts
if (err) { };
reject(err); return new Promise((resolve, reject) => {
} else { jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => {
resolve(encoded); if (err) {
} reject(err);
}); } else {
resolve(encoded);
}
}); });
});
} }
export function verifyToken<TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes>( export function verifyToken<
token: string, type?: TClaims["type"], TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes
): Promise<TClaims & tok.BaseClaims> { >(token: string, type?: TClaims["type"]): Promise<TClaims & tok.BaseClaims> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
jwt.verify(token, JWT_SECRET, { jwt.verify(
issuer: ISSUER, token,
}, (err, decoded) => { JWT_SECRET,
if (err) { {
if (err.name === "TokenExpiredError") { issuer: ISSUER
reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err)); },
} else if (err.name === "JsonWebTokenError") { (err, decoded) => {
reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); if (err) {
} else { if (err.name === "TokenExpiredError") {
reject(err); reject(
} new ApiError(
} else { "The specified token is expired",
const claims: tok.TokenClaims = decoded as any; ErrorCode.BadToken,
if (type != null && claims.type !== type) { err
reject(new ApiError(`Expected a "${type}" token, received a "${claims.type}" token`, )
ErrorCode.BadToken)); );
} } else if (err.name === "JsonWebTokenError") {
resolve(claims as TClaims & tok.BaseClaims); reject(new ApiError("Invalid token", ErrorCode.BadToken, err));
} } else {
}); reject(err);
}); }
} else {
const claims: tok.TokenClaims = decoded as any;
if (type != null && claims.type !== type) {
reject(
new ApiError(
`Expected a "${type}" token, received a "${claims.type}" token`,
ErrorCode.BadToken
)
);
}
resolve(claims as TClaims & tok.BaseClaims);
}
}
);
});
} }
export function generateAccessToken(user: User): Promise<string> { export function generateAccessToken(user: User): Promise<string> {
const access_token_claims: tok.AccessToken = { const accessTokenClaims: tok.AccessToken = {
aud: user.id, aud: user.id,
name: user.name, 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> { export function generateRefreshToken(user: User): Promise<string> {
const refresh_token_claims: tok.RefreshToken = { const refreshTokenClaims: tok.RefreshToken = {
aud: user.id, aud: user.id,
name: user.name, 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> { export function generateDeviceRegistrationToken(): Promise<string> {
const device_reg_token_claims: tok.DeviceRegistrationToken = { const deviceRegTokenClaims: tok.DeviceRegistrationToken = {
type: "device_reg", type: "device_reg"
}; };
return signToken(device_reg_token_claims); return signToken(deviceRegTokenClaims);
} }
export function generateDeviceToken(id: number, deviceId: string): Promise<string> { export function generateDeviceToken(
const device_token_claims: tok.DeviceToken = { id: number,
type: "device", deviceId: string
aud: deviceId, ): Promise<string> {
id, const deviceTokenClaims: tok.DeviceToken = {
}; type: "device",
return signToken(device_token_claims); aud: deviceId,
id
};
return signToken(deviceTokenClaims);
} }
export function generateSuperuserToken(): Promise<string> { export function generateSuperuserToken(): Promise<string> {
const superuser_claims: tok.SuperuserToken = { const superuserClaims: tok.SuperuserToken = {
type: "superuser", type: "superuser"
}; };
return signToken(superuser_claims); return signToken(superuserClaims);
} }

4
server/configureLogger.ts

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

48
server/entities/SprinklersDevice.ts

@ -5,38 +5,38 @@ import { User } from "./User";
@Entity() @Entity()
export class SprinklersDevice implements ISprinklersDevice { export class SprinklersDevice implements ISprinklersDevice {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column({ unique: true, nullable: true, type: "varchar" }) @Column({ unique: true, nullable: true, type: "varchar" })
deviceId: string | null = null; deviceId: string | null = null;
@Column() @Column()
name: string = ""; name: string = "";
@ManyToMany((type) => User) @ManyToMany(type => User)
users: User[] | undefined; users: User[] | undefined;
constructor(data?: Partial<SprinklersDevice>) { constructor(data?: Partial<SprinklersDevice>) {
if (data) { if (data) {
Object.assign(this, data); Object.assign(this, data);
}
} }
}
} }
// @Entity() // @Entity()
export class UserSprinklersDevice { export class UserSprinklersDevice {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column() @Column()
userId: string = ""; userId: string = "";
@Column() @Column()
sprinklersDeviceId: string = ""; sprinklersDeviceId: string = "";
constructor(data?: UserSprinklersDevice) { constructor(data?: UserSprinklersDevice) {
if (data) { if (data) {
Object.assign(this, data); Object.assign(this, data);
}
} }
}
} }

58
server/entities/User.ts

@ -1,45 +1,51 @@
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
import { omit } from "lodash"; 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 { IUser } from "@common/httpApi";
import { SprinklersDevice} from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
const HASH_ROUNDS = 1; const HASH_ROUNDS = 1;
@Entity() @Entity()
export class User implements IUser { export class User implements IUser {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column({ unique: true }) @Column({ unique: true })
username: string = ""; username: string = "";
@Column() @Column()
name: string = ""; name: string = "";
@Column() @Column()
passwordHash: string = ""; passwordHash: string = "";
@ManyToMany((type) => SprinklersDevice) @ManyToMany(type => SprinklersDevice)
@JoinTable({ name: "user_sprinklers_device" }) @JoinTable({ name: "user_sprinklers_device" })
devices: SprinklersDevice[] | undefined; devices: SprinklersDevice[] | undefined;
constructor(data?: Partial<User>) { constructor(data?: Partial<User>) {
if (data) { if (data) {
Object.assign(this, data); Object.assign(this, data);
}
} }
}
async setPassword(newPassword: string): Promise<void> { async setPassword(newPassword: string): Promise<void> {
this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS); this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS);
} }
async comparePassword(password: string): Promise<boolean> { async comparePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.passwordHash); return bcrypt.compare(password, this.passwordHash);
} }
toJSON() { toJSON() {
return omit(this, "passwordHash"); return omit(this, "passwordHash");
} }
} }

6
server/env.ts

@ -12,13 +12,13 @@ const dotenvFiles: string[] = [
// since normally you expect tests to produce the same // since normally you expect tests to produce the same
// results for everyone // results for everyone
NODE_ENV !== "test" && `${paths.dotenv}.local`, NODE_ENV !== "test" && `${paths.dotenv}.local`,
paths.dotenv, paths.dotenv
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
dotenvFiles.forEach((dotenvFile) => { dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) { if (fs.existsSync(dotenvFile)) {
require("dotenv").config({ require("dotenv").config({
path: dotenvFile, path: dotenvFile
}); });
} }
}); });

113
server/express/api/devices.ts

@ -1,5 +1,5 @@
import PromiseRouter from "express-promise-router"; import PromiseRouter from "express-promise-router";
import { serialize} from "serializr"; import { serialize } from "serializr";
import ApiError from "@common/ApiError"; import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode"; import { ErrorCode } from "@common/ErrorCode";
@ -11,59 +11,76 @@ import { ServerState } from "@server/state";
const DEVICE_ID_LEN = 20; const DEVICE_ID_LEN = 20;
function randomDeviceId(): string { function randomDeviceId(): string {
let deviceId = ""; let deviceId = "";
for (let i = 0; i < DEVICE_ID_LEN; i++) { for (let i = 0; i < DEVICE_ID_LEN; i++) {
const j = Math.floor(Math.random() * 36); const j = Math.floor(Math.random() * 36);
let ch; // tslint:disable-next-line let ch; // tslint:disable-next-line
if (j < 10) { // 0-9 if (j < 10) {
ch = String.fromCharCode(48 + j); // 0-9
} else { // a-z ch = String.fromCharCode(48 + j);
ch = String.fromCharCode(97 + (j - 10)); } else {
} // a-z
deviceId += ch; ch = String.fromCharCode(97 + (j - 10));
} }
return deviceId; deviceId += ch;
}
return deviceId;
} }
export function devices(state: ServerState) { export function devices(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
router.get("/:deviceId", verifyAuthorization(), async (req, res) => { router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
const token = req.token!; const token = req.token!;
const userId = token.aud; const userId = token.aud;
const deviceId = req.params.deviceId; const deviceId = req.params.deviceId;
const userDevice = await state.database.sprinklersDevices const userDevice = await state.database.sprinklersDevices.findUserDevice(
.findUserDevice(userId, deviceId); userId,
if (!userDevice) { deviceId
throw new ApiError("User does not have access to the specified device", ErrorCode.NoPermission); );
} if (!userDevice) {
const device = state.mqttClient.acquireDevice(req.params.deviceId); throw new ApiError(
const j = serialize(schema.sprinklersDevice, device); "User does not have access to the specified device",
res.send(j); ErrorCode.NoPermission
device.release(); );
}); }
const device = state.mqttClient.acquireDevice(req.params.deviceId);
const j = serialize(schema.sprinklersDevice, device);
res.send(j);
device.release();
});
router.post("/register", verifyAuthorization({ router.post(
type: "device_reg", "/register",
}), async (req, res) => { verifyAuthorization({
const deviceId = randomDeviceId(); type: "device_reg"
const newDevice = state.database.sprinklersDevices.create({ }),
name: "Sprinklers Device", deviceId, async (req, res) => {
}); const deviceId = randomDeviceId();
await state.database.sprinklersDevices.save(newDevice); const newDevice = state.database.sprinklersDevices.create({
const token = await generateDeviceToken(newDevice.id, deviceId); name: "Sprinklers Device",
res.send({ deviceId
data: newDevice, token, });
}); await state.database.sprinklersDevices.save(newDevice);
}); const token = await generateDeviceToken(newDevice.id, deviceId);
res.send({
data: newDevice,
token
});
}
);
router.post("/connect", verifyAuthorization({ router.post(
type: "device", "/connect",
}), async (req, res) => { verifyAuthorization({
res.send({ type: "device"
url: state.mqttUrl, }),
}); async (req, res) => {
}); res.send({
url: state.mqttUrl
});
}
);
return router; return router;
} }

18
server/express/api/index.ts

@ -10,16 +10,16 @@ import { token } from "./token";
import { users } from "./users"; import { users } from "./users";
export default function createApi(state: ServerState) { export default function createApi(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
router.use("/devices", devices(state)); router.use("/devices", devices(state));
router.use("/users", users(state)); router.use("/users", users(state));
router.use("/mosquitto", mosquitto(state)); router.use("/mosquitto", mosquitto(state));
router.use("/token", token(state)); router.use("/token", token(state));
router.use("*", (req, res) => { router.use("*", (req, res) => {
throw new ApiError("API endpoint not found", ErrorCode.NotFound); throw new ApiError("API endpoint not found", ErrorCode.NotFound);
}); });
return router; return router;
} }

87
server/express/api/mosquitto.ts

@ -10,49 +10,56 @@ import { ServerState } from "@server/state";
export const SUPERUSER = "sprinklers3"; export const SUPERUSER = "sprinklers3";
export function mosquitto(state: ServerState) { export function mosquitto(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
router.post("/auth", async (req, res) => { router.post("/auth", async (req, res) => {
const body = req.body; const body = req.body;
const { username, password, topic, acc } = body; const { username, password, topic, acc } = body;
if (typeof username !== "string" || typeof password !== "string") { 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",
if (username === SUPERUSER) { ErrorCode.BadRequest
await verifyToken<SuperuserToken>(password, "superuser"); );
return res.status(200).send({ username }); }
} if (username === SUPERUSER) {
const claims = await verifyToken<DeviceToken>(password, "device"); await verifyToken<SuperuserToken>(password, "superuser");
if (claims.aud !== username) { return res.status(200).send({ username });
throw new ApiError("Username does not match token", ErrorCode.BadRequest); }
} const claims = await verifyToken<DeviceToken>(password, "device");
res.status(200).send({ if (claims.aud !== username) {
username, id: claims.id, throw new ApiError("Username does not match token", ErrorCode.BadRequest);
}); }
res.status(200).send({
username,
id: claims.id
}); });
});
router.post("/superuser", async (req, res) => { router.post("/superuser", async (req, res) => {
const { username } = req.body; const { username } = req.body;
if (typeof username !== "string") { if (typeof username !== "string") {
throw new ApiError("Must specify a username", ErrorCode.BadRequest); throw new ApiError("Must specify a username", ErrorCode.BadRequest);
} }
if (username !== SUPERUSER) { if (username !== SUPERUSER) {
return res.status(403).send(); return res.status(403).send();
} }
res.status(200).send(); res.status(200).send();
}); });
router.post("/acl", async (req, res) => { router.post("/acl", async (req, res) => {
const { username, topic, clientid, acc } = req.body; const { username, topic, clientid, acc } = req.body;
if (typeof username !== "string" || typeof topic !== "string") { 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",
const prefix = DEVICE_PREFIX + "/" + username; ErrorCode.BadRequest
if (!topic.startsWith(prefix)) { );
throw new ApiError(`device ${username} cannot access topic ${topic}`); }
} const prefix = DEVICE_PREFIX + "/" + username;
res.status(200).send(); if (!topic.startsWith(prefix)) {
}); throw new ApiError(`device ${username} cannot access topic ${topic}`);
}
res.status(200).send();
});
return router; return router;
} }

128
server/express/api/token.ts

@ -9,73 +9,81 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization";
import { ServerState } from "@server/state"; import { ServerState } from "@server/state";
export function token(state: ServerState) { export function token(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
async function passwordGrant(body: httpApi.TokenGrantPasswordRequest, res: Express.Response): Promise<User> { async function passwordGrant(
const { username, password } = body; body: httpApi.TokenGrantPasswordRequest,
if (!body || !username || !password) { res: Express.Response
throw new ApiError("Must specify username and password"); ): Promise<User> {
} const { username, password } = body;
const user = await state.database.users.findByUsername(username); if (!body || !username || !password) {
if (!user) { throw new ApiError("Must specify username and password");
throw new ApiError("User does not exist");
}
const passwordMatches = await user.comparePassword(password);
if (passwordMatches) {
return user;
} else {
throw new ApiError("Invalid user credentials");
}
} }
const user = await state.database.users.findByUsername(username);
if (!user) {
throw new ApiError("User does not exist");
}
const passwordMatches = await user.comparePassword(password);
if (passwordMatches) {
return user;
} else {
throw new ApiError("Invalid user credentials");
}
}
async function refreshGrant(body: httpApi.TokenGrantRefreshRequest, res: Express.Response): Promise<User> { async function refreshGrant(
const { refresh_token } = body; body: httpApi.TokenGrantRefreshRequest,
if (!body || !refresh_token) { res: Express.Response
throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken); ): Promise<User> {
} const { refresh_token } = body;
const claims = await authentication.verifyToken(refresh_token); if (!body || !refresh_token) {
if (claims.type !== "refresh") { throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken);
throw new ApiError("Not a refresh token", ErrorCode.BadToken); }
} const claims = await authentication.verifyToken(refresh_token);
const user = await state.database.users.findOne(claims.aud); if (claims.type !== "refresh") {
if (!user) { throw new ApiError("Not a refresh token", ErrorCode.BadToken);
throw new ApiError("User no longer exists", ErrorCode.BadToken); }
} const user = await state.database.users.findOne(claims.aud);
return user; if (!user) {
throw new ApiError("User no longer exists", ErrorCode.BadToken);
} }
return user;
}
router.post("/grant", async (req, res) => { router.post("/grant", async (req, res) => {
const body: httpApi.TokenGrantRequest = req.body; const body: httpApi.TokenGrantRequest = req.body;
let user: User; let user: User;
if (body.grant_type === "password") { if (body.grant_type === "password") {
user = await passwordGrant(body, res); user = await passwordGrant(body, res);
} else if (body.grant_type === "refresh") { } else if (body.grant_type === "refresh") {
user = await refreshGrant(body, res); user = await refreshGrant(body, res);
} else { } else {
throw new ApiError("Invalid grant_type"); throw new ApiError("Invalid grant_type");
} }
const [access_token, refresh_token] = await Promise.all([ // tslint:disable-next-line:variable-name
await authentication.generateAccessToken(user), const [access_token, refresh_token] = await Promise.all([
await authentication.generateRefreshToken(user), await authentication.generateAccessToken(user),
]); await authentication.generateRefreshToken(user)
const response: httpApi.TokenGrantResponse = { ]);
access_token, refresh_token, const response: httpApi.TokenGrantResponse = {
}; access_token,
res.json(response); refresh_token
}); };
res.json(response);
});
router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => { router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => {
// tslint:disable-next-line:no-shadowed-variable // tslint:disable-next-line:no-shadowed-variable
const token = await authentication.generateDeviceRegistrationToken(); const token = await authentication.generateDeviceRegistrationToken();
res.json({ token }); res.json({ token });
}); });
router.post("/verify", verifyAuthorization(), async (req, res) => { router.post("/verify", verifyAuthorization(), async (req, res) => {
res.json({ res.json({
ok: true, ok: true,
token: req.token, token: req.token
});
}); });
});
return router; return router;
} }

62
server/express/api/users.ts

@ -7,42 +7,42 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization";
import { ServerState } from "@server/state"; import { ServerState } from "@server/state";
export function users(state: ServerState) { export function users(state: ServerState) {
const router = PromiseRouter(); const router = PromiseRouter();
router.use(verifyAuthorization()); router.use(verifyAuthorization());
async function getUser(params: { username: string }): Promise<User> {
const { username } = params;
const user = await state.database.users
.findByUsername(username, { devices: true });
if (!user) {
throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound);
}
return user;
}
router.get("/", (req, res) => { async function getUser(params: { username: string }): Promise<User> {
state.database.users.findAll() const { username } = params;
.then((users_) => { const user = await state.database.users.findByUsername(username, {
res.json({ devices: true
data: users_, });
}); if (!user) {
}); throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound);
}
return user;
}
router.get("/", (req, res) => {
state.database.users.findAll().then(_users => {
res.json({
data: _users
});
}); });
});
router.get("/:username", async (req, res) => { router.get("/:username", async (req, res) => {
const user = await getUser(req.params); const user = await getUser(req.params);
res.json({ res.json({
data: user, data: user
});
}); });
});
router.get("/:username/devices", async (req, res) => { router.get("/:username/devices", async (req, res) => {
const user = await getUser(req.params); const user = await getUser(req.params);
res.json({ res.json({
data: user.devices, data: user.devices
});
}); });
});
return router; return router;
} }

20
server/express/errorHandler.ts

@ -2,13 +2,17 @@ import * as express from "express";
import ApiError from "@common/ApiError"; import ApiError from "@common/ApiError";
const errorHandler: express.ErrorRequestHandler = const errorHandler: express.ErrorRequestHandler = (
(err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { err: any,
if (err instanceof ApiError) { req: express.Request,
res.status(err.statusCode).json(err.toJSON()); res: express.Response,
} else { next: express.NextFunction
next(err); ) => {
} if (err instanceof ApiError) {
}; res.status(err.statusCode).json(err.toJSON());
} else {
next(err);
}
};
export default errorHandler; export default errorHandler;

16
server/express/index.ts

@ -8,17 +8,17 @@ import requestLogger from "./requestLogger";
import serveApp from "./serveApp"; import serveApp from "./serveApp";
export function createApp(state: ServerState) { export function createApp(state: ServerState) {
const app = express(); const app = express();
app.use(requestLogger); app.use(requestLogger);
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
app.use("/api", createApi(state)); app.use("/api", createApi(state));
serveApp(app); serveApp(app);
app.use(errorHandler); app.use(errorHandler);
return app; return app;
} }

8
server/express/serveApp.ts

@ -8,8 +8,8 @@ const paths = require("paths");
const index = path.join(paths.publicDir, "index.html"); const index = path.join(paths.publicDir, "index.html");
export default function serveApp(app: Express) { export default function serveApp(app: Express) {
app.use(serveStatic(paths.clientBuildDir)); app.use(serveStatic(paths.clientBuildDir));
app.get("/*", (req, res) => { app.get("/*", (req, res) => {
res.sendFile(index); res.sendFile(index);
}); });
} }

56
server/express/verifyAuthorization.ts

@ -6,36 +6,44 @@ import * as tok from "@common/TokenClaims";
import { verifyToken } from "@server/authentication"; import { verifyToken } from "@server/authentication";
declare global { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
token?: tok.AccessToken; token?: tok.AccessToken;
}
} }
}
} }
export interface VerifyAuthorizationOpts { export interface VerifyAuthorizationOpts {
type: tok.TokenClaims["type"]; type: tok.TokenClaims["type"];
} }
export function verifyAuthorization(options?: Partial<VerifyAuthorizationOpts>): Express.RequestHandler { export function verifyAuthorization(
const opts: VerifyAuthorizationOpts = { options?: Partial<VerifyAuthorizationOpts>
type: "access", ): Express.RequestHandler {
...options, const opts: VerifyAuthorizationOpts = {
}; type: "access",
return (req, res, next) => { ...options
const fun = async () => { };
const bearer = req.headers.authorization; return (req, res, next) => {
if (!bearer) { const fun = async () => {
throw new ApiError("No Authorization header specified", ErrorCode.BadToken); const bearer = req.headers.authorization;
} if (!bearer) {
const matches = /^Bearer (.*)$/.exec(bearer); throw new ApiError(
if (!matches || !matches[1]) { "No Authorization header specified",
throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken); ErrorCode.BadToken
} );
const token = matches[1]; }
const matches = /^Bearer (.*)$/.exec(bearer);
if (!matches || !matches[1]) {
throw new ApiError(
"Invalid Authorization header, must be Bearer",
ErrorCode.BadToken
);
}
const token = matches[1];
req.token = await verifyToken(token, opts.type) as any; req.token = (await verifyToken(token, opts.type)) as any;
};
fun().then(() => next(null), (err) => next(err));
}; };
fun().then(() => next(null), err => next(err));
};
} }

21
server/index.ts

@ -5,7 +5,7 @@ import "./env";
import "./configureLogger"; import "./configureLogger";
import log from "@common/logger"; import log from "@common/logger";
import {Server} from "http"; import { Server } from "http";
import * as WebSocket from "ws"; import * as WebSocket from "ws";
import { ServerState } from "./state"; import { ServerState } from "./state";
@ -20,15 +20,16 @@ const port = +(process.env.PORT || 8080);
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";
const server = new Server(app); const server = new Server(app);
const webSocketServer = new WebSocket.Server({server}); const webSocketServer = new WebSocket.Server({ server });
webSocketApi.listen(webSocketServer); webSocketApi.listen(webSocketServer);
state.start() state
.then(() => { .start()
server.listen(port, host, () => { .then(() => {
log.info(`listening at ${host}:${port}`); server.listen(port, host, () => {
}); log.info(`listening at ${host}:${port}`);
})
.catch((err) => {
log.error({ err }, "error starting server");
}); });
})
.catch(err => {
log.error({ err }, "error starting server");
});

202
server/logging/prettyPrint.ts

@ -6,137 +6,149 @@ import { Transform, TransformCallback } from "stream";
type Level = "default" | 60 | 50 | 40 | 30 | 20 | 10; type Level = "default" | 60 | 50 | 40 | 30 | 20 | 10;
const levels = { const levels = {
default: "USERLVL", default: "USERLVL",
60: "FATAL", 60: "FATAL",
50: "ERROR", 50: "ERROR",
40: "WARN", 40: "WARN",
30: "INFO", 30: "INFO",
20: "DEBUG", 20: "DEBUG",
10: "TRACE", 10: "TRACE"
}; };
const levelColors = { const levelColors = {
default: chalk.white.underline, default: chalk.white.underline,
60: chalk.bgRed.underline, 60: chalk.bgRed.underline,
50: chalk.red.underline, 50: chalk.red.underline,
40: chalk.yellow.underline, 40: chalk.yellow.underline,
30: chalk.green.underline, 30: chalk.green.underline,
20: chalk.blue.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) { function formatter(value: any) {
let line = formatTime(value, " "); let line = formatTime(value, " ");
line += formatSource(value); line += formatSource(value);
line += asColoredLevel(value); line += asColoredLevel(value);
// line += " ("; // line += " (";
// if (value.name) { // if (value.name) {
// line += value.name + "/"; // line += value.name + "/";
// } // }
// line += value.pid + " on " + value.hostname + ")"; // line += value.pid + " on " + value.hostname + ")";
const isRequest = value.req && value.res; const isRequest = value.req && value.res;
line += ": "; line += ": ";
if (isRequest) { if (isRequest) {
line += chalk.reset(formatRequest(value)); line += chalk.reset(formatRequest(value));
return line;
}
if (value.msg) {
line += chalk.cyan(value.msg);
}
if (value.err) {
line += "\n " + withSpaces(value.err.stack) + "\n";
} else {
line += filter(value);
}
line += "\n";
return line; return line;
}
if (value.msg) {
line += chalk.cyan(value.msg);
}
if (value.err) {
line += "\n " + withSpaces(value.err.stack) + "\n";
} else {
line += filter(value);
}
line += "\n";
return line;
} }
function formatRequest(value: any): string { function formatRequest(value: any): string {
const matches = /Content-Length: (\d+)/.exec(value.res.header); const matches = /Content-Length: (\d+)/.exec(value.res.header);
const contentLength = matches ? matches[1] : null; const contentLength = matches ? matches[1] : null;
return `${value.req.remoteAddress} - ` + return (
`"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` + `${value.req.remoteAddress} - ` +
`${value.responseTime} ms - ${contentLength}`; `"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` +
`${value.responseTime} ms - ${contentLength}`
);
} }
function withSpaces(value: string): string { function withSpaces(value: string): string {
const lines = value.split("\n"); const lines = value.split("\n");
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
lines[i] = " " + lines[i]; lines[i] = " " + lines[i];
} }
return lines.join("\n"); return lines.join("\n");
} }
function filter(value: any) { function filter(value: any) {
const keys = Object.keys(value); const keys = Object.keys(value);
const filteredKeys = standardKeys; const filteredKeys = standardKeys;
let result = ""; let result = "";
for (const key of keys) { for (const key of keys) {
if (filteredKeys.indexOf(key) < 0) { 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));
} }
}
return result; return result;
} }
function asISODate(time: string) { function asISODate(time: string) {
return new Date(time).toISOString(); return new Date(time).toISOString();
} }
function formatTime(value: any, after?: string) { function formatTime(value: any, after?: string) {
after = after || ""; after = after || "";
try { try {
if (!value || !value.time) { if (!value || !value.time) {
return ""; return "";
} else { } else {
return "[" + asISODate(value.time) + "]" + after; return "[" + asISODate(value.time) + "]" + after;
}
} catch (_) {
return "";
} }
} catch (_) {
return "";
}
} }
function formatSource(value: any) { function formatSource(value: any) {
if (value.source) { if (value.source) {
return chalk.magenta("(" + value.source + ") "); return chalk.magenta("(" + value.source + ") ");
} else { } else {
return ""; return "";
} }
} }
function asColoredLevel(value: any) { function asColoredLevel(value: any) {
const level = value.level as Level; const level = value.level as Level;
if (levelColors.hasOwnProperty(level)) { if (levelColors.hasOwnProperty(level)) {
return levelColors[level](levels[level]); return levelColors[level](levels[level]);
} else { } else {
return levelColors.default(levels.default); return levelColors.default(levels.default);
} }
} }
class PrettyPrintTranform extends Transform { class PrettyPrintTranform extends Transform {
_transform(chunk: any, encoding: string, cb: TransformCallback) { _transform(chunk: any, encoding: string, cb: TransformCallback) {
let value: any; let value: any;
try { try {
value = JSON.parse(chunk.toString()); value = JSON.parse(chunk.toString());
} catch (e) { } catch (e) {
process.stdout.write(chunk.toString() + "\n"); process.stdout.write(chunk.toString() + "\n");
return cb(); return cb();
} }
const line = formatter(value); const line = formatter(value);
if (!line) { if (!line) {
return cb(); return cb();
}
process.stdout.write(line);
cb();
} }
process.stdout.write(line);
cb();
}
} }
pump(process.stdin, split(), new PrettyPrintTranform()); pump(process.stdin, split(), new PrettyPrintTranform());

55
server/repositories/SprinklersDeviceRepository.ts

@ -4,30 +4,39 @@ import { SprinklersDevice, User } from "@server/entities";
@EntityRepository(SprinklersDevice) @EntityRepository(SprinklersDevice)
export class SprinklersDeviceRepository extends Repository<SprinklersDevice> { export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
findByName(name: string) { findByName(name: string) {
return this.findOne({ name }); return this.findOne({ name });
} }
async userHasAccess(userId: number, deviceId: number): Promise<boolean> { async userHasAccess(userId: number, deviceId: number): Promise<boolean> {
const count = await this.manager const count = await this.manager
.createQueryBuilder(User, "user") .createQueryBuilder(User, "user")
.innerJoinAndSelect("user.devices", "sprinklers_device", .innerJoinAndSelect(
"user.id = :userId AND sprinklers_device.id = :deviceId", "user.devices",
{ userId, deviceId }) "sprinklers_device",
.getCount(); "user.id = :userId AND sprinklers_device.id = :deviceId",
return count > 0; { userId, deviceId }
} )
.getCount();
return count > 0;
}
async findUserDevice(userId: number, deviceId: number): Promise<SprinklersDevice | null> { async findUserDevice(
const user = await this.manager userId: number,
.createQueryBuilder(User, "user") deviceId: number
.innerJoinAndSelect("user.devices", "sprinklers_device", ): Promise<SprinklersDevice | null> {
"user.id = :userId AND sprinklers_device.id = :deviceId", const user = await this.manager
{ userId, deviceId }) .createQueryBuilder(User, "user")
.getOne(); .innerJoinAndSelect(
if (!user) { "user.devices",
return null; "sprinklers_device",
} "user.id = :userId AND sprinklers_device.id = :deviceId",
return user.devices![0]; { userId, deviceId }
)
.getOne();
if (!user) {
return null;
} }
return user.devices![0];
}
} }

37
server/repositories/UserRepository.ts

@ -3,30 +3,31 @@ import { EntityRepository, FindOneOptions, Repository } from "typeorm";
import { User } from "@server/entities"; import { User } from "@server/entities";
export interface FindUserOptions { export interface FindUserOptions {
devices: boolean; devices: boolean;
} }
function applyDefaultOptions(options?: Partial<FindUserOptions>): FindOneOptions<User> { function applyDefaultOptions(
const opts: FindUserOptions = { devices: false, ...options }; options?: Partial<FindUserOptions>
const relations = [opts.devices && "devices"] ): FindOneOptions<User> {
.filter(Boolean) as string[]; const opts: FindUserOptions = { devices: false, ...options };
return { relations }; const relations = [opts.devices && "devices"].filter(Boolean) as string[];
return { relations };
} }
@EntityRepository(User) @EntityRepository(User)
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<User> {
findAll(options?: Partial<FindUserOptions>) { findAll(options?: Partial<FindUserOptions>) {
const opts = applyDefaultOptions(options); const opts = applyDefaultOptions(options);
return super.find(opts); return super.find(opts);
} }
findById(id: number, options?: Partial<FindUserOptions>) { findById(id: number, options?: Partial<FindUserOptions>) {
const opts = applyDefaultOptions(options); const opts = applyDefaultOptions(options);
return super.findOne(id, opts); return super.findOne(id, opts);
} }
findByUsername(username: string, options?: Partial<FindUserOptions>) { findByUsername(username: string, options?: Partial<FindUserOptions>) {
const opts = applyDefaultOptions(options); const opts = applyDefaultOptions(options);
return this.findOne({ username }, opts); return this.findOne({ username }, opts);
} }
} }

30
server/sprinklersRpc/WebSocketApi.ts

@ -4,23 +4,23 @@ import { ServerState } from "@server/state";
import { WebSocketConnection } from "./WebSocketConnection"; import { WebSocketConnection } from "./WebSocketConnection";
export class WebSocketApi { export class WebSocketApi {
state: ServerState; state: ServerState;
clients: Set<WebSocketConnection> = new Set(); clients: Set<WebSocketConnection> = new Set();
constructor(state: ServerState) { constructor(state: ServerState) {
this.state = state; this.state = state;
} }
listen(webSocketServer: WebSocket.Server) { listen(webSocketServer: WebSocket.Server) {
webSocketServer.on("connection", this.handleConnection); webSocketServer.on("connection", this.handleConnection);
} }
handleConnection = (socket: WebSocket) => { handleConnection = (socket: WebSocket) => {
const client = new WebSocketConnection(this, socket); const client = new WebSocketConnection(this, socket);
this.clients.add(client); this.clients.add(client);
} };
removeClient(client: WebSocketConnection) { removeClient(client: WebSocketConnection) {
return this.clients.delete(client); return this.clients.delete(client);
} }
} }

502
server/sprinklersRpc/WebSocketConnection.ts

@ -18,245 +18,299 @@ import { WebSocketApi } from "./WebSocketApi";
type Disposer = () => void; type Disposer = () => void;
export class WebSocketConnection { export class WebSocketConnection {
api: WebSocketApi; api: WebSocketApi;
socket: WebSocket; socket: WebSocket;
disposers: Array<() => void> = []; disposers: Array<() => void> = [];
// map of device id to disposer function // map of device id to disposer function
deviceSubscriptions: Map<string, Disposer> = new Map(); deviceSubscriptions: Map<string, Disposer> = new Map();
/// This shall be the user id if the client has been authenticated, null otherwise /// This shall be the user id if the client has been authenticated, null otherwise
userId: number | null = null; userId: number | null = null;
user: User | null = null; user: User | null = null;
private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers(); private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers();
get state() { get state() {
return this.api.state; return this.api.state;
} }
constructor(api: WebSocketApi, socket: WebSocket) { constructor(api: WebSocketApi, socket: WebSocket) {
this.api = api; this.api = api;
this.socket = socket; this.socket = socket;
this.socket.on("message", this.handleSocketMessage); this.socket.on("message", this.handleSocketMessage);
this.socket.on("close", this.onClose); this.socket.on("close", this.onClose);
} }
stop = () => { stop = () => {
this.socket.close(); this.socket.close();
} };
onClose = (code: number, reason: string) => { onClose = (code: number, reason: string) => {
log.debug({ code, reason }, "WebSocketConnection closing"); log.debug({ code, reason }, "WebSocketConnection closing");
this.disposers.forEach((disposer) => disposer()); this.disposers.forEach(disposer => disposer());
this.deviceSubscriptions.forEach((disposer) => disposer()); this.deviceSubscriptions.forEach(disposer => disposer());
this.api.removeClient(this); this.api.removeClient(this);
} };
subscribeBrokerConnection() { subscribeBrokerConnection() {
this.disposers.push(autorun(() => { this.disposers.push(
const updateData: ws.IBrokerConnectionUpdate = { autorun(() => {
brokerConnected: this.state.mqttClient.connected, const updateData: ws.IBrokerConnectionUpdate = {
}; brokerConnected: this.state.mqttClient.connected
this.sendNotification("brokerConnectionUpdate", updateData); };
})); this.sendNotification("brokerConnectionUpdate", updateData);
} })
);
checkAuthorization() { }
if (!this.userId || !this.user) {
throw new RpcError("this WebSocket session has not been authenticated", checkAuthorization() {
ErrorCode.Unauthorized); if (!this.userId || !this.user) {
} throw new RpcError(
} "this WebSocket session has not been authenticated",
ErrorCode.Unauthorized
checkDevice(devId: string) { );
const userDevice = this.user!.devices!.find((dev) => dev.deviceId === devId);
if (userDevice == null) {
throw new RpcError("you do not have permission to subscribe to device",
ErrorCode.NoPermission, { id: devId });
}
const deviceId = userDevice.deviceId;
if (!deviceId) {
throw new RpcError("device has no associated device prefix", ErrorCode.Internal);
}
return userDevice;
}
sendMessage(data: ws.ServerMessage) {
this.socket.send(JSON.stringify(data));
} }
}
sendNotification<Method extends ws.ServerNotificationMethod>(
method: Method, checkDevice(devId: string) {
data: ws.IServerNotificationTypes[Method]) { const userDevice = this.user!.devices!.find(dev => dev.deviceId === devId);
this.sendMessage({ type: "notification", method, data }); if (userDevice == null) {
throw new RpcError(
"you do not have permission to subscribe to device",
ErrorCode.NoPermission,
{ id: devId }
);
} }
const deviceId = userDevice.deviceId;
sendResponse<Method extends ws.ClientRequestMethods>( if (!deviceId) {
method: Method, throw new RpcError(
id: number, "device has no associated device prefix",
data: ws.ServerResponseData<Method>) { ErrorCode.Internal
this.sendMessage({ type: "response", method, id, ...data }); );
} }
return userDevice;
handleSocketMessage = (socketData: WebSocket.Data) => { }
this.doHandleSocketMessage(socketData)
.catch((err) => { sendMessage(data: ws.ServerMessage) {
this.onError({ err }, "unhandled error on handling socket message"); this.socket.send(JSON.stringify(data));
}); }
sendNotification<Method extends ws.ServerNotificationMethod>(
method: Method,
data: ws.IServerNotificationTypes[Method]
) {
this.sendMessage({ type: "notification", method, data });
}
sendResponse<Method extends ws.ClientRequestMethods>(
method: Method,
id: number,
data: ws.ServerResponseData<Method>
) {
this.sendMessage({ type: "response", method, id, ...data });
}
handleSocketMessage = (socketData: WebSocket.Data) => {
this.doHandleSocketMessage(socketData).catch(err => {
this.onError({ err }, "unhandled error on handling socket message");
});
};
async doDeviceCallRequest(
requestData: ws.IDeviceCallRequest
): Promise<deviceRequests.Response> {
const userDevice = this.checkDevice(requestData.deviceId);
const deviceId = userDevice.deviceId!;
const device = this.state.mqttClient.acquireDevice(deviceId);
try {
const request = schema.requests.deserializeRequest(requestData.data);
return await device.makeRequest(request);
} finally {
device.release();
} }
}
async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise<deviceRequests.Response> {
const userDevice = this.checkDevice(requestData.deviceId); private async doHandleSocketMessage(socketData: WebSocket.Data) {
const deviceId = userDevice.deviceId!; if (typeof socketData !== "string") {
const device = this.state.mqttClient.acquireDevice(deviceId); return this.onError(
try { { type: typeof socketData },
const request = schema.requests.deserializeRequest(requestData.data); "received invalid socket data type from client",
return await device.makeRequest(request); ErrorCode.Parse
} finally { );
device.release();
}
} }
let data: ws.ClientMessage;
private async doHandleSocketMessage(socketData: WebSocket.Data) { try {
if (typeof socketData !== "string") { data = JSON.parse(socketData);
return this.onError({ type: typeof socketData }, } catch (err) {
"received invalid socket data type from client", ErrorCode.Parse); return this.onError(
} { socketData, err },
let data: ws.ClientMessage; "received invalid websocket message from client",
try { ErrorCode.Parse
data = JSON.parse(socketData); );
} catch (err) {
return this.onError({ socketData, err }, "received invalid websocket message from client",
ErrorCode.Parse);
}
switch (data.type) {
case "request":
await this.handleRequest(data);
break;
default:
return this.onError({ data }, "received invalid message type from client",
ErrorCode.BadRequest);
}
} }
switch (data.type) {
private async handleRequest(request: ws.ClientRequest) { case "request":
let response: ws.ServerResponseData; await this.handleRequest(data);
try { break;
if (!this.requestHandlers[request.method]) { default:
// noinspection ExceptionCaughtLocallyJS return this.onError(
throw new RpcError("received invalid client request method"); { data },
} "received invalid message type from client",
response = await rpc.handleRequest(this.requestHandlers, request, this); ErrorCode.BadRequest
} catch (err) { );
if (err instanceof RpcError) {
log.debug({ err }, "rpc error");
response = { result: "error", error: err.toJSON() };
} else {
log.error({ method: request.method, err }, "unhandled error during processing of client request");
response = {
result: "error", error: {
code: ErrorCode.Internal, message: "unhandled error during processing of client request",
data: err.toString(),
},
};
}
}
this.sendResponse(request.method, request.id, response);
} }
}
private onError(data: any, message: string, code: number = ErrorCode.Internal) {
log.error(data, message); private async handleRequest(request: ws.ClientRequest) {
const errorData: ws.IError = { code, message, data }; let response: ws.ServerResponseData;
this.sendNotification("error", errorData); try {
if (!this.requestHandlers[request.method]) {
// noinspection ExceptionCaughtLocallyJS
throw new RpcError("received invalid client request method");
}
response = await rpc.handleRequest(this.requestHandlers, request, this);
} catch (err) {
if (err instanceof RpcError) {
log.debug({ err }, "rpc error");
response = { result: "error", error: err.toJSON() };
} else {
log.error(
{ method: request.method, err },
"unhandled error during processing of client request"
);
response = {
result: "error",
error: {
code: ErrorCode.Internal,
message: "unhandled error during processing of client request",
data: err.toString()
}
};
}
} }
this.sendResponse(request.method, request.id, response);
}
private onError(
data: any,
message: string,
code: number = ErrorCode.Internal
) {
log.error(data, message);
const errorData: ws.IError = { code, message, data };
this.sendNotification("error", errorData);
}
} }
class WebSocketRequestHandlers implements ws.ClientRequestHandlers { class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
async authenticate(this: WebSocketConnection, data: ws.IAuthenticateRequest): async authenticate(
Promise<ws.ServerResponseData<"authenticate">> { this: WebSocketConnection,
if (!data.accessToken) { data: ws.IAuthenticateRequest
throw new RpcError("no token specified", ErrorCode.BadRequest); ): Promise<ws.ServerResponseData<"authenticate">> {
} if (!data.accessToken) {
let claims: AccessToken; throw new RpcError("no token specified", ErrorCode.BadRequest);
try {
claims = await verifyToken<AccessToken>(data.accessToken, "access");
} catch (e) {
throw new RpcError("invalid token", ErrorCode.BadToken, e);
}
this.userId = claims.aud;
this.user = await this.state.database.users.
findById(this.userId, { devices: true }) || null;
if (!this.user) {
throw new RpcError("user no longer exists", ErrorCode.BadToken);
}
log.debug({ userId: claims.aud, name: claims.name }, "authenticated websocket client");
this.subscribeBrokerConnection();
return {
result: "success",
data: { authenticated: true, message: "authenticated", user: this.user.toJSON() },
};
} }
let claims: AccessToken;
async deviceSubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): try {
Promise<ws.ServerResponseData<"deviceSubscribe">> { claims = await verifyToken<AccessToken>(data.accessToken, "access");
this.checkAuthorization(); } catch (e) {
const userDevice = this.checkDevice(data.deviceId); throw new RpcError("invalid token", ErrorCode.BadToken, e);
const deviceId = userDevice.deviceId!; }
if (!this.deviceSubscriptions.has(deviceId)) { this.userId = claims.aud;
const device = this.state.mqttClient.acquireDevice(deviceId); this.user =
log.debug({ deviceId, userId: this.userId }, "websocket client subscribed to device"); (await this.state.database.users.findById(this.userId, {
devices: true
const autorunDisposer = autorun(() => { })) || null;
const json = serialize(schema.sprinklersDevice, device); if (!this.user) {
log.trace({ device: json }); throw new RpcError("user no longer exists", ErrorCode.BadToken);
const updateData: ws.IDeviceUpdate = { deviceId, data: json }; }
this.sendNotification("deviceUpdate", updateData); log.debug(
}, { delay: 100 }); { userId: claims.aud, name: claims.name },
"authenticated websocket client"
this.deviceSubscriptions.set(deviceId, () => { );
autorunDisposer(); this.subscribeBrokerConnection();
device.release(); return {
this.deviceSubscriptions.delete(deviceId); result: "success",
}); data: {
} authenticated: true,
message: "authenticated",
const response: ws.IDeviceSubscribeResponse = { user: this.user.toJSON()
deviceId, }
}; };
return { result: "success", data: response }; }
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"
);
const autorunDisposer = autorun(
() => {
const json = serialize(schema.sprinklersDevice, device);
log.trace({ device: json });
const updateData: ws.IDeviceUpdate = { deviceId, data: json };
this.sendNotification("deviceUpdate", updateData);
},
{ delay: 100 }
);
this.deviceSubscriptions.set(deviceId, () => {
autorunDisposer();
device.release();
this.deviceSubscriptions.delete(deviceId);
});
} }
async deviceUnsubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): const response: ws.IDeviceSubscribeResponse = {
Promise<ws.ServerResponseData<"deviceUnsubscribe">> { deviceId
this.checkAuthorization(); };
const userDevice = this.checkDevice(data.deviceId); return { result: "success", data: response };
const deviceId = userDevice.deviceId!; }
const disposer = this.deviceSubscriptions.get(deviceId);
async deviceUnsubscribe(
if (disposer) { this: WebSocketConnection,
disposer(); data: ws.IDeviceSubscribeRequest
} ): Promise<ws.ServerResponseData<"deviceUnsubscribe">> {
this.checkAuthorization();
const response: ws.IDeviceSubscribeResponse = { const userDevice = this.checkDevice(data.deviceId);
deviceId, const deviceId = userDevice.deviceId!;
}; const disposer = this.deviceSubscriptions.get(deviceId);
return { result: "success", data: response };
if (disposer) {
disposer();
} }
async deviceCall(this: WebSocketConnection, data: ws.IDeviceCallRequest): const response: ws.IDeviceSubscribeResponse = {
Promise<ws.ServerResponseData<"deviceCall">> { deviceId
this.checkAuthorization(); };
try { return { result: "success", data: response };
const response = await this.doDeviceCallRequest(data); }
const resData: ws.IDeviceCallResponse = {
data: response, async deviceCall(
}; this: WebSocketConnection,
return { result: "success", data: resData }; data: ws.IDeviceCallRequest
} catch (err) { ): Promise<ws.ServerResponseData<"deviceCall">> {
const e: deviceRequests.ErrorResponseData = err; this.checkAuthorization();
throw new RpcError(e.message, e.code, e); try {
} const response = await this.doDeviceCallRequest(data);
const resData: ws.IDeviceCallResponse = {
data: response
};
return { result: "success", data: resData };
} catch (err) {
const e: deviceRequests.ErrorResponseData = err;
throw new RpcError(e.message, e.code, e);
} }
}
} }

42
server/state.ts

@ -5,29 +5,29 @@ import { SUPERUSER } from "@server/express/api/mosquitto";
import { Database } from "./Database"; import { Database } from "./Database";
export class ServerState { export class ServerState {
mqttUrl: string; mqttUrl: string;
mqttClient: mqtt.MqttRpcClient; mqttClient: mqtt.MqttRpcClient;
database: Database; database: Database;
constructor() { constructor() {
const mqttUrl = process.env.MQTT_URL; const mqttUrl = process.env.MQTT_URL;
if (!mqttUrl) { if (!mqttUrl) {
throw new Error("Must specify a MQTT_URL to connect to"); throw new Error("Must specify a MQTT_URL to connect to");
}
this.mqttUrl = mqttUrl;
this.mqttClient = new mqtt.MqttRpcClient({
mqttUri: mqttUrl,
});
this.database = new Database();
} }
this.mqttUrl = mqttUrl;
this.mqttClient = new mqtt.MqttRpcClient({
mqttUri: mqttUrl
});
this.database = new Database();
}
async start() { async start() {
await this.database.connect(); await this.database.connect();
await this.database.createAll(); await this.database.createAll();
logger.info("created database and tables"); logger.info("created database and tables");
this.mqttClient.username = SUPERUSER; this.mqttClient.username = SUPERUSER;
this.mqttClient.password = await generateSuperuserToken(); this.mqttClient.password = await generateSuperuserToken();
this.mqttClient.start(); this.mqttClient.start();
} }
} }

8
server/tsconfig.json

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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save