Use prettier on everything
This commit is contained in:
parent
f1a9d11dcf
commit
e6c3904701
@ -4,4 +4,4 @@ pipeline:
|
||||
commands:
|
||||
- apk add yarn python make g++
|
||||
- yarn install --frozen-lockfile
|
||||
- yarn build
|
||||
- yarn build
|
||||
|
@ -5,18 +5,16 @@
|
||||
### Docker
|
||||
|
||||
```shell
|
||||
|
||||
# for production build (http://localhost:8080)
|
||||
docker-compose up
|
||||
|
||||
# for development with hot-reload (http://localhost:8081)
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
```
|
||||
|
||||
### Not docker
|
||||
|
||||
```shell
|
||||
|
||||
yarn install
|
||||
|
||||
# for production build (http://localhost:8080)
|
||||
@ -25,6 +23,4 @@ yarn start:pretty
|
||||
|
||||
# for development build (http://localhost:8081)
|
||||
yarn start:dev
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
2
build.sh
2
build.sh
@ -23,4 +23,4 @@ docker container cp "$EXTRACT_CONTAINER:/app/public" "$BUILD_DIR/public"
|
||||
docker container rm -f "$EXTRACT_CONTAINER"
|
||||
|
||||
echo "Building dist image $DIST_IMAGE"
|
||||
docker build -t "$DIST_IMAGE" .
|
||||
docker build -t "$DIST_IMAGE" .
|
||||
|
@ -13,29 +13,29 @@ import "semantic-ui-css/semantic.css";
|
||||
import "@client/styles/app";
|
||||
|
||||
function NavContainer() {
|
||||
return (
|
||||
<Container className="app">
|
||||
<NavBar/>
|
||||
return (
|
||||
<Container className="app">
|
||||
<NavBar />
|
||||
|
||||
<Switch>
|
||||
<Route path={route.device(":deviceId")} component={p.DevicePage}/>
|
||||
<Route path={route.device()} component={p.DevicesPage}/>
|
||||
<Route path={route.messagesTest} component={p.MessageTest}/>
|
||||
<Redirect from="/" to={route.device()} />
|
||||
<Redirect to="/"/>
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route path={route.device(":deviceId")} component={p.DevicePage} />
|
||||
<Route path={route.device()} component={p.DevicesPage} />
|
||||
<Route path={route.messagesTest} component={p.MessageTest} />
|
||||
<Redirect from="/" to={route.device()} />
|
||||
<Redirect to="/" />
|
||||
</Switch>
|
||||
|
||||
<MessagesView/>
|
||||
</Container>
|
||||
);
|
||||
<MessagesView />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={route.login} component={p.LoginPage}/>
|
||||
<Route path={route.logout} component={p.LogoutPage}/>
|
||||
<NavContainer/>
|
||||
</Switch>
|
||||
);
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={route.login} component={p.LoginPage} />
|
||||
<Route path={route.logout} component={p.LogoutPage} />
|
||||
<NavContainer />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
@ -2,5 +2,7 @@ import * as React from "react";
|
||||
import { Item, ItemImageProps } from "semantic-ui-react";
|
||||
|
||||
export default function DeviceImage(props: ItemImageProps) {
|
||||
return <Item.Image {...props} src={require("@client/images/raspberry_pi.png")} />;
|
||||
return (
|
||||
<Item.Image {...props} src={require("@client/images/raspberry_pi.png")} />
|
||||
);
|
||||
}
|
||||
|
@ -9,146 +9,182 @@ import * as p from "@client/pages";
|
||||
import * as route from "@client/routePaths";
|
||||
import { AppState, injectState } from "@client/state";
|
||||
import { ISprinklersDevice } from "@common/httpApi";
|
||||
import { ConnectionState as ConState, SprinklersDevice } from "@common/sprinklersRpc";
|
||||
import {
|
||||
ConnectionState as ConState,
|
||||
SprinklersDevice
|
||||
} from "@common/sprinklersRpc";
|
||||
import { Route, RouteComponentProps, withRouter } from "react-router";
|
||||
import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from ".";
|
||||
import {
|
||||
ProgramTable,
|
||||
RunSectionForm,
|
||||
SectionRunnerView,
|
||||
SectionTable
|
||||
} from ".";
|
||||
|
||||
import "@client/styles/DeviceView";
|
||||
|
||||
const ConnectionState = observer(({ connectionState, className }:
|
||||
{ connectionState: ConState, className?: string }) => {
|
||||
const ConnectionState = observer(
|
||||
({
|
||||
connectionState,
|
||||
className
|
||||
}: {
|
||||
connectionState: ConState;
|
||||
className?: string;
|
||||
}) => {
|
||||
const connected = connectionState.isDeviceConnected;
|
||||
let connectionText: string;
|
||||
let iconName: SemanticICONS = "unlinkify";
|
||||
let clazzName: string = "disconnected";
|
||||
if (connected) {
|
||||
connectionText = "Connected";
|
||||
iconName = "linkify";
|
||||
clazzName = "connected";
|
||||
connectionText = "Connected";
|
||||
iconName = "linkify";
|
||||
clazzName = "connected";
|
||||
} else if (connectionState.noPermission) {
|
||||
connectionText = "No permission for this device";
|
||||
iconName = "ban";
|
||||
connectionText = "No permission for this device";
|
||||
iconName = "ban";
|
||||
} else if (connected === false) {
|
||||
connectionText = "Device Disconnected";
|
||||
connectionText = "Device Disconnected";
|
||||
} else if (connectionState.clientToServer === false) {
|
||||
connectionText = "Disconnected from server";
|
||||
connectionText = "Disconnected from server";
|
||||
} else {
|
||||
connectionText = "Unknown";
|
||||
iconName = "question";
|
||||
clazzName = "unknown";
|
||||
connectionText = "Unknown";
|
||||
iconName = "question";
|
||||
clazzName = "unknown";
|
||||
}
|
||||
const classes = classNames("connectionState", clazzName, className);
|
||||
return (
|
||||
<div className={classes}>
|
||||
<Icon name={iconName} />
|
||||
{connectionText}
|
||||
</div>
|
||||
<div className={classes}>
|
||||
<Icon name={iconName} />
|
||||
|
||||
{connectionText}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
interface DeviceViewProps {
|
||||
deviceId: number;
|
||||
appState: AppState;
|
||||
inList?: boolean;
|
||||
deviceId: number;
|
||||
appState: AppState;
|
||||
inList?: boolean;
|
||||
}
|
||||
|
||||
class DeviceView extends React.Component<DeviceViewProps> {
|
||||
deviceInfo: ISprinklersDevice | null = null;
|
||||
device: SprinklersDevice | null = null;
|
||||
deviceInfo: ISprinklersDevice | null = null;
|
||||
device: SprinklersDevice | null = null;
|
||||
|
||||
componentWillUnmount() {
|
||||
componentWillUnmount() {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
}
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const {
|
||||
inList,
|
||||
appState: { uiStore, routerStore }
|
||||
} = this.props;
|
||||
if (!this.deviceInfo || !this.device) {
|
||||
return null;
|
||||
}
|
||||
const { connectionState, sectionRunner, sections } = this.device;
|
||||
if (!connectionState.isAvailable || inList) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<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() {
|
||||
const { userStore, sprinklersRpc } = this.props.appState;
|
||||
const id = this.props.deviceId;
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (this.deviceInfo == null || this.deviceInfo.id !== id) {
|
||||
this.deviceInfo = userStore.findDevice(id);
|
||||
}
|
||||
if (!this.deviceInfo || !this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device = null;
|
||||
}
|
||||
} else {
|
||||
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device.release();
|
||||
}
|
||||
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const { inList, appState: { uiStore, routerStore } } = this.props;
|
||||
if (!this.deviceInfo || !this.device) {
|
||||
return null;
|
||||
}
|
||||
const { connectionState, sectionRunner, sections } = this.device;
|
||||
if (!connectionState.isAvailable || inList) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
render() {
|
||||
this.updateDevice();
|
||||
const { inList } = this.props;
|
||||
let itemContent: React.ReactNode;
|
||||
if (!this.deviceInfo || !this.device) {
|
||||
// TODO: better and link back to devices list
|
||||
itemContent = <span>You do not have access to this device</span>;
|
||||
} else {
|
||||
const { connectionState } = this.device;
|
||||
let header: React.ReactNode;
|
||||
let image: React.ReactNode;
|
||||
if (inList) {
|
||||
// tslint:disable-line:prefer-conditional-expression
|
||||
const devicePath = route.device(this.deviceInfo.id);
|
||||
header = (
|
||||
<Link to={devicePath}>
|
||||
Device <kbd>{this.deviceInfo.name}</kbd>
|
||||
</Link>
|
||||
);
|
||||
image = <DeviceImage size="tiny" as={Link} to={devicePath} />;
|
||||
} else {
|
||||
header = (
|
||||
<span>
|
||||
Device <kbd>{this.deviceInfo.name}</kbd>
|
||||
</span>
|
||||
);
|
||||
image = <DeviceImage />;
|
||||
}
|
||||
itemContent = (
|
||||
<React.Fragment>
|
||||
{image}
|
||||
<Item.Content className="device">
|
||||
<Header as={inList ? "h2" : "h1"}>
|
||||
{header}
|
||||
<ConnectionState connectionState={connectionState} />
|
||||
</Header>
|
||||
<Item.Meta>Raspberry Pi Grinklers Device</Item.Meta>
|
||||
{this.renderBody()}
|
||||
</Item.Content>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
updateDevice() {
|
||||
const { userStore, sprinklersRpc } = this.props.appState;
|
||||
const id = this.props.deviceId;
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (this.deviceInfo == null || this.deviceInfo.id !== id) {
|
||||
this.deviceInfo = userStore.findDevice(id);
|
||||
}
|
||||
if (!this.deviceInfo || !this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device = null;
|
||||
}
|
||||
} else {
|
||||
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
}
|
||||
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.updateDevice();
|
||||
const { inList } = this.props;
|
||||
let itemContent: React.ReactNode;
|
||||
if (!this.deviceInfo || !this.device) {
|
||||
// TODO: better and link back to devices list
|
||||
itemContent = <span>You do not have access to this device</span>;
|
||||
} else {
|
||||
const { connectionState } = this.device;
|
||||
let header: React.ReactNode;
|
||||
let image: React.ReactNode;
|
||||
if (inList) { // tslint:disable-line:prefer-conditional-expression
|
||||
const devicePath = route.device(this.deviceInfo.id);
|
||||
header = <Link to={devicePath}>Device <kbd>{this.deviceInfo.name}</kbd></Link>;
|
||||
image = <DeviceImage size="tiny" as={Link} to={devicePath} />;
|
||||
} else {
|
||||
header = <span>Device <kbd>{this.deviceInfo.name}</kbd></span>;
|
||||
image = <DeviceImage />;
|
||||
}
|
||||
itemContent = (
|
||||
<React.Fragment>
|
||||
{image}
|
||||
<Item.Content className="device">
|
||||
<Header as={inList ? "h2" : "h1"}>
|
||||
{header}
|
||||
<ConnectionState connectionState={connectionState} />
|
||||
</Header>
|
||||
<Item.Meta>
|
||||
Raspberry Pi Grinklers Device
|
||||
</Item.Meta>
|
||||
{this.renderBody()}
|
||||
</Item.Content>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return <Item>{itemContent}</Item>;
|
||||
}
|
||||
return <Item>{itemContent}</Item>;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectState(observer(DeviceView));
|
||||
|
@ -7,70 +7,71 @@ import { Duration } from "@common/Duration";
|
||||
import "@client/styles/DurationView";
|
||||
|
||||
export default class DurationView extends React.Component<{
|
||||
label?: string,
|
||||
inline?: boolean,
|
||||
duration: Duration,
|
||||
onDurationChange?: (newDuration: Duration) => void,
|
||||
className?: string,
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
duration: Duration;
|
||||
onDurationChange?: (newDuration: Duration) => void;
|
||||
className?: string;
|
||||
}> {
|
||||
render() {
|
||||
const { duration, label, inline, onDurationChange, className } = this.props;
|
||||
const inputsClassName = classNames("durationInputs", { inline });
|
||||
if (onDurationChange) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Form.Field inline={inline} className={className}>
|
||||
{label && <label>{label}</label>}
|
||||
<div className={inputsClassName}>
|
||||
<Input
|
||||
type="number"
|
||||
className="durationInput minutes"
|
||||
value={duration.minutes}
|
||||
onChange={this.onMinutesChange}
|
||||
label="M"
|
||||
labelPosition="right"
|
||||
onWheel={this.onWheel}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="durationInput seconds"
|
||||
value={duration.seconds}
|
||||
onChange={this.onSecondsChange}
|
||||
max="60"
|
||||
label="S"
|
||||
labelPosition="right"
|
||||
onWheel={this.onWheel}
|
||||
/>
|
||||
</div>
|
||||
</Form.Field>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className={className}>
|
||||
{label && <label>{label}</label>} {duration.minutes}M {duration.seconds}S
|
||||
</span>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const { duration, label, inline, onDurationChange, className } = this.props;
|
||||
const inputsClassName = classNames("durationInputs", { inline });
|
||||
if (onDurationChange) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Form.Field inline={inline} className={className}>
|
||||
{label && <label>{label}</label>}
|
||||
<div className={inputsClassName}>
|
||||
<Input
|
||||
type="number"
|
||||
className="durationInput minutes"
|
||||
value={duration.minutes}
|
||||
onChange={this.onMinutesChange}
|
||||
label="M"
|
||||
labelPosition="right"
|
||||
onWheel={this.onWheel}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="durationInput seconds"
|
||||
value={duration.seconds}
|
||||
onChange={this.onSecondsChange}
|
||||
max="60"
|
||||
label="S"
|
||||
labelPosition="right"
|
||||
onWheel={this.onWheel}
|
||||
/>
|
||||
</div>
|
||||
</Form.Field>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className={className}>
|
||||
{label && <label>{label}</label>} {duration.minutes}M{" "}
|
||||
{duration.seconds}S
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
|
||||
if (!this.props.onDurationChange || isNaN(Number(value))) {
|
||||
return;
|
||||
}
|
||||
const newMinutes = Number(value);
|
||||
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
|
||||
private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
|
||||
if (!this.props.onDurationChange || isNaN(Number(value))) {
|
||||
return;
|
||||
}
|
||||
const newMinutes = Number(value);
|
||||
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
|
||||
};
|
||||
|
||||
private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
|
||||
if (!this.props.onDurationChange || isNaN(Number(value))) {
|
||||
return;
|
||||
}
|
||||
const newSeconds = Number(value);
|
||||
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
|
||||
private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
|
||||
if (!this.props.onDurationChange || isNaN(Number(value))) {
|
||||
return;
|
||||
}
|
||||
const newSeconds = Number(value);
|
||||
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
|
||||
};
|
||||
|
||||
private onWheel = () => {
|
||||
// do nothing
|
||||
}
|
||||
private onWheel = () => {
|
||||
// do nothing
|
||||
};
|
||||
}
|
||||
|
@ -9,45 +9,49 @@ import "@client/styles/MessagesView";
|
||||
|
||||
@observer
|
||||
class MessageView extends React.Component<{
|
||||
uiStore: UiStore,
|
||||
message: UiMessage,
|
||||
className?: string,
|
||||
uiStore: UiStore;
|
||||
message: UiMessage;
|
||||
className?: string;
|
||||
}> {
|
||||
render() {
|
||||
const { id, ...messageProps } = this.props.message;
|
||||
const className = classNames(messageProps.className, this.props.className);
|
||||
return (
|
||||
<Message
|
||||
{...messageProps}
|
||||
className={className}
|
||||
onDismiss={this.dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id, ...messageProps } = this.props.message;
|
||||
const className = classNames(messageProps.className, this.props.className);
|
||||
return (
|
||||
<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);
|
||||
private dismiss: MessageProps["onDismiss"] = (event, data) => {
|
||||
const { uiStore, message } = this.props;
|
||||
if (message.onDismiss) {
|
||||
message.onDismiss(event, data);
|
||||
}
|
||||
uiStore.messages.remove(message);
|
||||
};
|
||||
}
|
||||
|
||||
class MessagesView extends React.Component<{ appState: AppState }> {
|
||||
render() {
|
||||
const { uiStore } = this.props.appState;
|
||||
const messages = uiStore.messages.map((message) => (
|
||||
<MessageView key={message.id} uiStore={uiStore} message={message} />
|
||||
));
|
||||
messages.reverse();
|
||||
return (
|
||||
<TransitionGroup as={Message.List} className="messages" animation="scale" duration={200}>
|
||||
{messages}
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const { uiStore } = this.props.appState;
|
||||
const messages = uiStore.messages.map(message => (
|
||||
<MessageView key={message.id} uiStore={uiStore} message={message} />
|
||||
));
|
||||
messages.reverse();
|
||||
return (
|
||||
<TransitionGroup
|
||||
as={Message.List}
|
||||
className="messages"
|
||||
animation="scale"
|
||||
duration={200}
|
||||
>
|
||||
{messages}
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectState(observer(MessagesView));
|
||||
|
@ -7,41 +7,38 @@ import * as route from "@client/routePaths";
|
||||
import { AppState, ConsumeState, injectState } from "@client/state";
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavItem = observer(({ to, children }: NavItemProps) => {
|
||||
function consumeState(appState: AppState) {
|
||||
const { location } = appState.routerStore;
|
||||
return (
|
||||
<Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>{children}</Menu.Item>
|
||||
);
|
||||
}
|
||||
function consumeState(appState: AppState) {
|
||||
const { location } = appState.routerStore;
|
||||
return (
|
||||
<Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>
|
||||
{children}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (<ConsumeState>{consumeState}</ConsumeState>);
|
||||
return <ConsumeState>{consumeState}</ConsumeState>;
|
||||
});
|
||||
|
||||
function NavBar({ appState }: { appState: AppState }) {
|
||||
let loginMenu;
|
||||
if (appState.isLoggedIn) {
|
||||
loginMenu = (
|
||||
<NavItem to={route.logout}>Logout</NavItem>
|
||||
);
|
||||
} else {
|
||||
loginMenu = (
|
||||
<NavItem to={route.login}>Login</NavItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Menu>
|
||||
<NavItem to={route.device()}>Devices</NavItem>
|
||||
<NavItem to={route.messagesTest}>Messages test</NavItem>
|
||||
<Menu.Menu position="right">
|
||||
{loginMenu}
|
||||
</Menu.Menu>
|
||||
</Menu>
|
||||
);
|
||||
let loginMenu;
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (appState.isLoggedIn) {
|
||||
loginMenu = <NavItem to={route.logout}>Logout</NavItem>;
|
||||
} else {
|
||||
loginMenu = <NavItem to={route.login}>Login</NavItem>;
|
||||
}
|
||||
return (
|
||||
<Menu>
|
||||
<NavItem to={route.device()}>Devices</NavItem>
|
||||
<NavItem to={route.messagesTest}>Messages test</NavItem>
|
||||
<Menu.Menu position="right">{loginMenu}</Menu.Menu>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(injectState(NavBar));
|
||||
|
@ -1,7 +1,12 @@
|
||||
import classNames = require("classnames");
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { SortableContainer, SortableElement, SortableHandle, SortEnd } from "react-sortable-hoc";
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableElement,
|
||||
SortableHandle,
|
||||
SortEnd
|
||||
} from "react-sortable-hoc";
|
||||
import { Button, Form, Icon, List } from "semantic-ui-react";
|
||||
|
||||
import { DurationView, SectionChooser } from "@client/components/index";
|
||||
@ -14,177 +19,196 @@ import { action } from "mobx";
|
||||
type ItemChangeHandler = (index: number, newItem: ProgramItem) => void;
|
||||
type ItemRemoveHandler = (index: number) => void;
|
||||
|
||||
const Handle = SortableHandle(() => <Button basic icon><Icon name="bars"/></Button>);
|
||||
const Handle = SortableHandle(() => (
|
||||
<Button basic icon>
|
||||
<Icon name="bars" />
|
||||
</Button>
|
||||
));
|
||||
|
||||
@observer
|
||||
class ProgramSequenceItem extends React.Component<{
|
||||
sequenceItem: ProgramItem,
|
||||
idx: number,
|
||||
sections: Section[],
|
||||
editing: boolean,
|
||||
onChange: ItemChangeHandler,
|
||||
onRemove: ItemRemoveHandler,
|
||||
sequenceItem: ProgramItem;
|
||||
idx: number;
|
||||
sections: Section[];
|
||||
editing: boolean;
|
||||
onChange: ItemChangeHandler;
|
||||
onRemove: ItemRemoveHandler;
|
||||
}> {
|
||||
renderContent() {
|
||||
const { editing, sequenceItem, sections } = this.props;
|
||||
const section = sections[sequenceItem.section];
|
||||
const duration = Duration.fromSeconds(sequenceItem.duration);
|
||||
renderContent() {
|
||||
const { editing, sequenceItem, sections } = this.props;
|
||||
const section = sections[sequenceItem.section];
|
||||
const duration = Duration.fromSeconds(sequenceItem.duration);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Form.Group>
|
||||
<Button icon negative onClick={this.onRemove}>
|
||||
<Icon name="cancel" />
|
||||
</Button>
|
||||
<SectionChooser
|
||||
label="Section"
|
||||
sections={sections}
|
||||
sectionId={section.id}
|
||||
onChange={this.onSectionChange}
|
||||
/>
|
||||
<DurationView
|
||||
label="Duration"
|
||||
duration={duration}
|
||||
onDurationChange={this.onDurationChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<List.Header>{section.toString()}</List.Header>
|
||||
<List.Description>for {duration.toString()}</List.Description>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
if (editing) {
|
||||
return (
|
||||
<Form.Group>
|
||||
<Button icon negative onClick={this.onRemove}>
|
||||
<Icon name="cancel" />
|
||||
</Button>
|
||||
<SectionChooser
|
||||
label="Section"
|
||||
sections={sections}
|
||||
sectionId={section.id}
|
||||
onChange={this.onSectionChange}
|
||||
/>
|
||||
<DurationView
|
||||
label="Duration"
|
||||
duration={duration}
|
||||
onDurationChange={this.onDurationChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<List.Header>{section.toString()}</List.Header>
|
||||
<List.Description>for {duration.toString()}</List.Description>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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 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 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);
|
||||
}
|
||||
private onRemove = () => {
|
||||
this.props.onRemove(this.props.idx);
|
||||
};
|
||||
}
|
||||
|
||||
const ProgramSequenceItemD = SortableElement(ProgramSequenceItem);
|
||||
|
||||
const ProgramSequenceList = SortableContainer(observer((props: {
|
||||
className: string,
|
||||
list: ProgramItem[],
|
||||
sections: Section[],
|
||||
editing: boolean,
|
||||
onChange: ItemChangeHandler,
|
||||
onRemove: ItemRemoveHandler,
|
||||
}) => {
|
||||
const { className, list, sections, ...rest } = props;
|
||||
const listItems = list.map((item, index) => {
|
||||
const ProgramSequenceList = SortableContainer(
|
||||
observer(
|
||||
(props: {
|
||||
className: string;
|
||||
list: ProgramItem[];
|
||||
sections: Section[];
|
||||
editing: boolean;
|
||||
onChange: ItemChangeHandler;
|
||||
onRemove: ItemRemoveHandler;
|
||||
}) => {
|
||||
const { className, list, sections, ...rest } = props;
|
||||
const listItems = list.map((item, index) => {
|
||||
const key = `item-${index}`;
|
||||
return (
|
||||
<ProgramSequenceItemD
|
||||
{...rest}
|
||||
key={key}
|
||||
sequenceItem={item}
|
||||
index={index}
|
||||
idx={index}
|
||||
sections={sections}
|
||||
/>
|
||||
<ProgramSequenceItemD
|
||||
{...rest}
|
||||
key={key}
|
||||
sequenceItem={item}
|
||||
index={index}
|
||||
idx={index}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return <ul className={className}>{listItems}</ul>;
|
||||
}), { withRef: true });
|
||||
});
|
||||
return <ul className={className}>{listItems}</ul>;
|
||||
}
|
||||
),
|
||||
{ withRef: true }
|
||||
);
|
||||
|
||||
@observer
|
||||
class ProgramSequenceView extends React.Component<{
|
||||
sequence: ProgramItem[], sections: Section[], editing?: boolean,
|
||||
sequence: ProgramItem[];
|
||||
sections: Section[];
|
||||
editing?: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const { sequence, sections } = this.props;
|
||||
const editing = this.props.editing || false;
|
||||
const className = classNames("programSequence", { editing });
|
||||
let addButton: React.ReactNode = null;
|
||||
if (editing) {
|
||||
addButton = (
|
||||
<Button onClick={this.addItem}>
|
||||
<Icon name="add"/>
|
||||
Add item
|
||||
</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>
|
||||
);
|
||||
render() {
|
||||
const { sequence, sections } = this.props;
|
||||
const editing = this.props.editing || false;
|
||||
const className = classNames("programSequence", { editing });
|
||||
let addButton: React.ReactNode = null;
|
||||
if (editing) {
|
||||
addButton = (
|
||||
<Button onClick={this.addItem}>
|
||||
<Icon name="add" />
|
||||
Add item
|
||||
</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;
|
||||
}
|
||||
@action.bound
|
||||
private changeItem: ItemChangeHandler = (index, newItem) => {
|
||||
this.props.sequence[index] = newItem;
|
||||
};
|
||||
|
||||
@action.bound
|
||||
private removeItem: ItemRemoveHandler = (index) => {
|
||||
this.props.sequence.splice(index, 1);
|
||||
}
|
||||
@action.bound
|
||||
private removeItem: ItemRemoveHandler = index => {
|
||||
this.props.sequence.splice(index, 1);
|
||||
};
|
||||
|
||||
@action.bound
|
||||
private addItem() {
|
||||
let sectionId = 0;
|
||||
for (const section of this.props.sections) {
|
||||
const sectionNotIncluded = this.props.sequence
|
||||
.every((sequenceItem) =>
|
||||
sequenceItem.section !== section.id);
|
||||
if (sectionNotIncluded) {
|
||||
sectionId = section.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const item = new ProgramItem({
|
||||
section: sectionId,
|
||||
duration: new Duration(5, 0).toSeconds(),
|
||||
});
|
||||
this.props.sequence.push(item);
|
||||
@action.bound
|
||||
private addItem() {
|
||||
let sectionId = 0;
|
||||
for (const section of this.props.sections) {
|
||||
const sectionNotIncluded = this.props.sequence.every(
|
||||
sequenceItem => sequenceItem.section !== section.id
|
||||
);
|
||||
if (sectionNotIncluded) {
|
||||
sectionId = section.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const item = new ProgramItem({
|
||||
section: sectionId,
|
||||
duration: new Duration(5, 0).toSeconds()
|
||||
});
|
||||
this.props.sequence.push(item);
|
||||
}
|
||||
|
||||
@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]);
|
||||
@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);
|
||||
|
@ -11,143 +11,161 @@ import { Program, SprinklersDevice } from "@common/sprinklersRpc";
|
||||
|
||||
@observer
|
||||
class ProgramRows extends React.Component<{
|
||||
program: Program,
|
||||
iDevice: ISprinklersDevice,
|
||||
device: SprinklersDevice,
|
||||
routerStore: RouterStore,
|
||||
expanded: boolean, toggleExpanded: (program: Program) => void,
|
||||
program: Program;
|
||||
iDevice: ISprinklersDevice;
|
||||
device: SprinklersDevice;
|
||||
routerStore: RouterStore;
|
||||
expanded: boolean;
|
||||
toggleExpanded: (program: Program) => void;
|
||||
}> {
|
||||
render() {
|
||||
const { program, iDevice, device, expanded } = this.props;
|
||||
const { sections } = device;
|
||||
render() {
|
||||
const { program, iDevice, device, expanded } = this.props;
|
||||
const { sections } = device;
|
||||
|
||||
const { name, running, enabled, schedule, sequence } = program;
|
||||
const { name, running, enabled, schedule, sequence } = program;
|
||||
|
||||
const buttonStyle: ButtonProps = { size: "small", compact: false };
|
||||
const detailUrl = route.program(iDevice.id, program.id);
|
||||
const buttonStyle: ButtonProps = { size: "small", compact: false };
|
||||
const detailUrl = route.program(iDevice.id, program.id);
|
||||
|
||||
const stopStartButton = (
|
||||
<Button onClick={this.cancelOrRun} {...buttonStyle} positive={!running} negative={running}>
|
||||
<Icon name={running ? "stop" : "play"} />
|
||||
{running ? "Stop" : "Run"}
|
||||
</Button>
|
||||
);
|
||||
const stopStartButton = (
|
||||
<Button
|
||||
onClick={this.cancelOrRun}
|
||||
{...buttonStyle}
|
||||
positive={!running}
|
||||
negative={running}
|
||||
>
|
||||
<Icon name={running ? "stop" : "play"} />
|
||||
{running ? "Stop" : "Run"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const mainRow = (
|
||||
<Table.Row>
|
||||
<Table.Cell className="program--number">{"" + program.id}</Table.Cell>
|
||||
<Table.Cell className="program--name">{name}</Table.Cell>
|
||||
<Table.Cell className="program--enabled">{enabled ? "Enabled" : "Not enabled"}</Table.Cell>
|
||||
<Table.Cell className="program--running">
|
||||
<span>{running ? "Running" : "Not running"}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{stopStartButton}
|
||||
<Button as={Link} to={detailUrl} {...buttonStyle} primary>
|
||||
<Icon name="edit" />
|
||||
Open
|
||||
</Button>
|
||||
<Button onClick={this.toggleExpanded} {...buttonStyle}>
|
||||
<Icon name="list" />
|
||||
{expanded ? "Hide Details" : "Show Details"}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
const detailRow = expanded && (
|
||||
<Table.Row>
|
||||
<Table.Cell className="program--sequence" colSpan="5">
|
||||
<Form>
|
||||
<h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={sections} />
|
||||
<ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} />
|
||||
</Form>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{mainRow}
|
||||
{detailRow}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const mainRow = (
|
||||
<Table.Row>
|
||||
<Table.Cell className="program--number">{"" + program.id}</Table.Cell>
|
||||
<Table.Cell className="program--name">{name}</Table.Cell>
|
||||
<Table.Cell className="program--enabled">
|
||||
{enabled ? "Enabled" : "Not enabled"}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="program--running">
|
||||
<span>{running ? "Running" : "Not running"}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{stopStartButton}
|
||||
<Button as={Link} to={detailUrl} {...buttonStyle} primary>
|
||||
<Icon name="edit" />
|
||||
Open
|
||||
</Button>
|
||||
<Button onClick={this.toggleExpanded} {...buttonStyle}>
|
||||
<Icon name="list" />
|
||||
{expanded ? "Hide Details" : "Show Details"}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
const detailRow = expanded && (
|
||||
<Table.Row>
|
||||
<Table.Cell className="program--sequence" colSpan="5">
|
||||
<Form>
|
||||
<h4>Sequence: </h4>{" "}
|
||||
<ProgramSequenceView sequence={sequence} sections={sections} />
|
||||
<ScheduleView schedule={schedule} label={<h4>Schedule: </h4>} />
|
||||
</Form>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{mainRow}
|
||||
{detailRow}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private cancelOrRun = () => {
|
||||
const { program } = this.props;
|
||||
program.running ? program.cancel() : program.run();
|
||||
}
|
||||
private cancelOrRun = () => {
|
||||
const { program } = this.props;
|
||||
program.running ? program.cancel() : program.run();
|
||||
};
|
||||
|
||||
private toggleExpanded = () => {
|
||||
this.props.toggleExpanded(this.props.program);
|
||||
}
|
||||
private toggleExpanded = () => {
|
||||
this.props.toggleExpanded(this.props.program);
|
||||
};
|
||||
}
|
||||
|
||||
type ProgramId = Program["id"];
|
||||
|
||||
@observer
|
||||
export default class ProgramTable extends React.Component<{
|
||||
iDevice: ISprinklersDevice, device: SprinklersDevice, routerStore: RouterStore,
|
||||
}, {
|
||||
expandedPrograms: ProgramId[],
|
||||
}> {
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
this.state = { expandedPrograms: [] };
|
||||
}
|
||||
export default class ProgramTable extends React.Component<
|
||||
{
|
||||
iDevice: ISprinklersDevice;
|
||||
device: SprinklersDevice;
|
||||
routerStore: RouterStore;
|
||||
},
|
||||
{
|
||||
expandedPrograms: ProgramId[];
|
||||
}
|
||||
> {
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
this.state = { expandedPrograms: [] };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { programs } = this.props.device;
|
||||
const programRows = programs.map(this.renderRows);
|
||||
render() {
|
||||
const { programs } = this.props.device;
|
||||
const programRows = programs.map(this.renderRows);
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="7">Programs</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell className="program--number">#</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--name">Name</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--enabled">Enabled?</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--running">Running?</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--actions">Actions</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{programRows}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="7">Programs</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell className="program--number">#</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--name">Name</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--enabled">
|
||||
Enabled?
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--running">
|
||||
Running?
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell className="program--actions">
|
||||
Actions
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{programRows}</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
private renderRows = (program: Program, i: number): JSX.Element | null => {
|
||||
if (!program) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
private renderRows = (program: Program, i: number): JSX.Element | null => {
|
||||
if (!program) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private toggleExpanded = (program: Program) => {
|
||||
const { expandedPrograms } = this.state;
|
||||
const idx = expandedPrograms.indexOf(program.id);
|
||||
if (idx !== -1) {
|
||||
expandedPrograms.splice(idx, 1);
|
||||
} else {
|
||||
expandedPrograms.push(program.id);
|
||||
}
|
||||
this.setState({
|
||||
expandedPrograms,
|
||||
});
|
||||
private toggleExpanded = (program: Program) => {
|
||||
const { expandedPrograms } = this.state;
|
||||
const idx = expandedPrograms.indexOf(program.id);
|
||||
if (idx !== -1) {
|
||||
expandedPrograms.splice(idx, 1);
|
||||
} else {
|
||||
expandedPrograms.push(program.id);
|
||||
}
|
||||
this.setState({
|
||||
expandedPrograms
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -10,88 +10,91 @@ import { Section, SprinklersDevice } from "@common/sprinklersRpc";
|
||||
import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests";
|
||||
|
||||
@observer
|
||||
export default class RunSectionForm extends React.Component<{
|
||||
device: SprinklersDevice,
|
||||
uiStore: UiStore,
|
||||
}, {
|
||||
duration: Duration,
|
||||
sectionId: number | undefined,
|
||||
}> {
|
||||
constructor(props: any, context?: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
duration: new Duration(0, 0),
|
||||
sectionId: undefined,
|
||||
};
|
||||
}
|
||||
export default class RunSectionForm extends React.Component<
|
||||
{
|
||||
device: SprinklersDevice;
|
||||
uiStore: UiStore;
|
||||
},
|
||||
{
|
||||
duration: Duration;
|
||||
sectionId: number | undefined;
|
||||
}
|
||||
> {
|
||||
constructor(props: any, context?: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
duration: new Duration(0, 0),
|
||||
sectionId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sectionId, duration } = this.state;
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Run Section</Header>
|
||||
<Form>
|
||||
<SectionChooser
|
||||
label="Section"
|
||||
sections={this.props.device.sections}
|
||||
sectionId={sectionId}
|
||||
onChange={this.onSectionChange}
|
||||
/>
|
||||
<DurationView
|
||||
label="Duration"
|
||||
duration={duration}
|
||||
onDurationChange={this.onDurationChange}
|
||||
/>
|
||||
<Form.Button
|
||||
primary
|
||||
onClick={this.run}
|
||||
disabled={!this.isValid}
|
||||
>
|
||||
<Icon name="play"/>
|
||||
Run
|
||||
</Form.Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const { sectionId, duration } = this.state;
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Run Section</Header>
|
||||
<Form>
|
||||
<SectionChooser
|
||||
label="Section"
|
||||
sections={this.props.device.sections}
|
||||
sectionId={sectionId}
|
||||
onChange={this.onSectionChange}
|
||||
/>
|
||||
<DurationView
|
||||
label="Duration"
|
||||
duration={duration}
|
||||
onDurationChange={this.onDurationChange}
|
||||
/>
|
||||
<Form.Button primary onClick={this.run} disabled={!this.isValid}>
|
||||
<Icon name="play" />
|
||||
Run
|
||||
</Form.Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
private onSectionChange = (newSectionId: number) => {
|
||||
this.setState({ sectionId: newSectionId });
|
||||
}
|
||||
private onSectionChange = (newSectionId: number) => {
|
||||
this.setState({ sectionId: newSectionId });
|
||||
};
|
||||
|
||||
private onDurationChange = (newDuration: Duration) => {
|
||||
this.setState({ duration: newDuration });
|
||||
}
|
||||
private onDurationChange = (newDuration: Duration) => {
|
||||
this.setState({ duration: newDuration });
|
||||
};
|
||||
|
||||
private run = (e: React.SyntheticEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
const { sectionId, duration } = this.state;
|
||||
if (sectionId == null) {
|
||||
return;
|
||||
}
|
||||
const section = this.props.device.sections[sectionId];
|
||||
section.run(duration.toSeconds())
|
||||
.then(this.onRunSuccess)
|
||||
.catch(this.onRunError);
|
||||
private run = (e: React.SyntheticEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
const { sectionId, duration } = this.state;
|
||||
if (sectionId == null) {
|
||||
return;
|
||||
}
|
||||
const section = this.props.device.sections[sectionId];
|
||||
section
|
||||
.run(duration.toSeconds())
|
||||
.then(this.onRunSuccess)
|
||||
.catch(this.onRunError);
|
||||
};
|
||||
|
||||
private onRunSuccess = (result: RunSectionResponse) => {
|
||||
log.debug({ result }, "requested section run");
|
||||
this.props.uiStore.addMessage({
|
||||
success: true, header: "Section running",
|
||||
content: result.message, timeout: 2000,
|
||||
});
|
||||
}
|
||||
private onRunSuccess = (result: RunSectionResponse) => {
|
||||
log.debug({ result }, "requested section run");
|
||||
this.props.uiStore.addMessage({
|
||||
success: true,
|
||||
header: "Section running",
|
||||
content: result.message,
|
||||
timeout: 2000
|
||||
});
|
||||
};
|
||||
|
||||
private onRunError = (err: RunSectionResponse) => {
|
||||
log.error(err, "error running section");
|
||||
this.props.uiStore.addMessage({
|
||||
error: true, header: "Error running section",
|
||||
content: err.message,
|
||||
});
|
||||
}
|
||||
private onRunError = (err: RunSectionResponse) => {
|
||||
log.error(err, "error running section");
|
||||
this.props.uiStore.addMessage({
|
||||
error: true,
|
||||
header: "Error running section",
|
||||
content: err.message
|
||||
});
|
||||
};
|
||||
|
||||
private get isValid(): boolean {
|
||||
return this.state.sectionId != null && this.state.duration.toSeconds() > 0;
|
||||
}
|
||||
private get isValid(): boolean {
|
||||
return this.state.sectionId != null && this.state.duration.toSeconds() > 0;
|
||||
}
|
||||
}
|
||||
|
@ -7,75 +7,101 @@ import { DateOfYear } from "@common/sprinklersRpc";
|
||||
const HTML_DATE_INPUT_FORMAT = "YYYY-MM-DD";
|
||||
|
||||
export interface ScheduleDateProps {
|
||||
date: DateOfYear | null | undefined;
|
||||
label: string | React.ReactNode | undefined;
|
||||
editing: boolean | undefined;
|
||||
onChange: (newDate: DateOfYear | null) => void;
|
||||
date: DateOfYear | null | undefined;
|
||||
label: string | React.ReactNode | undefined;
|
||||
editing: boolean | undefined;
|
||||
onChange: (newDate: DateOfYear | null) => void;
|
||||
}
|
||||
|
||||
interface ScheduleDateState {
|
||||
rawValue: string | "";
|
||||
lastDate: DateOfYear | null | undefined;
|
||||
rawValue: string | "";
|
||||
lastDate: DateOfYear | null | undefined;
|
||||
}
|
||||
|
||||
export default class ScheduleDate extends React.Component<ScheduleDateProps, ScheduleDateState> {
|
||||
static getDerivedStateFromProps(props: ScheduleDateProps, state: ScheduleDateState): Partial<ScheduleDateState> {
|
||||
if (!DateOfYear.equals(props.date, state.lastDate)) {
|
||||
const thisYear = moment().year();
|
||||
const rawValue = props.date == null ? "" :
|
||||
moment(props.date).year(thisYear).format(HTML_DATE_INPUT_FORMAT);
|
||||
return { lastDate: props.date, rawValue };
|
||||
}
|
||||
return {};
|
||||
export default class ScheduleDate extends React.Component<
|
||||
ScheduleDateProps,
|
||||
ScheduleDateState
|
||||
> {
|
||||
static getDerivedStateFromProps(
|
||||
props: ScheduleDateProps,
|
||||
state: ScheduleDateState
|
||||
): Partial<ScheduleDateState> {
|
||||
if (!DateOfYear.equals(props.date, state.lastDate)) {
|
||||
const thisYear = moment().year();
|
||||
const rawValue =
|
||||
props.date == null
|
||||
? ""
|
||||
: moment(props.date)
|
||||
.year(thisYear)
|
||||
.format(HTML_DATE_INPUT_FORMAT);
|
||||
return { lastDate: props.date, rawValue };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
constructor(p: ScheduleDateProps) {
|
||||
super(p);
|
||||
this.state = { rawValue: "", lastDate: undefined };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { date, label, editing } = this.props;
|
||||
|
||||
let dayNode: React.ReactNode;
|
||||
if (editing) {
|
||||
// tslint:disable-line:prefer-conditional-expression
|
||||
let clearIcon: React.ReactNode | undefined;
|
||||
if (date) {
|
||||
clearIcon = <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>;
|
||||
}
|
||||
|
||||
constructor(p: ScheduleDateProps) {
|
||||
super(p);
|
||||
this.state = { rawValue: "", lastDate: undefined };
|
||||
let labelNode: React.ReactNode = null;
|
||||
if (typeof label === "string") {
|
||||
labelNode = <label>{label}</label>;
|
||||
} else if (label != null) {
|
||||
labelNode = label;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { date, label, editing } = this.props;
|
||||
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
|
||||
) => {
|
||||
const { onChange } = this.props;
|
||||
if (!onChange) return;
|
||||
const m = moment(data.value, HTML_DATE_INPUT_FORMAT);
|
||||
onChange(DateOfYear.fromMoment(m).with({ year: 0 }));
|
||||
};
|
||||
|
||||
let labelNode: React.ReactNode = null;
|
||||
if (typeof label === "string") {
|
||||
labelNode = <label>{label}</label>;
|
||||
} else if (label != null) {
|
||||
labelNode = label;
|
||||
}
|
||||
|
||||
return <Form.Field inline>{labelNode}{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);
|
||||
}
|
||||
private onClear = () => {
|
||||
const { onChange } = this.props;
|
||||
if (!onChange) return;
|
||||
onChange(null);
|
||||
};
|
||||
}
|
||||
|
@ -6,37 +6,41 @@ import { TimeOfDay } from "@common/sprinklersRpc";
|
||||
import TimeInput from "./TimeInput";
|
||||
|
||||
function timeToString(time: TimeOfDay) {
|
||||
return moment(time).format("LTS");
|
||||
return moment(time).format("LTS");
|
||||
}
|
||||
|
||||
export default class ScheduleTimes extends React.Component<{
|
||||
times: TimeOfDay[];
|
||||
onChange: (newTimes: TimeOfDay[]) => void;
|
||||
editing: boolean;
|
||||
times: TimeOfDay[];
|
||||
onChange: (newTimes: TimeOfDay[]) => void;
|
||||
editing: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const { times, editing } = this.props;
|
||||
let timesNode: React.ReactNode;
|
||||
if (editing) {
|
||||
timesNode = times
|
||||
.map((time, i) => <TimeInput value={time} key={i} index={i} onChange={this.onTimeChange} />);
|
||||
} else {
|
||||
timesNode = (
|
||||
<span>
|
||||
{times.map((time) => timeToString(time)).join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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);
|
||||
render() {
|
||||
const { times, editing } = this.props;
|
||||
let timesNode: React.ReactNode;
|
||||
if (editing) {
|
||||
timesNode = times.map((time, i) => (
|
||||
<TimeInput
|
||||
value={time}
|
||||
key={i}
|
||||
index={i}
|
||||
onChange={this.onTimeChange}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
timesNode = (
|
||||
<span>{times.map(time => timeToString(time)).join(", ")}</span>
|
||||
);
|
||||
}
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
@ -7,49 +7,68 @@ import { TimeOfDay } from "@common/sprinklersRpc";
|
||||
const HTML_TIME_INPUT_FORMAT = "HH:mm";
|
||||
|
||||
function timeOfDayToHtmlDateInput(tod: TimeOfDay): string {
|
||||
return moment(tod).format(HTML_TIME_INPUT_FORMAT);
|
||||
return moment(tod).format(HTML_TIME_INPUT_FORMAT);
|
||||
}
|
||||
|
||||
export interface TimeInputProps {
|
||||
value: TimeOfDay;
|
||||
index: number;
|
||||
onChange: (newValue: TimeOfDay, index: number) => void;
|
||||
value: TimeOfDay;
|
||||
index: number;
|
||||
onChange: (newValue: TimeOfDay, index: number) => void;
|
||||
}
|
||||
|
||||
export interface TimeInputState {
|
||||
rawValue: string;
|
||||
lastTime: TimeOfDay | null;
|
||||
rawValue: string;
|
||||
lastTime: TimeOfDay | null;
|
||||
}
|
||||
|
||||
export default class TimeInput extends React.Component<TimeInputProps, TimeInputState> {
|
||||
static getDerivedStateFromProps(props: TimeInputProps, state: TimeInputState): Partial<TimeInputState> {
|
||||
if (!TimeOfDay.equals(props.value, state.lastTime)) {
|
||||
return { lastTime: props.value, rawValue: timeOfDayToHtmlDateInput(props.value) };
|
||||
}
|
||||
return {};
|
||||
export default class TimeInput extends React.Component<
|
||||
TimeInputProps,
|
||||
TimeInputState
|
||||
> {
|
||||
static getDerivedStateFromProps(
|
||||
props: TimeInputProps,
|
||||
state: TimeInputState
|
||||
): Partial<TimeInputState> {
|
||||
if (!TimeOfDay.equals(props.value, state.lastTime)) {
|
||||
return {
|
||||
lastTime: props.value,
|
||||
rawValue: timeOfDayToHtmlDateInput(props.value)
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
this.state = { rawValue: "", lastTime: null };
|
||||
}
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
this.state = { rawValue: "", lastTime: null };
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Input type="time" value={this.state.rawValue} onChange={this.onChange} onBlur={this.onBlur} />;
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={this.state.rawValue}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private onChange = (e: React.SyntheticEvent<HTMLInputElement>, data: InputOnChangeData) => {
|
||||
this.setState({
|
||||
rawValue: data.value,
|
||||
});
|
||||
}
|
||||
private onChange = (
|
||||
e: React.SyntheticEvent<HTMLInputElement>,
|
||||
data: InputOnChangeData
|
||||
) => {
|
||||
this.setState({
|
||||
rawValue: data.value
|
||||
});
|
||||
};
|
||||
|
||||
private onBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
const m = moment(this.state.rawValue, HTML_TIME_INPUT_FORMAT);
|
||||
if (m.isValid()) {
|
||||
this.props.onChange(TimeOfDay.fromMoment(m), this.props.index);
|
||||
} else {
|
||||
this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) });
|
||||
}
|
||||
private onBlur: React.FocusEventHandler<HTMLInputElement> = e => {
|
||||
const m = moment(this.state.rawValue, HTML_TIME_INPUT_FORMAT);
|
||||
if (m.isValid()) {
|
||||
this.props.onChange(TimeOfDay.fromMoment(m), this.props.index);
|
||||
} else {
|
||||
this.setState({ rawValue: timeOfDayToHtmlDateInput(this.props.value) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -4,51 +4,56 @@ import { Checkbox, CheckboxProps, Form } from "semantic-ui-react";
|
||||
import { Weekday, WEEKDAYS } from "@common/sprinklersRpc";
|
||||
|
||||
export interface WeekdaysViewProps {
|
||||
weekdays: Weekday[];
|
||||
editing: boolean;
|
||||
onChange?: (newWeekdays: Weekday[]) => void;
|
||||
weekdays: Weekday[];
|
||||
editing: boolean;
|
||||
onChange?: (newWeekdays: Weekday[]) => void;
|
||||
}
|
||||
|
||||
export default class WeekdaysView extends React.Component<WeekdaysViewProps> {
|
||||
render() {
|
||||
const { weekdays, editing } = this.props;
|
||||
let node: React.ReactNode;
|
||||
if (editing) {
|
||||
node = WEEKDAYS.map((weekday) => {
|
||||
const checked = weekdays.find((wd) => wd === weekday) != null;
|
||||
const name = Weekday[weekday];
|
||||
return (
|
||||
<Form.Field
|
||||
control={Checkbox}
|
||||
x-weekday={weekday}
|
||||
label={name}
|
||||
checked={checked}
|
||||
key={weekday}
|
||||
onChange={this.toggleWeekday}
|
||||
/>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
node = weekdays.map((weekday) => Weekday[weekday]).join(", ");
|
||||
}
|
||||
render() {
|
||||
const { weekdays, editing } = this.props;
|
||||
let node: React.ReactNode;
|
||||
if (editing) {
|
||||
node = WEEKDAYS.map(weekday => {
|
||||
const checked = weekdays.find(wd => wd === weekday) != null;
|
||||
const name = Weekday[weekday];
|
||||
return (
|
||||
<Form.Group inline>
|
||||
<label>On</label> {node}
|
||||
</Form.Group>
|
||||
<Form.Field
|
||||
control={Checkbox}
|
||||
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) => {
|
||||
const { weekdays, onChange } = this.props;
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const weekday: Weekday = Number(event.currentTarget.getAttribute("x-weekday"));
|
||||
if (data.checked) {
|
||||
const newWeekdays = weekdays.concat([weekday]);
|
||||
newWeekdays.sort();
|
||||
onChange(newWeekdays);
|
||||
} else {
|
||||
onChange(weekdays.filter((wd) => wd !== weekday));
|
||||
}
|
||||
return (
|
||||
<Form.Group inline>
|
||||
<label>On</label> {node}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
private toggleWeekday = (
|
||||
event: React.FormEvent<HTMLInputElement>,
|
||||
data: CheckboxProps
|
||||
) => {
|
||||
const { weekdays, onChange } = this.props;
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const weekday: Weekday = Number(
|
||||
event.currentTarget.getAttribute("x-weekday")
|
||||
);
|
||||
if (data.checked) {
|
||||
const newWeekdays = weekdays.concat([weekday]);
|
||||
newWeekdays.sort();
|
||||
onChange(newWeekdays);
|
||||
} else {
|
||||
onChange(weekdays.filter(wd => wd !== weekday));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -2,7 +2,12 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Form } from "semantic-ui-react";
|
||||
|
||||
import { DateOfYear, Schedule, TimeOfDay, Weekday } from "@common/sprinklersRpc";
|
||||
import {
|
||||
DateOfYear,
|
||||
Schedule,
|
||||
TimeOfDay,
|
||||
Weekday
|
||||
} from "@common/sprinklersRpc";
|
||||
import ScheduleDate from "./ScheduleDate";
|
||||
import ScheduleTimes from "./ScheduleTimes";
|
||||
import WeekdaysView from "./WeekdaysView";
|
||||
@ -11,52 +16,70 @@ import "@client/styles/ScheduleView";
|
||||
import { action } from "mobx";
|
||||
|
||||
export interface ScheduleViewProps {
|
||||
label?: string | React.ReactNode | undefined;
|
||||
schedule: Schedule;
|
||||
editing?: boolean;
|
||||
label?: string | React.ReactNode | undefined;
|
||||
schedule: Schedule;
|
||||
editing?: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class ScheduleView extends React.Component<ScheduleViewProps> {
|
||||
render() {
|
||||
const { schedule, label } = this.props;
|
||||
const editing = this.props.editing || false;
|
||||
render() {
|
||||
const { schedule, label } = this.props;
|
||||
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>
|
||||
);
|
||||
let labelNode: React.ReactNode;
|
||||
if (typeof label === "string") {
|
||||
labelNode = <label>{label}</label>;
|
||||
} else if (label != null) {
|
||||
labelNode = label;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private updateTimes(newTimes: TimeOfDay[]) {
|
||||
this.props.schedule.times = newTimes;
|
||||
}
|
||||
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
|
||||
private updateWeekdays(newWeekdays: Weekday[]) {
|
||||
this.props.schedule.weekdays = newWeekdays;
|
||||
}
|
||||
@action.bound
|
||||
private updateTimes(newTimes: TimeOfDay[]) {
|
||||
this.props.schedule.times = newTimes;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private updateFromDate(newFromDate: DateOfYear | null) {
|
||||
this.props.schedule.from = newFromDate;
|
||||
}
|
||||
@action.bound
|
||||
private updateWeekdays(newWeekdays: Weekday[]) {
|
||||
this.props.schedule.weekdays = newWeekdays;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private updateToDate(newToDate: DateOfYear | null) {
|
||||
this.props.schedule.to = newToDate;
|
||||
}
|
||||
@action.bound
|
||||
private updateFromDate(newFromDate: DateOfYear | null) {
|
||||
this.props.schedule.from = newFromDate;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private updateToDate(newToDate: DateOfYear | null) {
|
||||
this.props.schedule.to = newToDate;
|
||||
}
|
||||
}
|
||||
|
@ -9,41 +9,49 @@ import "@client/styles/SectionChooser";
|
||||
|
||||
@observer
|
||||
export default class SectionChooser extends React.Component<{
|
||||
label?: string,
|
||||
inline?: boolean,
|
||||
sections: Section[],
|
||||
sectionId?: number,
|
||||
onChange?: (sectionId: number) => void,
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
sections: Section[];
|
||||
sectionId?: number;
|
||||
onChange?: (sectionId: number) => void;
|
||||
}> {
|
||||
render() {
|
||||
const { label, inline, sections, sectionId, onChange } = this.props;
|
||||
if (onChange == null) {
|
||||
const sectionStr = sectionId != null ? sections[sectionId].toString() : "";
|
||||
return <React.Fragment>{label || ""} '{sectionStr}'</React.Fragment>;
|
||||
}
|
||||
const section = (sectionId == null) ? "" : sectionId;
|
||||
return (
|
||||
<Form.Select
|
||||
className="sectionChooser"
|
||||
label={label}
|
||||
inline={inline}
|
||||
placeholder="Section"
|
||||
options={this.sectionOptions}
|
||||
value={section}
|
||||
onChange={this.onSectionChange}
|
||||
/>
|
||||
);
|
||||
render() {
|
||||
const { label, inline, sections, sectionId, onChange } = this.props;
|
||||
if (onChange == null) {
|
||||
const sectionStr =
|
||||
sectionId != null ? sections[sectionId].toString() : "";
|
||||
return (
|
||||
<React.Fragment>
|
||||
{label || ""} '{sectionStr}'
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const 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) => {
|
||||
this.props.onChange!(this.props.sections[v.value as number].id);
|
||||
}
|
||||
private onSectionChange = (
|
||||
e: React.SyntheticEvent<HTMLElement>,
|
||||
v: DropdownProps
|
||||
) => {
|
||||
this.props.onChange!(this.props.sections[v.value as number].id);
|
||||
};
|
||||
|
||||
@computed
|
||||
private get sectionOptions(): DropdownItemProps[] {
|
||||
return this.props.sections.map((s, i) => ({
|
||||
text: s ? `${s.id}: ${s.name}` : null,
|
||||
value: i,
|
||||
}));
|
||||
}
|
||||
@computed
|
||||
private get sectionOptions(): DropdownItemProps[] {
|
||||
return this.props.sections.map((s, i) => ({
|
||||
text: s ? `${s.id}: ${s.name}` : null,
|
||||
value: i
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -10,149 +10,168 @@ import { Section, SectionRun, SectionRunner } from "@common/sprinklersRpc";
|
||||
import "@client/styles/SectionRunnerView";
|
||||
|
||||
interface PausedStateProps {
|
||||
paused: boolean;
|
||||
togglePaused: () => void;
|
||||
paused: boolean;
|
||||
togglePaused: () => void;
|
||||
}
|
||||
|
||||
function PausedState({ paused, togglePaused }: PausedStateProps) {
|
||||
const classes = classNames({
|
||||
"sectionRunner--pausedState": true,
|
||||
"sectionRunner--pausedState-paused": paused,
|
||||
"sectionRunner--pausedState-unpaused": !paused,
|
||||
});
|
||||
return (
|
||||
<Button className={classes} size="medium" onClick={togglePaused}>
|
||||
<Icon name={paused ? "pause" : "play"}/>
|
||||
{paused ? "Paused" : "Processing"}
|
||||
</Button>
|
||||
);
|
||||
const classes = classNames({
|
||||
"sectionRunner--pausedState": true,
|
||||
"sectionRunner--pausedState-paused": paused,
|
||||
"sectionRunner--pausedState-unpaused": !paused
|
||||
});
|
||||
return (
|
||||
<Button className={classes} size="medium" onClick={togglePaused}>
|
||||
<Icon name={paused ? "pause" : "play"} />
|
||||
{paused ? "Paused" : "Processing"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
class SectionRunView extends React.Component<{
|
||||
class SectionRunView extends React.Component<
|
||||
{
|
||||
run: SectionRun;
|
||||
sections: Section[];
|
||||
}, {
|
||||
},
|
||||
{
|
||||
now: number;
|
||||
}> {
|
||||
animationFrameHandle: number | null = null;
|
||||
startTime: number;
|
||||
}
|
||||
> {
|
||||
animationFrameHandle: number | null = null;
|
||||
startTime: number;
|
||||
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
const now = performance.now();
|
||||
this.state = { now };
|
||||
this.startTime = Date.now() - now;
|
||||
}
|
||||
constructor(p: any) {
|
||||
super(p);
|
||||
const now = performance.now();
|
||||
this.state = { now };
|
||||
this.startTime = Date.now() - now;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
componentDidMount() {
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
componentDidUpdate() {
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cancelAnimationFrame();
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.cancelAnimationFrame();
|
||||
}
|
||||
|
||||
cancelAnimationFrame = () => {
|
||||
if (this.animationFrameHandle != null) {
|
||||
cancelAnimationFrame(this.animationFrameHandle);
|
||||
this.animationFrameHandle = null;
|
||||
}
|
||||
cancelAnimationFrame = () => {
|
||||
if (this.animationFrameHandle != null) {
|
||||
cancelAnimationFrame(this.animationFrameHandle);
|
||||
this.animationFrameHandle = null;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame = () => {
|
||||
const startTime = this.props.run.startTime;
|
||||
if (startTime != null) {
|
||||
if (this.animationFrameHandle == null) {
|
||||
this.animationFrameHandle = requestAnimationFrame(this.updateNow);
|
||||
}
|
||||
} else {
|
||||
this.cancelAnimationFrame();
|
||||
}
|
||||
requestAnimationFrame = () => {
|
||||
const startTime = this.props.run.startTime;
|
||||
if (startTime != null) {
|
||||
if (this.animationFrameHandle == null) {
|
||||
this.animationFrameHandle = requestAnimationFrame(this.updateNow);
|
||||
}
|
||||
} else {
|
||||
this.cancelAnimationFrame();
|
||||
}
|
||||
};
|
||||
|
||||
updateNow = (now: number) => {
|
||||
this.animationFrameHandle = null;
|
||||
this.setState({
|
||||
now: this.startTime + now,
|
||||
});
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
updateNow = (now: number) => {
|
||||
this.animationFrameHandle = null;
|
||||
this.setState({
|
||||
now: this.startTime + now
|
||||
});
|
||||
this.requestAnimationFrame();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { run, sections } = this.props;
|
||||
const startTime = run.unpauseTime ? run.unpauseTime : run.startTime;
|
||||
const now = this.state.now;
|
||||
const section = sections[run.section];
|
||||
const duration = Duration.fromSeconds(run.duration);
|
||||
const cancel = run.cancel;
|
||||
let running: boolean = false; // tslint:disable-line:no-unused-variable
|
||||
let paused: boolean = false;
|
||||
let progressBar: React.ReactNode | undefined;
|
||||
if (startTime != null) {
|
||||
let elapsed = (run.totalDuration - run.duration);
|
||||
if (run.pauseTime) {
|
||||
paused = true;
|
||||
} else {
|
||||
running = true;
|
||||
elapsed += (now - startTime.valueOf()) / 1000;
|
||||
}
|
||||
const percentage = elapsed / run.totalDuration;
|
||||
progressBar =
|
||||
<Progress color={paused ? "yellow" : "blue"} size="tiny" percent={percentage * 100}/>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
render() {
|
||||
const { run, sections } = this.props;
|
||||
const startTime = run.unpauseTime ? run.unpauseTime : run.startTime;
|
||||
const now = this.state.now;
|
||||
const section = sections[run.section];
|
||||
const duration = Duration.fromSeconds(run.duration);
|
||||
const cancel = run.cancel;
|
||||
let running: boolean = false; // tslint:disable-line:no-unused-variable
|
||||
let paused: boolean = false;
|
||||
let progressBar: React.ReactNode | undefined;
|
||||
if (startTime != null) {
|
||||
let elapsed = run.totalDuration - run.duration;
|
||||
if (run.pauseTime) {
|
||||
paused = true;
|
||||
} else {
|
||||
running = true;
|
||||
elapsed += (now - startTime.valueOf()) / 1000;
|
||||
}
|
||||
const percentage = elapsed / run.totalDuration;
|
||||
progressBar = (
|
||||
<Progress
|
||||
color={paused ? "yellow" : "blue"}
|
||||
size="tiny"
|
||||
percent={percentage * 100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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
|
||||
export default class SectionRunnerView extends React.Component<{
|
||||
sectionRunner: SectionRunner, sections: Section[],
|
||||
}, {}> {
|
||||
render() {
|
||||
const { current, queue, paused } = this.props.sectionRunner;
|
||||
const { sections } = this.props;
|
||||
const queueView = queue.map((run) =>
|
||||
<SectionRunView key={run.id} run={run} sections={sections}/>);
|
||||
if (current) {
|
||||
queueView.unshift(<SectionRunView key={-1} run={current} sections={sections}/>);
|
||||
}
|
||||
if (queueView.length === 0) {
|
||||
queueView.push(<Segment key={0}>No items in queue</Segment>);
|
||||
}
|
||||
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>
|
||||
);
|
||||
export default class SectionRunnerView extends React.Component<
|
||||
{
|
||||
sectionRunner: SectionRunner;
|
||||
sections: Section[];
|
||||
},
|
||||
{}
|
||||
> {
|
||||
render() {
|
||||
const { current, queue, paused } = this.props.sectionRunner;
|
||||
const { sections } = this.props;
|
||||
const queueView = queue.map(run => (
|
||||
<SectionRunView key={run.id} run={run} sections={sections} />
|
||||
));
|
||||
if (current) {
|
||||
queueView.unshift(
|
||||
<SectionRunView key={-1} run={current} sections={sections} />
|
||||
);
|
||||
}
|
||||
if (queueView.length === 0) {
|
||||
queueView.push(<Segment key={0}>No items in queue</Segment>);
|
||||
}
|
||||
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"));
|
||||
}
|
||||
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")
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -8,46 +8,52 @@ import { Section } from "@common/sprinklersRpc";
|
||||
/* tslint:disable:object-literal-sort-keys */
|
||||
|
||||
@observer
|
||||
export default class SectionTable extends React.Component<{ sections: Section[] }> {
|
||||
private static renderRow(section: Section, index: number) {
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
const { name, state } = section;
|
||||
const sectionStateClass = classNames({
|
||||
"section-state": true,
|
||||
"running": state,
|
||||
});
|
||||
const sectionState = state ?
|
||||
(<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>
|
||||
);
|
||||
export default class SectionTable extends React.Component<{
|
||||
sections: Section[];
|
||||
}> {
|
||||
private static renderRow(section: Section, index: number) {
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
const { name, state } = section;
|
||||
const sectionStateClass = classNames({
|
||||
"section-state": true,
|
||||
running: state
|
||||
});
|
||||
const sectionState = state ? (
|
||||
<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() {
|
||||
const rows = this.props.sections.map(SectionTable.renderRow);
|
||||
return (
|
||||
<Table celled striped unstackable compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="3">Sections</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell className="section--number">#</Table.HeaderCell>
|
||||
<Table.HeaderCell className="section--name">Name</Table.HeaderCell>
|
||||
<Table.HeaderCell className="section--state">State</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{rows}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const rows = this.props.sections.map(SectionTable.renderRow);
|
||||
return (
|
||||
<Table celled striped unstackable compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="3">Sections</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell className="section--number">#</Table.HeaderCell>
|
||||
<Table.HeaderCell className="section--name">Name</Table.HeaderCell>
|
||||
<Table.HeaderCell className="section--state">
|
||||
State
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -59,8 +59,7 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) {
|
||||
(env, key) => {
|
||||
env[key] = process.env[key];
|
||||
return env;
|
||||
},
|
||||
{
|
||||
}, {
|
||||
// Useful for determining whether we’re running in production mode.
|
||||
// Most importantly, it switches React into the correct mode.
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
@ -79,5 +78,8 @@ exports.getClientEnvironment = function getClientEnvironment(publicUrl) {
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return { raw, stringified };
|
||||
};
|
||||
return {
|
||||
raw,
|
||||
stringified
|
||||
};
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
@ -7,7 +8,9 @@
|
||||
|
||||
<title>Sprinklers3</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -8,30 +8,30 @@ import { AppState, ProvideState } from "@client/state";
|
||||
import logger from "@common/logger";
|
||||
|
||||
const state = new AppState();
|
||||
state.start()
|
||||
.catch((err: any) => {
|
||||
logger.error({ err }, "error starting state");
|
||||
});
|
||||
state.start().catch((err: any) => {
|
||||
logger.error({ err }, "error starting state");
|
||||
});
|
||||
|
||||
const rootElem = document.getElementById("app");
|
||||
|
||||
const doRender = (Component: React.ComponentType) => {
|
||||
ReactDOM.render((
|
||||
<AppContainer>
|
||||
<ProvideState state={state}>
|
||||
<Router history={state.history}>
|
||||
<Component/>
|
||||
</Router>
|
||||
</ProvideState>
|
||||
</AppContainer>
|
||||
), rootElem);
|
||||
ReactDOM.render(
|
||||
<AppContainer>
|
||||
<ProvideState state={state}>
|
||||
<Router history={state.history}>
|
||||
<Component />
|
||||
</Router>
|
||||
</ProvideState>
|
||||
</AppContainer>,
|
||||
rootElem
|
||||
);
|
||||
};
|
||||
|
||||
doRender(App);
|
||||
|
||||
if (module.hot) {
|
||||
module.hot.accept("@client/App", () => {
|
||||
const NextApp = require<any>("@client/App").default as typeof App;
|
||||
doRender(NextApp);
|
||||
});
|
||||
module.hot.accept("@client/App", () => {
|
||||
const NextApp = require<any>("@client/App").default as typeof App;
|
||||
doRender(NextApp);
|
||||
});
|
||||
}
|
||||
|
@ -5,16 +5,22 @@ import { Item } from "semantic-ui-react";
|
||||
import DeviceView from "@client/components/DeviceView";
|
||||
import { RouteComponentProps, withRouter } from "react-router";
|
||||
|
||||
class DevicePage extends React.Component<RouteComponentProps<{ deviceId: string }>> {
|
||||
render() {
|
||||
const { match: { params: { deviceId } } } = this.props;
|
||||
const devId = Number(deviceId);
|
||||
return (
|
||||
<Item.Group divided>
|
||||
<DeviceView deviceId={devId} inList={false} />
|
||||
</Item.Group>
|
||||
);
|
||||
}
|
||||
class DevicePage extends React.Component<
|
||||
RouteComponentProps<{ deviceId: string }>
|
||||
> {
|
||||
render() {
|
||||
const {
|
||||
match: {
|
||||
params: { deviceId }
|
||||
}
|
||||
} = this.props;
|
||||
const devId = Number(deviceId);
|
||||
return (
|
||||
<Item.Group divided>
|
||||
<DeviceView deviceId={devId} inList={false} />
|
||||
</Item.Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(observer(DevicePage));
|
||||
|
@ -6,28 +6,26 @@ import { DeviceView } from "@client/components";
|
||||
import { AppState, injectState } from "@client/state";
|
||||
|
||||
class DevicesPage extends React.Component<{ appState: AppState }> {
|
||||
render() {
|
||||
const { appState } = this.props;
|
||||
const { userData } = appState.userStore;
|
||||
let deviceNodes: React.ReactNode;
|
||||
if (!userData) {
|
||||
deviceNodes = <span>Not logged in</span>;
|
||||
} else if (!userData.devices || !userData.devices.length) {
|
||||
deviceNodes = <span>You have no devices</span>;
|
||||
} else {
|
||||
deviceNodes = userData.devices.map((device) => (
|
||||
<DeviceView key={device.id} deviceId={device.id} inList />
|
||||
));
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h1>Devices</h1>
|
||||
<Item.Group>
|
||||
{deviceNodes}
|
||||
</Item.Group>
|
||||
</React.Fragment>
|
||||
);
|
||||
render() {
|
||||
const { appState } = this.props;
|
||||
const { userData } = appState.userStore;
|
||||
let deviceNodes: React.ReactNode;
|
||||
if (!userData) {
|
||||
deviceNodes = <span>Not logged in</span>;
|
||||
} else if (!userData.devices || !userData.devices.length) {
|
||||
deviceNodes = <span>You have no devices</span>;
|
||||
} else {
|
||||
deviceNodes = userData.devices.map(device => (
|
||||
<DeviceView key={device.id} deviceId={device.id} inList />
|
||||
));
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h1>Devices</h1>
|
||||
<Item.Group>{deviceNodes}</Item.Group>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectState(observer(DevicesPage));
|
||||
|
@ -1,7 +1,16 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Container, Dimmer, Form, Header, InputOnChangeData, Loader, Message, Segment } from "semantic-ui-react";
|
||||
import {
|
||||
Container,
|
||||
Dimmer,
|
||||
Form,
|
||||
Header,
|
||||
InputOnChangeData,
|
||||
Loader,
|
||||
Message,
|
||||
Segment
|
||||
} from "semantic-ui-react";
|
||||
|
||||
import { AppState, injectState } from "@client/state";
|
||||
import log from "@common/logger";
|
||||
@ -9,76 +18,94 @@ import log from "@common/logger";
|
||||
import "@client/styles/LoginPage";
|
||||
|
||||
class LoginPageState {
|
||||
@observable username = "";
|
||||
@observable password = "";
|
||||
@observable
|
||||
username = "";
|
||||
@observable
|
||||
password = "";
|
||||
|
||||
@observable loading: boolean = false;
|
||||
@observable error: string | null = null;
|
||||
@observable
|
||||
loading: boolean = false;
|
||||
@observable
|
||||
error: string | null = null;
|
||||
|
||||
@computed get canLogin() {
|
||||
return this.username.length > 0 && this.password.length > 0;
|
||||
}
|
||||
@computed
|
||||
get canLogin() {
|
||||
return this.username.length > 0 && this.password.length > 0;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
onUsernameChange(e: any, data: InputOnChangeData) {
|
||||
this.username = data.value;
|
||||
}
|
||||
@action.bound
|
||||
onUsernameChange(e: any, data: InputOnChangeData) {
|
||||
this.username = data.value;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
onPasswordChange(e: any, data: InputOnChangeData) {
|
||||
this.password = data.value;
|
||||
}
|
||||
@action.bound
|
||||
onPasswordChange(e: any, data: InputOnChangeData) {
|
||||
this.password = data.value;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
login(appState: AppState) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
appState.httpApi.grantPassword(this.username, this.password)
|
||||
.then(action("loginSuccess", () => {
|
||||
this.loading = false;
|
||||
log.info("logged in");
|
||||
appState.history.push("/");
|
||||
}))
|
||||
.catch(action("loginError", (err: any) => {
|
||||
this.loading = false;
|
||||
this.error = err.message;
|
||||
log.error({ err }, "login error");
|
||||
}));
|
||||
}
|
||||
@action.bound
|
||||
login(appState: AppState) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
appState.httpApi
|
||||
.grantPassword(this.username, this.password)
|
||||
.then(
|
||||
action("loginSuccess", () => {
|
||||
this.loading = false;
|
||||
log.info("logged in");
|
||||
appState.history.push("/");
|
||||
})
|
||||
)
|
||||
.catch(
|
||||
action("loginError", (err: any) => {
|
||||
this.loading = false;
|
||||
this.error = err.message;
|
||||
log.error({ err }, "login error");
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginPage extends React.Component<{ appState: AppState }> {
|
||||
pageState = new LoginPageState();
|
||||
pageState = new LoginPageState();
|
||||
|
||||
render() {
|
||||
const { username, password, canLogin, loading, error } = this.pageState;
|
||||
return (
|
||||
<Container className="loginPage">
|
||||
<Segment>
|
||||
<Dimmer inverted active={loading}>
|
||||
<Loader/>
|
||||
</Dimmer>
|
||||
render() {
|
||||
const { username, password, canLogin, loading, error } = this.pageState;
|
||||
return (
|
||||
<Container className="loginPage">
|
||||
<Segment>
|
||||
<Dimmer inverted active={loading}>
|
||||
<Loader />
|
||||
</Dimmer>
|
||||
|
||||
<Header as="h1">Login</Header>
|
||||
<Form>
|
||||
<Form.Input label="Username" value={username} onChange={this.pageState.onUsernameChange}/>
|
||||
<Form.Input
|
||||
label="Password"
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={this.pageState.onPasswordChange}
|
||||
/>
|
||||
<Message error visible={error != null}>{error}</Message>
|
||||
<Form.Button disabled={!canLogin} onClick={this.login}>Login</Form.Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
<Header as="h1">Login</Header>
|
||||
<Form>
|
||||
<Form.Input
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={this.pageState.onUsernameChange}
|
||||
/>
|
||||
<Form.Input
|
||||
label="Password"
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={this.pageState.onPasswordChange}
|
||||
/>
|
||||
<Message error visible={error != null}>
|
||||
{error}
|
||||
</Message>
|
||||
<Form.Button disabled={!canLogin} onClick={this.login}>
|
||||
Login
|
||||
</Form.Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
login = () => {
|
||||
this.pageState.login(this.props.appState);
|
||||
}
|
||||
login = () => {
|
||||
this.pageState.login(this.props.appState);
|
||||
};
|
||||
}
|
||||
|
||||
export default injectState(observer(LoginPage));
|
||||
|
@ -4,14 +4,10 @@ import { Redirect } from "react-router";
|
||||
import { AppState, ConsumeState } from "@client/state";
|
||||
|
||||
export default function LogoutPage() {
|
||||
function consumeState(appState: AppState) {
|
||||
appState.tokenStore.clearAll();
|
||||
return (
|
||||
<Redirect to="/login" />
|
||||
);
|
||||
}
|
||||
function consumeState(appState: AppState) {
|
||||
appState.tokenStore.clearAll();
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConsumeState>{consumeState}</ConsumeState>
|
||||
);
|
||||
return <ConsumeState>{consumeState}</ConsumeState>;
|
||||
}
|
||||
|
@ -5,36 +5,42 @@ import { AppState, injectState } from "@client/state";
|
||||
import { getRandomId } from "@common/utils";
|
||||
|
||||
class MessageTest extends React.Component<{ appState: AppState }> {
|
||||
render() {
|
||||
return (
|
||||
<Segment>
|
||||
<h2>Message Test</h2>
|
||||
<Button onClick={this.test1}>Add test message</Button>
|
||||
<Button onClick={this.test2}>Add test message w/ timeout</Button>
|
||||
<Button onClick={this.test3}>Add test message w/ content</Button>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Segment>
|
||||
<h2>Message Test</h2>
|
||||
<Button onClick={this.test1}>Add test message</Button>
|
||||
<Button onClick={this.test2}>Add test message w/ timeout</Button>
|
||||
<Button onClick={this.test3}>Add test message w/ content</Button>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
private test1 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
info: true, content: "Test Message! " + getRandomId(), header: "Header to test message",
|
||||
});
|
||||
}
|
||||
private test1 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
info: true,
|
||||
content: "Test Message! " + getRandomId(),
|
||||
header: "Header to test message"
|
||||
});
|
||||
};
|
||||
|
||||
private test2 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
warning: true, content: "Im gonna dissapear in 5 seconds " + getRandomId(),
|
||||
header: "Header to test message", timeout: 5000,
|
||||
});
|
||||
}
|
||||
private test2 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
warning: true,
|
||||
content: "Im gonna dissapear in 5 seconds " + getRandomId(),
|
||||
header: "Header to test message",
|
||||
timeout: 5000
|
||||
});
|
||||
};
|
||||
|
||||
private test3 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
color: "brown", content: <div className="ui segment">I Have crazy content!</div>,
|
||||
header: "Header to test message", timeout: 5000,
|
||||
});
|
||||
}
|
||||
private test3 = () => {
|
||||
this.props.appState.uiStore.addMessage({
|
||||
color: "brown",
|
||||
content: <div className="ui segment">I Have crazy content!</div>,
|
||||
header: "Header to test message",
|
||||
timeout: 5000
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default injectState(MessageTest);
|
||||
|
@ -3,7 +3,16 @@ import { observer } from "mobx-react";
|
||||
import * as qs from "query-string";
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { Button, CheckboxProps, Form, Icon, Input, InputOnChangeData, Menu, Modal } from "semantic-ui-react";
|
||||
import {
|
||||
Button,
|
||||
CheckboxProps,
|
||||
Form,
|
||||
Icon,
|
||||
Input,
|
||||
InputOnChangeData,
|
||||
Menu,
|
||||
Modal
|
||||
} from "semantic-ui-react";
|
||||
|
||||
import { ProgramSequenceView, ScheduleView } from "@client/components";
|
||||
import * as route from "@client/routePaths";
|
||||
@ -13,233 +22,253 @@ import log from "@common/logger";
|
||||
import { Program, SprinklersDevice } from "@common/sprinklersRpc";
|
||||
import { action } from "mobx";
|
||||
|
||||
interface ProgramPageProps extends RouteComponentProps<{ deviceId: string, programId: string }> {
|
||||
appState: AppState;
|
||||
interface ProgramPageProps
|
||||
extends RouteComponentProps<{ deviceId: string; programId: string }> {
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ProgramPage extends React.Component<ProgramPageProps> {
|
||||
get isEditing(): boolean {
|
||||
return qs.parse(this.props.location.search).editing != null;
|
||||
get isEditing(): boolean {
|
||||
return qs.parse(this.props.location.search).editing != null;
|
||||
}
|
||||
|
||||
deviceInfo: ISprinklersDevice | null = null;
|
||||
device: SprinklersDevice | null = null;
|
||||
program: Program | null = null;
|
||||
programView: Program | null = null;
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
}
|
||||
}
|
||||
|
||||
deviceInfo: ISprinklersDevice | null = null;
|
||||
device: SprinklersDevice | null = null;
|
||||
program: Program | null = null;
|
||||
programView: Program | null = null;
|
||||
|
||||
componentWillUnmount() {
|
||||
updateProgram() {
|
||||
const { userStore, sprinklersRpc } = this.props.appState;
|
||||
const devId = Number(this.props.match.params.deviceId);
|
||||
const programId = Number(this.props.match.params.programId);
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (this.deviceInfo == null || this.deviceInfo.id !== devId) {
|
||||
this.deviceInfo = userStore.findDevice(devId);
|
||||
}
|
||||
if (!this.deviceInfo || !this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device = null;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device.release();
|
||||
}
|
||||
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
updateProgram() {
|
||||
const { userStore, sprinklersRpc } = this.props.appState;
|
||||
const devId = Number(this.props.match.params.deviceId);
|
||||
const programId = Number(this.props.match.params.programId);
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (this.deviceInfo == null || this.deviceInfo.id !== devId) {
|
||||
this.deviceInfo = userStore.findDevice(devId);
|
||||
}
|
||||
if (!this.deviceInfo || !this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
this.device = null;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (this.device == null || this.device.id !== this.deviceInfo.deviceId) {
|
||||
if (this.device) {
|
||||
this.device.release();
|
||||
}
|
||||
this.device = sprinklersRpc.acquireDevice(this.deviceInfo.deviceId);
|
||||
}
|
||||
}
|
||||
if (!this.program || this.program.id !== programId) {
|
||||
if (this.device.programs.length > programId && programId >= 0) {
|
||||
this.program = this.device.programs[programId];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.isEditing) {
|
||||
if (this.programView == null && this.program) {
|
||||
// this.programView = createViewModel(this.program);
|
||||
// this.programView = observable(toJS(this.program));
|
||||
this.programView = this.program.clone();
|
||||
}
|
||||
} else {
|
||||
if (this.programView != null) {
|
||||
// this.programView.reset();
|
||||
this.programView = null;
|
||||
}
|
||||
}
|
||||
if (!this.program || this.program.id !== programId) {
|
||||
if (this.device.programs.length > programId && programId >= 0) {
|
||||
this.program = this.device.programs[programId];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderName(program: Program) {
|
||||
const { name } = program;
|
||||
if (this.isEditing) {
|
||||
return (
|
||||
<Menu.Item header>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field inline>
|
||||
<label><h4>Program</h4></label>
|
||||
<Input
|
||||
placeholder="Program Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={this.onNameChange}
|
||||
/>
|
||||
</Form.Field>
|
||||
({program.id})
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
);
|
||||
} else {
|
||||
return <Menu.Item header as="h4">Program {name} ({program.id})</Menu.Item>;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderActions(program: Program) {
|
||||
const { running } = program;
|
||||
const editing = this.isEditing;
|
||||
let editButtons;
|
||||
if (editing) {
|
||||
editButtons = (
|
||||
<React.Fragment>
|
||||
<Button primary onClick={this.save}>
|
||||
<Icon name="save" />
|
||||
Save
|
||||
</Button>
|
||||
<Button negative onClick={this.stopEditing}>
|
||||
<Icon name="cancel" />
|
||||
Cancel
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
editButtons = (
|
||||
<Button primary onClick={this.startEditing}>
|
||||
<Icon name="edit" />
|
||||
Edit
|
||||
</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>
|
||||
);
|
||||
renderName(program: Program) {
|
||||
const { name } = program;
|
||||
if (this.isEditing) {
|
||||
return (
|
||||
<Menu.Item header>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field inline>
|
||||
<label>
|
||||
<h4>Program</h4>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Program Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={this.onNameChange}
|
||||
/>
|
||||
</Form.Field>
|
||||
({program.id})
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Menu.Item header as="h4">
|
||||
Program {name} ({program.id})
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.updateProgram();
|
||||
|
||||
const program = this.programView || this.program;
|
||||
if (!this.device || !program) {
|
||||
return null;
|
||||
}
|
||||
const editing = this.isEditing;
|
||||
|
||||
const { running, enabled, schedule, sequence } = program;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
renderActions(program: Program) {
|
||||
const { running } = program;
|
||||
const editing = this.isEditing;
|
||||
let editButtons;
|
||||
if (editing) {
|
||||
editButtons = (
|
||||
<React.Fragment>
|
||||
<Button primary onClick={this.save}>
|
||||
<Icon name="save" />
|
||||
Save
|
||||
</Button>
|
||||
<Button negative onClick={this.stopEditing}>
|
||||
<Icon name="cancel" />
|
||||
Cancel
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
editButtons = (
|
||||
<Button primary onClick={this.startEditing}>
|
||||
<Icon name="edit" />
|
||||
Edit
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private cancelOrRun() {
|
||||
if (!this.program) {
|
||||
return;
|
||||
}
|
||||
this.program.running ? this.program.cancel() : this.program.run();
|
||||
}
|
||||
render() {
|
||||
this.updateProgram();
|
||||
|
||||
@action.bound
|
||||
private startEditing() {
|
||||
this.props.history.push({ search: qs.stringify({ editing: true }) });
|
||||
const program = this.programView || this.program;
|
||||
if (!this.device || !program) {
|
||||
return null;
|
||||
}
|
||||
const editing = this.isEditing;
|
||||
|
||||
@action.bound
|
||||
private save() {
|
||||
if (!this.programView || !this.program) {
|
||||
return;
|
||||
}
|
||||
assign(this.program, this.programView);
|
||||
this.program.update()
|
||||
.then((data) => {
|
||||
log.info({ data }, "Program updated");
|
||||
}, (err) => {
|
||||
log.error({ err }, "error updating Program");
|
||||
});
|
||||
this.stopEditing();
|
||||
}
|
||||
const { running, enabled, schedule, sequence } = program;
|
||||
|
||||
@action.bound
|
||||
private stopEditing() {
|
||||
this.props.history.push({ search: "" });
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private close() {
|
||||
const { deviceId } = this.props.match.params;
|
||||
this.props.history.push({ pathname: route.device(deviceId), search: "" });
|
||||
@action.bound
|
||||
private cancelOrRun() {
|
||||
if (!this.program) {
|
||||
return;
|
||||
}
|
||||
this.program.running ? this.program.cancel() : this.program.run();
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onNameChange(e: any, p: InputOnChangeData) {
|
||||
if (this.programView) {
|
||||
this.programView.name = p.value;
|
||||
}
|
||||
}
|
||||
@action.bound
|
||||
private startEditing() {
|
||||
this.props.history.push({ search: qs.stringify({ editing: true }) });
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onEnabledChange(e: any, p: CheckboxProps) {
|
||||
if (this.programView) {
|
||||
this.programView.enabled = p.checked!;
|
||||
}
|
||||
@action.bound
|
||||
private save() {
|
||||
if (!this.programView || !this.program) {
|
||||
return;
|
||||
}
|
||||
assign(this.program, this.programView);
|
||||
this.program.update().then(
|
||||
data => {
|
||||
log.info({ data }, "Program updated");
|
||||
},
|
||||
err => {
|
||||
log.error({ err }, "error updating Program");
|
||||
}
|
||||
);
|
||||
this.stopEditing();
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private stopEditing() {
|
||||
this.props.history.push({ search: "" });
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private close() {
|
||||
const { deviceId } = this.props.match.params;
|
||||
this.props.history.push({ pathname: route.device(deviceId), search: "" });
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onNameChange(e: any, p: InputOnChangeData) {
|
||||
if (this.programView) {
|
||||
this.programView.name = p.value;
|
||||
}
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onEnabledChange(e: any, p: CheckboxProps) {
|
||||
if (this.programView) {
|
||||
this.programView.enabled = p.checked!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DecoratedProgramPage = injectState(observer(ProgramPage));
|
||||
|
@ -13,4 +13,4 @@ if (typeof Promise === 'undefined') {
|
||||
|
||||
// Object.assign() is commonly used with React.
|
||||
// It will use the native implementation if it's present and isn't buggy.
|
||||
Object.assign = require('object-assign');
|
||||
Object.assign = require('object-assign');
|
@ -1,11 +1,11 @@
|
||||
export interface RouteParams {
|
||||
deviceId: string;
|
||||
programId: string;
|
||||
deviceId: string;
|
||||
programId: string;
|
||||
}
|
||||
|
||||
export const routerRouteParams: RouteParams = {
|
||||
deviceId: ":deviceId",
|
||||
programId: ":programId",
|
||||
deviceId: ":deviceId",
|
||||
programId: ":programId"
|
||||
};
|
||||
|
||||
export const home = "/";
|
||||
@ -15,9 +15,12 @@ export const login = "/login";
|
||||
export const logout = "/logout";
|
||||
|
||||
export function device(deviceId?: string | number): string {
|
||||
return `/devices/${deviceId || ""}`;
|
||||
return `/devices/${deviceId || ""}`;
|
||||
}
|
||||
|
||||
export function program(deviceId: string | number, programId?: string | number): string {
|
||||
return `${device(deviceId)}/programs/${programId}`;
|
||||
export function program(
|
||||
deviceId: string | number,
|
||||
programId?: string | number
|
||||
): string {
|
||||
return `${device(deviceId)}/programs/${programId}`;
|
||||
}
|
||||
|
@ -8,66 +8,71 @@ import { log, WebSocketRpcClient } from "./WebSocketRpcClient";
|
||||
|
||||
// tslint:disable:member-ordering
|
||||
export class WSSprinklersDevice extends s.SprinklersDevice {
|
||||
readonly api: WebSocketRpcClient;
|
||||
readonly api: WebSocketRpcClient;
|
||||
|
||||
constructor(api: WebSocketRpcClient, id: string) {
|
||||
super(api, id);
|
||||
this.api = api;
|
||||
constructor(api: WebSocketRpcClient, id: string) {
|
||||
super(api, id);
|
||||
this.api = api;
|
||||
|
||||
autorun(this.updateConnectionState);
|
||||
this.waitSubscribe();
|
||||
}
|
||||
autorun(this.updateConnectionState);
|
||||
this.waitSubscribe();
|
||||
}
|
||||
|
||||
private updateConnectionState = () => {
|
||||
const { clientToServer, serverToBroker } = this.api.connectionState;
|
||||
runInAction("updateConnectionState", () => {
|
||||
Object.assign(this.connectionState, { clientToServer, serverToBroker });
|
||||
});
|
||||
}
|
||||
private updateConnectionState = () => {
|
||||
const { clientToServer, serverToBroker } = this.api.connectionState;
|
||||
runInAction("updateConnectionState", () => {
|
||||
Object.assign(this.connectionState, { clientToServer, serverToBroker });
|
||||
});
|
||||
};
|
||||
|
||||
async subscribe() {
|
||||
const subscribeRequest: ws.IDeviceSubscribeRequest = {
|
||||
deviceId: this.id,
|
||||
};
|
||||
try {
|
||||
await this.api.makeRequest("deviceSubscribe", subscribeRequest);
|
||||
runInAction("deviceSubscribeSuccess", () => {
|
||||
this.connectionState.hasPermission = true;
|
||||
});
|
||||
} catch (err) {
|
||||
runInAction("deviceSubscribeError", () => {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
if ((err as ws.IError).code === ErrorCode.NoPermission) {
|
||||
this.connectionState.hasPermission = false;
|
||||
} else {
|
||||
log.error({ err });
|
||||
}
|
||||
});
|
||||
async subscribe() {
|
||||
const subscribeRequest: ws.IDeviceSubscribeRequest = {
|
||||
deviceId: this.id
|
||||
};
|
||||
try {
|
||||
await this.api.makeRequest("deviceSubscribe", subscribeRequest);
|
||||
runInAction("deviceSubscribeSuccess", () => {
|
||||
this.connectionState.hasPermission = true;
|
||||
});
|
||||
} catch (err) {
|
||||
runInAction("deviceSubscribeError", () => {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
if ((err as ws.IError).code === ErrorCode.NoPermission) {
|
||||
this.connectionState.hasPermission = false;
|
||||
} else {
|
||||
log.error({ err });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribe() {
|
||||
const unsubscribeRequest: ws.IDeviceSubscribeRequest = {
|
||||
deviceId: this.id,
|
||||
};
|
||||
try {
|
||||
await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest);
|
||||
runInAction("deviceUnsubscribeSuccess", () => {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "error unsubscribing from device");
|
||||
}
|
||||
async unsubscribe() {
|
||||
const unsubscribeRequest: ws.IDeviceSubscribeRequest = {
|
||||
deviceId: this.id
|
||||
};
|
||||
try {
|
||||
await this.api.makeRequest("deviceUnsubscribe", unsubscribeRequest);
|
||||
runInAction("deviceUnsubscribeSuccess", () => {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "error unsubscribing from device");
|
||||
}
|
||||
}
|
||||
|
||||
makeRequest(request: deviceRequests.Request): Promise<deviceRequests.Response> {
|
||||
return this.api.makeDeviceCall(this.id, request);
|
||||
}
|
||||
makeRequest(
|
||||
request: deviceRequests.Request
|
||||
): Promise<deviceRequests.Response> {
|
||||
return this.api.makeDeviceCall(this.id, request);
|
||||
}
|
||||
|
||||
waitSubscribe = () => {
|
||||
when(() => this.api.authenticated, () => {
|
||||
this.subscribe();
|
||||
when(() => !this.api.authenticated, this.waitSubscribe);
|
||||
});
|
||||
}
|
||||
waitSubscribe = () => {
|
||||
when(
|
||||
() => this.api.authenticated,
|
||||
() => {
|
||||
this.subscribe();
|
||||
when(() => !this.api.authenticated, this.waitSubscribe);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -11,7 +11,11 @@ import * as deviceRequests from "@common/sprinklersRpc/deviceRequests";
|
||||
import * as schema from "@common/sprinklersRpc/schema/";
|
||||
import { seralizeRequest } from "@common/sprinklersRpc/schema/requests";
|
||||
import * as ws from "@common/sprinklersRpc/websocketData";
|
||||
import { DefaultEvents, TypedEventEmitter, typedEventEmitter } from "@common/TypedEventEmitter";
|
||||
import {
|
||||
DefaultEvents,
|
||||
TypedEventEmitter,
|
||||
typedEventEmitter
|
||||
} from "@common/TypedEventEmitter";
|
||||
import { WSSprinklersDevice } from "./WSSprinklersDevice";
|
||||
|
||||
export const log = logger.child({ source: "websocket" });
|
||||
@ -20,288 +24,310 @@ const TIMEOUT_MS = 5000;
|
||||
const RECONNECT_TIMEOUT_MS = 5000;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const websocketProtocol = (location.protocol === "https:") ? "wss:" : "ws:";
|
||||
const websocketProtocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const websocketPort = isDev ? 8080 : location.port;
|
||||
|
||||
const DEFAULT_URL = `${websocketProtocol}//${location.hostname}:${websocketPort}`;
|
||||
const DEFAULT_URL = `${websocketProtocol}//${
|
||||
location.hostname
|
||||
}:${websocketPort}`;
|
||||
|
||||
export interface WebSocketRpcClientEvents extends DefaultEvents {
|
||||
newUserData(userData: IUser): void;
|
||||
rpcError(error: s.RpcError): void;
|
||||
tokenError(error: s.RpcError): void;
|
||||
newUserData(userData: IUser): void;
|
||||
rpcError(error: s.RpcError): void;
|
||||
tokenError(error: s.RpcError): void;
|
||||
}
|
||||
|
||||
// tslint:disable:member-ordering
|
||||
|
||||
export interface WebSocketRpcClient extends TypedEventEmitter<WebSocketRpcClientEvents> {
|
||||
}
|
||||
export interface WebSocketRpcClient
|
||||
extends TypedEventEmitter<WebSocketRpcClientEvents> {}
|
||||
|
||||
@typedEventEmitter
|
||||
export class WebSocketRpcClient extends s.SprinklersRPC {
|
||||
@computed
|
||||
get connected(): boolean {
|
||||
return this.connectionState.isServerConnected || false;
|
||||
}
|
||||
@computed
|
||||
get connected(): boolean {
|
||||
return this.connectionState.isServerConnected || false;
|
||||
}
|
||||
|
||||
readonly webSocketUrl: string;
|
||||
readonly webSocketUrl: string;
|
||||
|
||||
devices: Map<string, WSSprinklersDevice> = new Map();
|
||||
@observable connectionState: s.ConnectionState = new s.ConnectionState();
|
||||
socket: WebSocket | null = null;
|
||||
devices: Map<string, WSSprinklersDevice> = new Map();
|
||||
@observable
|
||||
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 reconnectTimer: number | null = null;
|
||||
private nextRequestId = Math.round(Math.random() * 1000000);
|
||||
private responseCallbacks: ws.ServerResponseHandlers = {};
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
@action
|
||||
private onDisconnect = action(() => {
|
||||
this.connectionState.serverToBroker = null;
|
||||
this.connectionState.clientToServer = false;
|
||||
this.authenticated = false;
|
||||
@action
|
||||
private onDisconnect = action(() => {
|
||||
this.connectionState.serverToBroker = null;
|
||||
this.connectionState.clientToServer = false;
|
||||
this.authenticated = false;
|
||||
});
|
||||
|
||||
private notificationHandlers = new WSClientNotificationHandlers(this);
|
||||
|
||||
constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) {
|
||||
super();
|
||||
this.webSocketUrl = webSocketUrl;
|
||||
this.tokenStore = tokenStore;
|
||||
this.connectionState.clientToServer = false;
|
||||
this.connectionState.serverToBroker = false;
|
||||
|
||||
this.on("rpcError", (err: s.RpcError) => {
|
||||
if (err.code === ErrorCode.BadToken) {
|
||||
this.emit("tokenError", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private notificationHandlers = new WSClientNotificationHandlers(this);
|
||||
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);
|
||||
}
|
||||
});
|
||||
stop() {
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._connect();
|
||||
if (this.socket != null) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
makeRequest<Method extends ws.ClientRequestMethods>(
|
||||
method: Method,
|
||||
params: ws.IClientRequestTypes[Method]
|
||||
): Promise<ws.IServerResponseTypes[Method]> {
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
|
||||
let timeoutHandle: number;
|
||||
this.responseCallbacks[id] = response => {
|
||||
clearTimeout(timeoutHandle);
|
||||
delete this.responseCallbacks[id];
|
||||
if (response.result === "success") {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
return resData.data;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
makeRequest<Method extends ws.ClientRequestMethods>(method: Method, params: ws.IClientRequestTypes[Method]):
|
||||
Promise<ws.IServerResponseTypes[Method]> {
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
|
||||
let timeoutHandle: number;
|
||||
this.responseCallbacks[id] = (response) => {
|
||||
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;
|
||||
});
|
||||
private sendMessage(data: ws.ClientMessage) {
|
||||
if (!this.socket) {
|
||||
throw new Error("WebSocketApiClient is not connected");
|
||||
}
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
private sendMessage(data: ws.ClientMessage) {
|
||||
if (!this.socket) {
|
||||
throw new Error("WebSocketApiClient is not connected");
|
||||
}
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
private sendRequest<Method extends ws.ClientRequestMethods>(
|
||||
id: number,
|
||||
method: Method,
|
||||
params: ws.IClientRequestTypes[Method]
|
||||
) {
|
||||
this.sendMessage({ type: "request", id, method, params });
|
||||
}
|
||||
|
||||
private sendRequest<Method extends ws.ClientRequestMethods>(
|
||||
id: number, method: Method, params: ws.IClientRequestTypes[Method],
|
||||
) {
|
||||
this.sendMessage({ type: "request", id, method, params });
|
||||
}
|
||||
private _reconnect = () => {
|
||||
this._connect();
|
||||
};
|
||||
|
||||
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");
|
||||
this.socket = new WebSocket(this.webSocketUrl);
|
||||
this.socket.onopen = this.onOpen.bind(this);
|
||||
this.socket.onclose = this.onClose.bind(this);
|
||||
this.socket.onerror = this.onError.bind(this);
|
||||
this.socket.onmessage = this.onMessage.bind(this);
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.socket != null &&
|
||||
(this.socket.readyState === WebSocket.OPEN)) {
|
||||
this.tryAuthenticate();
|
||||
return;
|
||||
}
|
||||
log.debug({ url: this.webSocketUrl }, "connecting to websocket");
|
||||
this.socket = new WebSocket(this.webSocketUrl);
|
||||
this.socket.onopen = this.onOpen.bind(this);
|
||||
this.socket.onclose = this.onClose.bind(this);
|
||||
this.socket.onerror = this.onError.bind(this);
|
||||
this.socket.onmessage = this.onMessage.bind(this);
|
||||
}
|
||||
@action
|
||||
private onOpen() {
|
||||
log.info("established websocket connection");
|
||||
this.connectionState.clientToServer = true;
|
||||
this.authenticated = false;
|
||||
this.tryAuthenticate();
|
||||
}
|
||||
|
||||
@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
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@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");
|
||||
switch (data.type) {
|
||||
case "notification":
|
||||
this.onNotification(data);
|
||||
break;
|
||||
case "response":
|
||||
this.onResponse(data);
|
||||
break;
|
||||
default:
|
||||
log.warn({ data }, "unsupported event type received");
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(event: MessageEvent) {
|
||||
let data: ws.ServerMessage;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
return log.error({ event, err }, "received invalid websocket message");
|
||||
}
|
||||
log.trace({ data }, "websocket message");
|
||||
switch (data.type) {
|
||||
case "notification":
|
||||
this.onNotification(data);
|
||||
break;
|
||||
case "response":
|
||||
this.onResponse(data);
|
||||
break;
|
||||
default:
|
||||
log.warn({ data }, "unsupported event type received");
|
||||
}
|
||||
private onNotification(data: ws.ServerNotification) {
|
||||
try {
|
||||
rpc.handleNotification(this.notificationHandlers, data);
|
||||
} catch (err) {
|
||||
logger.error(err, "error handling server notification");
|
||||
}
|
||||
}
|
||||
|
||||
private onNotification(data: ws.ServerNotification) {
|
||||
try {
|
||||
rpc.handleNotification(this.notificationHandlers, data);
|
||||
} catch (err) {
|
||||
logger.error(err, "error handling server notification");
|
||||
}
|
||||
}
|
||||
|
||||
private onResponse(data: ws.ServerResponse) {
|
||||
try {
|
||||
rpc.handleResponse(this.responseCallbacks, data);
|
||||
} catch (err) {
|
||||
log.error({ err }, "error handling server response");
|
||||
}
|
||||
private onResponse(data: ws.ServerResponse) {
|
||||
try {
|
||||
rpc.handleResponse(this.responseCallbacks, data);
|
||||
} catch (err) {
|
||||
log.error({ err }, "error handling server response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WSClientNotificationHandlers implements ws.ServerNotificationHandlers {
|
||||
client: WebSocketRpcClient;
|
||||
class WSClientNotificationHandlers implements ws.ServerNotificationHandlers {
|
||||
client: WebSocketRpcClient;
|
||||
|
||||
constructor(client: WebSocketRpcClient) {
|
||||
this.client = client;
|
||||
}
|
||||
constructor(client: WebSocketRpcClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) {
|
||||
this.client.connectionState.serverToBroker = data.brokerConnected;
|
||||
}
|
||||
@action.bound
|
||||
brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) {
|
||||
this.client.connectionState.serverToBroker = data.brokerConnected;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
deviceUpdate(data: ws.IDeviceUpdate) {
|
||||
const device = this.client.devices.get(data.deviceId);
|
||||
if (!device) {
|
||||
return log.warn({ data }, "invalid deviceUpdate received");
|
||||
}
|
||||
update(schema.sprinklersDevice, device, data.data);
|
||||
@action.bound
|
||||
deviceUpdate(data: ws.IDeviceUpdate) {
|
||||
const device = this.client.devices.get(data.deviceId);
|
||||
if (!device) {
|
||||
return log.warn({ data }, "invalid deviceUpdate received");
|
||||
}
|
||||
update(schema.sprinklersDevice, device, data.data);
|
||||
}
|
||||
|
||||
error(data: ws.IError) {
|
||||
log.warn({ err: data }, "server error");
|
||||
}
|
||||
error(data: ws.IError) {
|
||||
log.warn({ err: data }, "server error");
|
||||
}
|
||||
}
|
||||
|
@ -12,81 +12,84 @@ import log from "@common/logger";
|
||||
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
|
||||
|
||||
interface AppEvents extends DefaultEvents {
|
||||
checkToken(): void;
|
||||
hasToken(): void;
|
||||
checkToken(): void;
|
||||
hasToken(): void;
|
||||
}
|
||||
|
||||
export default class AppState extends TypedEventEmitter<AppEvents> {
|
||||
history: History = createBrowserHistory();
|
||||
routerStore = new RouterStore();
|
||||
uiStore = new UiStore();
|
||||
userStore = new UserStore();
|
||||
httpApi = new HttpApi();
|
||||
tokenStore = this.httpApi.tokenStore;
|
||||
sprinklersRpc = new WebSocketRpcClient(this.tokenStore);
|
||||
history: History = createBrowserHistory();
|
||||
routerStore = new RouterStore();
|
||||
uiStore = new UiStore();
|
||||
userStore = new UserStore();
|
||||
httpApi = new HttpApi();
|
||||
tokenStore = this.httpApi.tokenStore;
|
||||
sprinklersRpc = new WebSocketRpcClient(this.tokenStore);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData);
|
||||
this.sprinklersRpc.on("tokenError", this.clearToken);
|
||||
this.httpApi.on("tokenGranted", () => this.emit("hasToken"));
|
||||
this.httpApi.on("tokenError", this.clearToken);
|
||||
constructor() {
|
||||
super();
|
||||
this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData);
|
||||
this.sprinklersRpc.on("tokenError", this.clearToken);
|
||||
this.httpApi.on("tokenGranted", () => this.emit("hasToken"));
|
||||
this.httpApi.on("tokenError", this.clearToken);
|
||||
|
||||
this.on("checkToken", this.doCheckToken);
|
||||
this.on("checkToken", this.doCheckToken);
|
||||
|
||||
this.on("hasToken", () => {
|
||||
when(() => !this.tokenStore.accessToken.isValid, this.checkToken);
|
||||
this.sprinklersRpc.start();
|
||||
});
|
||||
this.on("hasToken", () => {
|
||||
when(() => !this.tokenStore.accessToken.isValid, this.checkToken);
|
||||
this.sprinklersRpc.start();
|
||||
});
|
||||
}
|
||||
|
||||
@computed
|
||||
get isLoggedIn() {
|
||||
return this.tokenStore.accessToken.isValid;
|
||||
}
|
||||
|
||||
async start() {
|
||||
configure({
|
||||
enforceActions: true
|
||||
});
|
||||
|
||||
syncHistoryWithStore(this.history, this.routerStore);
|
||||
await this.tokenStore.loadLocalStorage();
|
||||
|
||||
await this.checkToken();
|
||||
}
|
||||
|
||||
clearToken = (err?: any) => {
|
||||
this.tokenStore.clearAccessToken();
|
||||
this.checkToken();
|
||||
};
|
||||
|
||||
checkToken = () => {
|
||||
this.emit("checkToken");
|
||||
};
|
||||
|
||||
private doCheckToken = async () => {
|
||||
const { accessToken, refreshToken } = this.tokenStore;
|
||||
accessToken.updateCurrentTime();
|
||||
if (accessToken.isValid) {
|
||||
// if the access token is valid, we are good
|
||||
this.emit("hasToken");
|
||||
return;
|
||||
}
|
||||
|
||||
@computed get isLoggedIn() {
|
||||
return this.tokenStore.accessToken.isValid;
|
||||
if (!refreshToken.isValid) {
|
||||
// if the refresh token is not valid, need to login again
|
||||
this.history.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
async start() {
|
||||
configure({
|
||||
enforceActions: true,
|
||||
});
|
||||
|
||||
syncHistoryWithStore(this.history, this.routerStore);
|
||||
await this.tokenStore.loadLocalStorage();
|
||||
|
||||
await this.checkToken();
|
||||
}
|
||||
|
||||
clearToken = (err?: any) => {
|
||||
this.tokenStore.clearAccessToken();
|
||||
this.checkToken();
|
||||
}
|
||||
|
||||
checkToken = () => {
|
||||
this.emit("checkToken");
|
||||
}
|
||||
|
||||
private doCheckToken = async () => {
|
||||
const { accessToken, refreshToken } = this.tokenStore;
|
||||
accessToken.updateCurrentTime();
|
||||
if (accessToken.isValid) { // if the access token is valid, we are good
|
||||
this.emit("hasToken");
|
||||
return;
|
||||
}
|
||||
if (!refreshToken.isValid) { // if the refresh token is not valid, need to login again
|
||||
this.history.push("/login");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.httpApi.grantRefresh();
|
||||
this.emit("hasToken");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === ErrorCode.BadToken) {
|
||||
log.warn({ err }, "refresh is bad for some reason, erasing");
|
||||
this.tokenStore.clearAll();
|
||||
this.history.push("/login");
|
||||
} else {
|
||||
log.error({ err }, "could not refresh access token");
|
||||
// TODO: some kind of error page?
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.httpApi.grantRefresh();
|
||||
this.emit("hasToken");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === ErrorCode.BadToken) {
|
||||
log.warn({ err }, "refresh is bad for some reason, erasing");
|
||||
this.tokenStore.clearAll();
|
||||
this.history.push("/login");
|
||||
} else {
|
||||
log.error({ err }, "could not refresh access token");
|
||||
// TODO: some kind of error page?
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -3,114 +3,145 @@ import { action } from "mobx";
|
||||
import { TokenStore } from "@client/state/TokenStore";
|
||||
import ApiError from "@common/ApiError";
|
||||
import { ErrorCode } from "@common/ErrorCode";
|
||||
import { TokenGrantPasswordRequest, TokenGrantRefreshRequest, TokenGrantResponse } from "@common/httpApi";
|
||||
import {
|
||||
TokenGrantPasswordRequest,
|
||||
TokenGrantRefreshRequest,
|
||||
TokenGrantResponse
|
||||
} from "@common/httpApi";
|
||||
import log from "@common/logger";
|
||||
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
|
||||
|
||||
export { ApiError };
|
||||
|
||||
interface HttpApiEvents extends DefaultEvents {
|
||||
tokenGranted(response: TokenGrantResponse): void;
|
||||
error(err: ApiError): void;
|
||||
tokenError(err: ApiError): void;
|
||||
tokenGranted(response: TokenGrantResponse): void;
|
||||
error(err: ApiError): void;
|
||||
tokenError(err: ApiError): void;
|
||||
}
|
||||
|
||||
export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
|
||||
baseUrl: string;
|
||||
baseUrl: string;
|
||||
|
||||
tokenStore: TokenStore;
|
||||
tokenStore: TokenStore;
|
||||
|
||||
private get authorizationHeader(): {} | { "Authorization": string } {
|
||||
if (!this.tokenStore.accessToken) {
|
||||
return {};
|
||||
}
|
||||
return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` };
|
||||
private get authorizationHeader(): {} | { Authorization: string } {
|
||||
if (!this.tokenStore.accessToken) {
|
||||
return {};
|
||||
}
|
||||
return { Authorization: `Bearer ${this.tokenStore.accessToken.token}` };
|
||||
}
|
||||
|
||||
constructor(baseUrl: string = `${location.protocol}//${location.hostname}:${location.port}/api`) {
|
||||
super();
|
||||
while (baseUrl.charAt(baseUrl.length - 1) === "/") {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
this.baseUrl = baseUrl;
|
||||
|
||||
this.tokenStore = new TokenStore();
|
||||
|
||||
this.on("error", (err: ApiError) => {
|
||||
if (err.code === ErrorCode.BadToken) {
|
||||
this.emit("tokenError", err);
|
||||
}
|
||||
});
|
||||
|
||||
this.on("tokenGranted", this.onTokenGranted);
|
||||
constructor(
|
||||
baseUrl: string = `${location.protocol}//${location.hostname}:${
|
||||
location.port
|
||||
}/api`
|
||||
) {
|
||||
super();
|
||||
while (baseUrl.charAt(baseUrl.length - 1) === "/") {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
this.baseUrl = baseUrl;
|
||||
|
||||
async makeRequest(url: string, options?: RequestInit, body?: any): Promise<any> {
|
||||
try {
|
||||
options = options || {};
|
||||
options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.authorizationHeader,
|
||||
...options.headers || {},
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options,
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.baseUrl + url, options);
|
||||
} catch (err) {
|
||||
throw new ApiError("Http request error", ErrorCode.Internal, err);
|
||||
}
|
||||
let responseBody: any;
|
||||
try {
|
||||
responseBody = await response.json() || {};
|
||||
} catch (e) {
|
||||
throw new ApiError("Invalid JSON response", ErrorCode.Internal, e);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(responseBody.message || response.statusText, responseBody.code, responseBody.data);
|
||||
}
|
||||
return responseBody;
|
||||
} catch (err) {
|
||||
this.emit("error", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
this.tokenStore = new TokenStore();
|
||||
|
||||
async grantPassword(username: string, password: string) {
|
||||
const request: TokenGrantPasswordRequest = {
|
||||
grant_type: "password", username, password,
|
||||
};
|
||||
const response: TokenGrantResponse = await this.makeRequest("/token/grant", {
|
||||
method: "POST",
|
||||
}, request);
|
||||
this.emit("tokenGranted", response);
|
||||
}
|
||||
this.on("error", (err: ApiError) => {
|
||||
if (err.code === ErrorCode.BadToken) {
|
||||
this.emit("tokenError", err);
|
||||
}
|
||||
});
|
||||
|
||||
async grantRefresh() {
|
||||
const { refreshToken } = this.tokenStore;
|
||||
if (!refreshToken.isValid) {
|
||||
throw new ApiError("can not grant refresh with invalid refresh_token");
|
||||
}
|
||||
const request: TokenGrantRefreshRequest = {
|
||||
grant_type: "refresh", refresh_token: refreshToken.token!,
|
||||
};
|
||||
const response: TokenGrantResponse = await this.makeRequest("/token/grant", {
|
||||
method: "POST",
|
||||
}, request);
|
||||
this.emit("tokenGranted", response);
|
||||
}
|
||||
this.on("tokenGranted", this.onTokenGranted);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onTokenGranted(response: TokenGrantResponse) {
|
||||
this.tokenStore.accessToken.token = response.access_token;
|
||||
this.tokenStore.refreshToken.token = response.refresh_token;
|
||||
this.tokenStore.saveLocalStorage();
|
||||
const { accessToken, refreshToken } = this.tokenStore;
|
||||
log.debug({
|
||||
accessToken: accessToken.claims, refreshToken: refreshToken.claims,
|
||||
}, "got new tokens");
|
||||
async makeRequest(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
body?: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
options = options || {};
|
||||
options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.authorizationHeader,
|
||||
...(options.headers || {})
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.baseUrl + url, options);
|
||||
} catch (err) {
|
||||
throw new ApiError("Http request error", ErrorCode.Internal, err);
|
||||
}
|
||||
let responseBody: any;
|
||||
try {
|
||||
responseBody = (await response.json()) || {};
|
||||
} catch (e) {
|
||||
throw new ApiError("Invalid JSON response", ErrorCode.Internal, e);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
responseBody.message || response.statusText,
|
||||
responseBody.code,
|
||||
responseBody.data
|
||||
);
|
||||
}
|
||||
return responseBody;
|
||||
} catch (err) {
|
||||
this.emit("error", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async grantPassword(username: string, password: string) {
|
||||
const request: TokenGrantPasswordRequest = {
|
||||
grant_type: "password",
|
||||
username,
|
||||
password
|
||||
};
|
||||
const response: TokenGrantResponse = await this.makeRequest(
|
||||
"/token/grant",
|
||||
{
|
||||
method: "POST"
|
||||
},
|
||||
request
|
||||
);
|
||||
this.emit("tokenGranted", response);
|
||||
}
|
||||
|
||||
async grantRefresh() {
|
||||
const { refreshToken } = this.tokenStore;
|
||||
if (!refreshToken.isValid) {
|
||||
throw new ApiError("can not grant refresh with invalid refresh_token");
|
||||
}
|
||||
const request: TokenGrantRefreshRequest = {
|
||||
grant_type: "refresh",
|
||||
refresh_token: refreshToken.token!
|
||||
};
|
||||
const response: TokenGrantResponse = await this.makeRequest(
|
||||
"/token/grant",
|
||||
{
|
||||
method: "POST"
|
||||
},
|
||||
request
|
||||
);
|
||||
this.emit("tokenGranted", response);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private onTokenGranted(response: TokenGrantResponse) {
|
||||
this.tokenStore.accessToken.token = response.access_token;
|
||||
this.tokenStore.refreshToken.token = response.refresh_token;
|
||||
this.tokenStore.saveLocalStorage();
|
||||
const { accessToken, refreshToken } = this.tokenStore;
|
||||
log.debug(
|
||||
{
|
||||
accessToken: accessToken.claims,
|
||||
refreshToken: refreshToken.claims
|
||||
},
|
||||
"got new tokens"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,67 +3,76 @@ import * as jwt from "jsonwebtoken";
|
||||
import { computed, createAtom, IAtom, observable } from "mobx";
|
||||
|
||||
export class Token<TClaims extends TokenClaims = TokenClaims> {
|
||||
@observable token: string | null;
|
||||
@observable
|
||||
token: string | null;
|
||||
|
||||
@computed get claims(): TClaims | null {
|
||||
if (this.token == null) {
|
||||
return null;
|
||||
}
|
||||
return jwt.decode(this.token) as any;
|
||||
@computed
|
||||
get claims(): TClaims | null {
|
||||
if (this.token == null) {
|
||||
return null;
|
||||
}
|
||||
return jwt.decode(this.token) as any;
|
||||
}
|
||||
|
||||
private isExpiredAtom: IAtom;
|
||||
private currentTime!: number;
|
||||
private expirationTimer: number | undefined;
|
||||
private isExpiredAtom: IAtom;
|
||||
private currentTime!: number;
|
||||
private expirationTimer: number | undefined;
|
||||
|
||||
constructor(token: string | null = null) {
|
||||
this.token = token;
|
||||
this.isExpiredAtom = createAtom("Token.isExpired",
|
||||
this.startUpdating, this.stopUpdating);
|
||||
this.updateCurrentTime();
|
||||
constructor(token: string | null = null) {
|
||||
this.token = token;
|
||||
this.isExpiredAtom = createAtom(
|
||||
"Token.isExpired",
|
||||
this.startUpdating,
|
||||
this.stopUpdating
|
||||
);
|
||||
this.updateCurrentTime();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
updateCurrentTime = (reportChanged: boolean = true) => {
|
||||
if (reportChanged) {
|
||||
this.isExpiredAtom.reportChanged();
|
||||
}
|
||||
this.currentTime = Date.now() / 1000;
|
||||
};
|
||||
|
||||
toJSON() {
|
||||
return this.token;
|
||||
get remainingTime(): number {
|
||||
if (!this.isExpiredAtom.reportObserved()) {
|
||||
this.updateCurrentTime(false);
|
||||
}
|
||||
if (this.claims == null || this.claims.exp == null) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
return this.claims.exp - this.currentTime;
|
||||
}
|
||||
|
||||
updateCurrentTime = (reportChanged: boolean = true) => {
|
||||
if (reportChanged) {
|
||||
this.isExpiredAtom.reportChanged();
|
||||
}
|
||||
this.currentTime = Date.now() / 1000;
|
||||
private startUpdating = () => {
|
||||
this.stopUpdating();
|
||||
const remaining = this.remainingTime;
|
||||
if (remaining > 0) {
|
||||
this.expirationTimer = setTimeout(
|
||||
this.updateCurrentTime,
|
||||
this.remainingTime
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
get remainingTime(): number {
|
||||
if (!this.isExpiredAtom.reportObserved()) {
|
||||
this.updateCurrentTime(false);
|
||||
}
|
||||
if (this.claims == null || this.claims.exp == null) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
return this.claims.exp - this.currentTime;
|
||||
private stopUpdating = () => {
|
||||
if (this.expirationTimer != null) {
|
||||
clearTimeout(this.expirationTimer);
|
||||
this.expirationTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private startUpdating = () => {
|
||||
this.stopUpdating();
|
||||
const remaining = this.remainingTime;
|
||||
if (remaining > 0) {
|
||||
this.expirationTimer = setTimeout(this.updateCurrentTime, this.remainingTime);
|
||||
}
|
||||
}
|
||||
get isExpired() {
|
||||
return this.remainingTime <= 0;
|
||||
}
|
||||
|
||||
private stopUpdating = () => {
|
||||
if (this.expirationTimer != null) {
|
||||
clearTimeout(this.expirationTimer);
|
||||
this.expirationTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return this.remainingTime <= 0;
|
||||
}
|
||||
|
||||
@computed get isValid() {
|
||||
return this.token != null && !this.isExpired;
|
||||
}
|
||||
@computed
|
||||
get isValid() {
|
||||
return this.token != null && !this.isExpired;
|
||||
}
|
||||
}
|
||||
|
@ -6,43 +6,51 @@ import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims";
|
||||
const LOCAL_STORAGE_KEY = "TokenStore";
|
||||
|
||||
export class TokenStore {
|
||||
@observable accessToken: Token<AccessToken & BaseClaims> = new Token();
|
||||
@observable refreshToken: Token<RefreshToken & BaseClaims> = new Token();
|
||||
@observable
|
||||
accessToken: Token<AccessToken & BaseClaims> = new Token();
|
||||
@observable
|
||||
refreshToken: Token<RefreshToken & BaseClaims> = new Token();
|
||||
|
||||
@action
|
||||
clearAccessToken() {
|
||||
this.accessToken.token = null;
|
||||
this.saveLocalStorage();
|
||||
}
|
||||
@action
|
||||
clearAccessToken() {
|
||||
this.accessToken.token = null;
|
||||
this.saveLocalStorage();
|
||||
}
|
||||
|
||||
@action
|
||||
clearAll() {
|
||||
this.accessToken.token = null;
|
||||
this.refreshToken.token = null;
|
||||
this.saveLocalStorage();
|
||||
}
|
||||
@action
|
||||
clearAll() {
|
||||
this.accessToken.token = null;
|
||||
this.refreshToken.token = null;
|
||||
this.saveLocalStorage();
|
||||
}
|
||||
|
||||
@action
|
||||
saveLocalStorage() {
|
||||
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.toJSON()));
|
||||
}
|
||||
@action
|
||||
saveLocalStorage() {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(this.toJSON())
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
loadLocalStorage() {
|
||||
const data = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
const data2 = JSON.parse(data);
|
||||
this.updateFromJson(data2);
|
||||
}
|
||||
@action
|
||||
loadLocalStorage() {
|
||||
const data = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
const data2 = JSON.parse(data);
|
||||
this.updateFromJson(data2);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { accessToken: this.accessToken.toJSON(), refreshToken: this.refreshToken.toJSON() };
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
accessToken: this.accessToken.toJSON(),
|
||||
refreshToken: this.refreshToken.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
updateFromJson(json: any) {
|
||||
this.accessToken.token = json.accessToken;
|
||||
this.refreshToken.token = json.refreshToken;
|
||||
}
|
||||
@action
|
||||
updateFromJson(json: any) {
|
||||
this.accessToken.token = json.accessToken;
|
||||
this.refreshToken.token = json.refreshToken;
|
||||
}
|
||||
}
|
||||
|
@ -4,34 +4,34 @@ import { MessageProps } from "semantic-ui-react";
|
||||
import { getRandomId } from "@common/utils";
|
||||
|
||||
export interface UiMessage extends MessageProps {
|
||||
id: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface UiMessageProps extends MessageProps {
|
||||
timeout?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class UiStore {
|
||||
messages: IObservableArray<UiMessage> = observable.array();
|
||||
messages: IObservableArray<UiMessage> = observable.array();
|
||||
|
||||
@action
|
||||
addMessage(message: UiMessageProps): UiMessage {
|
||||
const { timeout, ...otherProps } = message;
|
||||
const msg = observable({
|
||||
...otherProps,
|
||||
id: getRandomId(),
|
||||
});
|
||||
this.messages.push(msg);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
this.removeMessage(msg);
|
||||
}, timeout);
|
||||
}
|
||||
return msg;
|
||||
@action
|
||||
addMessage(message: UiMessageProps): UiMessage {
|
||||
const { timeout, ...otherProps } = message;
|
||||
const msg = observable({
|
||||
...otherProps,
|
||||
id: getRandomId()
|
||||
});
|
||||
this.messages.push(msg);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
this.removeMessage(msg);
|
||||
}, timeout);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@action
|
||||
removeMessage(message: UiMessage) {
|
||||
return this.messages.remove(message);
|
||||
}
|
||||
@action
|
||||
removeMessage(message: UiMessage) {
|
||||
return this.messages.remove(message);
|
||||
}
|
||||
}
|
||||
|
@ -2,16 +2,20 @@ import { ISprinklersDevice, IUser } from "@common/httpApi";
|
||||
import { action, observable } from "mobx";
|
||||
|
||||
export class UserStore {
|
||||
@observable userData: IUser | null = null;
|
||||
@observable
|
||||
userData: IUser | null = null;
|
||||
|
||||
@action.bound
|
||||
receiveUserData(userData: IUser) {
|
||||
this.userData = userData;
|
||||
}
|
||||
@action.bound
|
||||
receiveUserData(userData: IUser) {
|
||||
this.userData = userData;
|
||||
}
|
||||
|
||||
findDevice(id: number): ISprinklersDevice | null {
|
||||
return this.userData &&
|
||||
this.userData.devices &&
|
||||
this.userData.devices.find((dev) => dev.id === id) || null;
|
||||
}
|
||||
findDevice(id: number): ISprinklersDevice | null {
|
||||
return (
|
||||
(this.userData &&
|
||||
this.userData.devices &&
|
||||
this.userData.devices.find(dev => dev.id === id)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,47 +5,52 @@ import { AppState } from "@client/state";
|
||||
const StateContext = React.createContext<AppState | null>(null);
|
||||
|
||||
export interface ProvideStateProps {
|
||||
state: AppState;
|
||||
children: React.ReactNode;
|
||||
state: AppState;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProvideState({ state, children }: ProvideStateProps) {
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
{children}
|
||||
</StateContext.Provider>
|
||||
);
|
||||
return (
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConsumeStateProps {
|
||||
children: (state: AppState) => React.ReactNode;
|
||||
children: (state: AppState) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConsumeState({ children }: ConsumeStateProps) {
|
||||
const consumeState = (state: AppState | null) => {
|
||||
const consumeState = (state: AppState | null) => {
|
||||
if (state == null) {
|
||||
throw new Error(
|
||||
"Component with ConsumeState must be mounted inside ProvideState"
|
||||
);
|
||||
}
|
||||
return children(state);
|
||||
};
|
||||
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
|
||||
}
|
||||
|
||||
type Diff<
|
||||
T extends string | number | symbol,
|
||||
U extends string | number | symbol
|
||||
> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T];
|
||||
type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };
|
||||
|
||||
export function injectState<P extends { appState: AppState }>(
|
||||
Component: React.ComponentType<P>
|
||||
): React.ComponentClass<Omit<P, "appState">> {
|
||||
return class extends React.Component<Omit<P, "appState">> {
|
||||
render() {
|
||||
const consumeState = (state: AppState | null) => {
|
||||
if (state == null) {
|
||||
throw new Error("Component with ConsumeState must be mounted inside ProvideState");
|
||||
throw new Error(
|
||||
"Component with injectState must be mounted inside ProvideState"
|
||||
);
|
||||
}
|
||||
return children(state);
|
||||
};
|
||||
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
|
||||
}
|
||||
|
||||
type Diff<T extends string | number | symbol, U extends string | number | symbol> =
|
||||
({[P in T]: P } & {[P in U]: never } & { [x: string]: never })[T];
|
||||
type Omit<T, K extends keyof T> = {[P in Diff<keyof T, K>]: T[P]};
|
||||
|
||||
export function injectState<P extends { appState: AppState }>(Component: React.ComponentType<P>):
|
||||
React.ComponentClass<Omit<P, "appState">> {
|
||||
return class extends React.Component<Omit<P, "appState">> {
|
||||
render() {
|
||||
const consumeState = (state: AppState | null) => {
|
||||
if (state == null) {
|
||||
throw new Error("Component with injectState must be mounted inside ProvideState");
|
||||
}
|
||||
return <Component {...this.props} appState={state}/>;
|
||||
};
|
||||
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
|
||||
}
|
||||
};
|
||||
return <Component {...this.props} appState={state} />;
|
||||
};
|
||||
return <StateContext.Consumer>{consumeState}</StateContext.Consumer>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -11,35 +11,34 @@
|
||||
}
|
||||
.connectionState {
|
||||
@media only screen and (min-width: 768px) {
|
||||
margin-left: .75em;
|
||||
margin-left: 0.75em;
|
||||
}
|
||||
font-size: .75em;
|
||||
font-size: 0.75em;
|
||||
font-weight: lighter;
|
||||
|
||||
&.connected {
|
||||
color: #13D213;
|
||||
color: #13d213;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
color: #D20000;
|
||||
color: #d20000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section--number,
|
||||
.program--number {
|
||||
width: 2em
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.section--name /*,
|
||||
.program--name*/
|
||||
{
|
||||
{
|
||||
width: 10em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section--state {
|
||||
|
||||
}
|
||||
|
||||
.section-state.running {
|
||||
|
@ -1,4 +1,4 @@
|
||||
$durationInput-spacing: 1.0em;
|
||||
$durationInput-spacing: 1em;
|
||||
$durationInput-inputWidth: 4em;
|
||||
$durationInput-labelWidth: 2.5em;
|
||||
|
||||
@ -10,7 +10,7 @@ $durationInput-labelWidth: 2.5em;
|
||||
|
||||
width: auto;
|
||||
|
||||
.ui.input.durationInput>input {
|
||||
.ui.input.durationInput > input {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ $durationInput-labelWidth: 2.5em;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
>input {
|
||||
> input {
|
||||
min-width: $durationInput-inputWidth;
|
||||
width: auto;
|
||||
flex-basis: $durationInput-inputWidth;
|
||||
@ -30,7 +30,7 @@ $durationInput-labelWidth: 2.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
>.label {
|
||||
> .label {
|
||||
min-width: $durationInput-labelWidth;
|
||||
width: $durationInput-labelWidth;
|
||||
flex: $durationInput-labelWidth;
|
||||
@ -39,4 +39,4 @@ $durationInput-labelWidth: 2.5em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
.programSequence-item {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
margin-bottom: .5em;
|
||||
margin-bottom: 0.5em;
|
||||
&.dragging {
|
||||
z-index: 1010;
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
.scheduleView {
|
||||
>.field, >.fields {
|
||||
>label {
|
||||
width: 2rem !important;
|
||||
}
|
||||
> .field,
|
||||
> .fields {
|
||||
> label {
|
||||
width: 2rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scheduleTimes {
|
||||
input {
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
input {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,13 @@
|
||||
}
|
||||
|
||||
.sectionRunner--pausedState {
|
||||
padding-left: .75em;
|
||||
font-size: .75em;
|
||||
padding-left: 0.75em;
|
||||
font-size: 0.75em;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.sectionRunner--pausedState > .fa {
|
||||
padding-right: .2em;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
|
||||
.sectionRunner--pausedState-unpaused {
|
||||
|
@ -26,9 +26,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
"references": [{
|
||||
"path": "../common"
|
||||
}]
|
||||
}
|
@ -11,7 +11,9 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const HappyPack = require("happypack");
|
||||
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
|
||||
|
||||
const {getClientEnvironment} = require("./env");
|
||||
const {
|
||||
getClientEnvironment
|
||||
} = require("./env");
|
||||
const paths = require("../paths");
|
||||
|
||||
// Webpack uses `publicPath` to determine where the app is being served from.
|
||||
@ -83,49 +85,48 @@ const rules = (env) => {
|
||||
sassConfig,
|
||||
],
|
||||
};
|
||||
return [
|
||||
{
|
||||
// "oneOf" will traverse all following loaders until one will
|
||||
// match the requirements. when no loader matches it will fall
|
||||
// back to the "file" loader at the end of the loader list.
|
||||
oneOf: [
|
||||
// "url" loader works like "file" loader except that it embeds assets
|
||||
// smaller than specified limit in bytes as data urls to avoid requests.
|
||||
// a missing `test` is equivalent to a match.
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
|
||||
loader: require.resolve("url-loader"),
|
||||
options: {
|
||||
limit: (env === "prod") ? 10000 : 0,
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
return [{
|
||||
// "oneOf" will traverse all following loaders until one will
|
||||
// match the requirements. when no loader matches it will fall
|
||||
// back to the "file" loader at the end of the loader list.
|
||||
oneOf: [
|
||||
// "url" loader works like "file" loader except that it embeds assets
|
||||
// smaller than specified limit in bytes as data urls to avoid requests.
|
||||
// a missing `test` is equivalent to a match.
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
|
||||
loader: require.resolve("url-loader"),
|
||||
options: {
|
||||
limit: (env === "prod") ? 10000 : 0,
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
cssRule,
|
||||
sassRule,
|
||||
// Process TypeScript with TSC through HappyPack.
|
||||
{
|
||||
test: /\.tsx?$/, use: "happypack/loader?id=ts",
|
||||
include: [ paths.clientDir, paths.commonDir ],
|
||||
},
|
||||
cssRule,
|
||||
sassRule,
|
||||
// Process TypeScript with TSC through HappyPack.
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: "happypack/loader?id=ts",
|
||||
include: [paths.clientDir, paths.commonDir],
|
||||
},
|
||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
||||
// When you `import` an asset, you get its (virtual) filename.
|
||||
// In production, they would get copied to the `build` folder.
|
||||
// This loader doesn"t use a "test" so it will catch all modules
|
||||
// that fall through the other loaders.
|
||||
{
|
||||
// Exclude `js` files to keep "css" loader working as it injects
|
||||
// it"s runtime that would otherwise processed through "file" loader.
|
||||
// Also exclude `html` and `json` extensions so they get processed
|
||||
// by webpacks internal loaders.
|
||||
exclude: [/\.js$/, /\.html$/, /\.json$/],
|
||||
loader: require.resolve("file-loader"),
|
||||
options: {
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
||||
// When you `import` an asset, you get its (virtual) filename.
|
||||
// In production, they would get copied to the `build` folder.
|
||||
// This loader doesn"t use a "test" so it will catch all modules
|
||||
// that fall through the other loaders.
|
||||
{
|
||||
// Exclude `js` files to keep "css" loader working as it injects
|
||||
// it"s runtime that would otherwise processed through "file" loader.
|
||||
// Also exclude `html` and `json` extensions so they get processed
|
||||
// by webpacks internal loaders.
|
||||
exclude: [/\.js$/, /\.html$/, /\.json$/],
|
||||
loader: require.resolve("file-loader"),
|
||||
options: {
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
],
|
||||
}, ];
|
||||
}
|
||||
|
||||
|
||||
@ -200,8 +201,7 @@ const getConfig = module.exports = (env) => {
|
||||
mode: isProd ? "production" : "development",
|
||||
bail: isProd,
|
||||
devtool: shouldUseSourceMap ?
|
||||
isProd ? "source-map" : "inline-source-map" :
|
||||
false,
|
||||
isProd ? "source-map" : "inline-source-map" : false,
|
||||
entry: [
|
||||
isDev && require.resolve("react-hot-loader/patch"),
|
||||
isDev && require.resolve("react-dev-utils/webpackHotDevClient"),
|
||||
@ -212,15 +212,13 @@ const getConfig = module.exports = (env) => {
|
||||
path: paths.clientBuildDir,
|
||||
pathinfo: isDev,
|
||||
filename: isProd ?
|
||||
'static/js/[name].[chunkhash:8].js' :
|
||||
"static/js/bundle.js",
|
||||
'static/js/[name].[chunkhash:8].js' : "static/js/bundle.js",
|
||||
chunkFilename: isProd ?
|
||||
'static/js/[name].[chunkhash:8].chunk.js' :
|
||||
"static/js/[name].chunk.js",
|
||||
'static/js/[name].[chunkhash:8].chunk.js' : "static/js/[name].chunk.js",
|
||||
publicPath: publicPath,
|
||||
devtoolModuleFilenameTemplate: isDev ?
|
||||
(info) =>
|
||||
"webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined,
|
||||
"webpack://" + path.resolve(info.absoluteResourcePath).replace(/\\/g, "/") : undefined,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".json", ".scss"],
|
||||
@ -247,4 +245,4 @@ const getConfig = module.exports = (env) => {
|
||||
}],
|
||||
},
|
||||
}
|
||||
};
|
||||
};
|
@ -1,26 +1,32 @@
|
||||
import { ErrorCode, toHttpStatus } from "@common/ErrorCode";
|
||||
|
||||
export default class ApiError extends Error {
|
||||
name = "ApiError";
|
||||
statusCode: number;
|
||||
code: ErrorCode;
|
||||
data: any;
|
||||
name = "ApiError";
|
||||
statusCode: number;
|
||||
code: ErrorCode;
|
||||
data: any;
|
||||
|
||||
constructor(message: string, code: ErrorCode = ErrorCode.BadRequest, data: any = {}) {
|
||||
super(message);
|
||||
this.statusCode = toHttpStatus(code);
|
||||
this.code = code;
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (data instanceof Error) {
|
||||
this.data = data.toString();
|
||||
} else {
|
||||
this.data = data;
|
||||
}
|
||||
constructor(
|
||||
message: string,
|
||||
code: ErrorCode = ErrorCode.BadRequest,
|
||||
data: any = {}
|
||||
) {
|
||||
super(message);
|
||||
this.statusCode = toHttpStatus(code);
|
||||
this.code = code;
|
||||
// tslint:disable-next-line:prefer-conditional-expression
|
||||
if (data instanceof Error) {
|
||||
this.data = data.toString();
|
||||
} else {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
message: this.message, code: this.code, data: this.data,
|
||||
};
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
data: this.data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,41 +1,41 @@
|
||||
export class Duration {
|
||||
static fromSeconds(seconds: number): Duration {
|
||||
return new Duration(Math.floor(seconds / 60), seconds % 60);
|
||||
}
|
||||
static fromSeconds(seconds: number): Duration {
|
||||
return new Duration(Math.floor(seconds / 60), seconds % 60);
|
||||
}
|
||||
|
||||
minutes: number = 0;
|
||||
seconds: number = 0;
|
||||
minutes: number = 0;
|
||||
seconds: number = 0;
|
||||
|
||||
constructor(minutes: number = 0, seconds: number = 0) {
|
||||
this.minutes = minutes;
|
||||
this.seconds = seconds;
|
||||
}
|
||||
constructor(minutes: number = 0, seconds: number = 0) {
|
||||
this.minutes = minutes;
|
||||
this.seconds = seconds;
|
||||
}
|
||||
|
||||
toSeconds(): number {
|
||||
return this.minutes * 60 + this.seconds;
|
||||
}
|
||||
toSeconds(): number {
|
||||
return this.minutes * 60 + this.seconds;
|
||||
}
|
||||
|
||||
withSeconds(newSeconds: number): Duration {
|
||||
let newMinutes = this.minutes;
|
||||
if (newSeconds >= 60) {
|
||||
newMinutes++;
|
||||
newSeconds = 0;
|
||||
}
|
||||
if (newSeconds < 0) {
|
||||
newMinutes = Math.max(0, newMinutes - 1);
|
||||
newSeconds = 59;
|
||||
}
|
||||
return new Duration(newMinutes, newSeconds);
|
||||
withSeconds(newSeconds: number): Duration {
|
||||
let newMinutes = this.minutes;
|
||||
if (newSeconds >= 60) {
|
||||
newMinutes++;
|
||||
newSeconds = 0;
|
||||
}
|
||||
if (newSeconds < 0) {
|
||||
newMinutes = Math.max(0, newMinutes - 1);
|
||||
newSeconds = 59;
|
||||
}
|
||||
return new Duration(newMinutes, newSeconds);
|
||||
}
|
||||
|
||||
withMinutes(newMinutes: number): Duration {
|
||||
if (newMinutes < 0) {
|
||||
newMinutes = 0;
|
||||
}
|
||||
return new Duration(newMinutes, this.seconds);
|
||||
withMinutes(newMinutes: number): Duration {
|
||||
if (newMinutes < 0) {
|
||||
newMinutes = 0;
|
||||
}
|
||||
return new Duration(newMinutes, this.seconds);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.minutes}M ${this.seconds.toFixed(1)}S`;
|
||||
}
|
||||
toString(): string {
|
||||
return `${this.minutes}M ${this.seconds.toFixed(1)}S`;
|
||||
}
|
||||
}
|
||||
|
@ -1,41 +1,41 @@
|
||||
export enum ErrorCode {
|
||||
BadRequest = 100,
|
||||
NotSpecified = 101,
|
||||
Parse = 102,
|
||||
Range = 103,
|
||||
InvalidData = 104,
|
||||
BadToken = 105,
|
||||
Unauthorized = 106,
|
||||
NoPermission = 107,
|
||||
NotImplemented = 108,
|
||||
NotFound = 109,
|
||||
Internal = 200,
|
||||
Timeout = 300,
|
||||
ServerDisconnected = 301,
|
||||
BrokerDisconnected = 302,
|
||||
BadRequest = 100,
|
||||
NotSpecified = 101,
|
||||
Parse = 102,
|
||||
Range = 103,
|
||||
InvalidData = 104,
|
||||
BadToken = 105,
|
||||
Unauthorized = 106,
|
||||
NoPermission = 107,
|
||||
NotImplemented = 108,
|
||||
NotFound = 109,
|
||||
Internal = 200,
|
||||
Timeout = 300,
|
||||
ServerDisconnected = 301,
|
||||
BrokerDisconnected = 302
|
||||
}
|
||||
|
||||
export function toHttpStatus(errorCode: ErrorCode): number {
|
||||
switch (errorCode) {
|
||||
case ErrorCode.BadRequest:
|
||||
case ErrorCode.NotSpecified:
|
||||
case ErrorCode.Parse:
|
||||
case ErrorCode.Range:
|
||||
case ErrorCode.InvalidData:
|
||||
return 400; // Bad request
|
||||
case ErrorCode.Unauthorized:
|
||||
case ErrorCode.BadToken:
|
||||
return 401; // Unauthorized
|
||||
case ErrorCode.NoPermission:
|
||||
return 403; // Forbidden
|
||||
case ErrorCode.NotFound:
|
||||
return 404;
|
||||
case ErrorCode.NotImplemented:
|
||||
return 501;
|
||||
case ErrorCode.Internal:
|
||||
case ErrorCode.ServerDisconnected:
|
||||
case ErrorCode.BrokerDisconnected:
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
switch (errorCode) {
|
||||
case ErrorCode.BadRequest:
|
||||
case ErrorCode.NotSpecified:
|
||||
case ErrorCode.Parse:
|
||||
case ErrorCode.Range:
|
||||
case ErrorCode.InvalidData:
|
||||
return 400; // Bad request
|
||||
case ErrorCode.Unauthorized:
|
||||
case ErrorCode.BadToken:
|
||||
return 401; // Unauthorized
|
||||
case ErrorCode.NoPermission:
|
||||
return 403; // Forbidden
|
||||
case ErrorCode.NotFound:
|
||||
return 404;
|
||||
case ErrorCode.NotImplemented:
|
||||
return 501;
|
||||
case ErrorCode.Internal:
|
||||
case ErrorCode.ServerDisconnected:
|
||||
case ErrorCode.BrokerDisconnected:
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
@ -1,34 +1,39 @@
|
||||
export interface BaseClaims {
|
||||
iss: string;
|
||||
exp?: number;
|
||||
iss: string;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
type: "access";
|
||||
aud: number;
|
||||
name: string;
|
||||
type: "access";
|
||||
aud: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RefreshToken {
|
||||
type: "refresh";
|
||||
aud: number;
|
||||
name: string;
|
||||
type: "refresh";
|
||||
aud: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationToken {
|
||||
type: "device_reg";
|
||||
type: "device_reg";
|
||||
}
|
||||
|
||||
export interface DeviceToken {
|
||||
type: "device";
|
||||
aud: string;
|
||||
id: number;
|
||||
type: "device";
|
||||
aud: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SuperuserToken {
|
||||
type: "superuser";
|
||||
type: "superuser";
|
||||
}
|
||||
|
||||
export type TokenClaimTypes = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken;
|
||||
export type TokenClaimTypes =
|
||||
| AccessToken
|
||||
| RefreshToken
|
||||
| DeviceRegistrationToken
|
||||
| DeviceToken
|
||||
| SuperuserToken;
|
||||
|
||||
export type TokenClaims = TokenClaimTypes & BaseClaims;
|
||||
|
@ -4,61 +4,81 @@ type TEventName = string | symbol;
|
||||
|
||||
type AnyListener = (...args: any[]) => void;
|
||||
|
||||
type Arguments<TListener> = TListener extends (...args: infer TArgs) => any ? TArgs : any[];
|
||||
type Listener<TEvents, TEvent extends keyof TEvents> = TEvents[TEvent] extends (...args: infer TArgs) => any ?
|
||||
(...args: TArgs) => void : AnyListener;
|
||||
type Arguments<TListener> = TListener extends (...args: infer TArgs) => any
|
||||
? TArgs
|
||||
: any[];
|
||||
type Listener<TEvents, TEvent extends keyof TEvents> = TEvents[TEvent] extends (
|
||||
...args: infer TArgs
|
||||
) => any
|
||||
? (...args: TArgs) => void
|
||||
: AnyListener;
|
||||
|
||||
export interface DefaultEvents {
|
||||
newListener: (event: TEventName, listener: AnyListener) => void;
|
||||
removeListener: (event: TEventName, listener: AnyListener) => void;
|
||||
newListener: (event: TEventName, listener: AnyListener) => void;
|
||||
removeListener: (event: TEventName, listener: AnyListener) => void;
|
||||
}
|
||||
|
||||
export type AnyEvents = DefaultEvents & {
|
||||
[event in TEventName]: any[];
|
||||
};
|
||||
export type AnyEvents = DefaultEvents & { [event in TEventName]: any[] };
|
||||
|
||||
type IEventSubscriber<TEvents extends DefaultEvents, This> =
|
||||
<TEvent extends keyof TEvents & TEventName>(event: TEvent, listener: Listener<TEvents, TEvent>) => This;
|
||||
type IEventSubscriber<TEvents extends DefaultEvents, This> = <
|
||||
TEvent extends keyof TEvents & TEventName
|
||||
>(
|
||||
event: TEvent,
|
||||
listener: Listener<TEvents, TEvent>
|
||||
) => This;
|
||||
|
||||
// tslint:disable:ban-types
|
||||
|
||||
interface ITypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> {
|
||||
on: IEventSubscriber<TEvents, this>;
|
||||
off: IEventSubscriber<TEvents, this>;
|
||||
once: IEventSubscriber<TEvents, this>;
|
||||
addListener: IEventSubscriber<TEvents, this>;
|
||||
removeListener: IEventSubscriber<TEvents, this>;
|
||||
prependListener: IEventSubscriber<TEvents, this>;
|
||||
prependOnceListener: IEventSubscriber<TEvents, this>;
|
||||
on: IEventSubscriber<TEvents, this>;
|
||||
off: IEventSubscriber<TEvents, this>;
|
||||
once: IEventSubscriber<TEvents, this>;
|
||||
addListener: IEventSubscriber<TEvents, this>;
|
||||
removeListener: IEventSubscriber<TEvents, this>;
|
||||
prependListener: IEventSubscriber<TEvents, this>;
|
||||
prependOnceListener: IEventSubscriber<TEvents, this>;
|
||||
|
||||
emit<TEvent extends keyof TEvents & TEventName>(event: TEvent, ...args: Arguments<TEvents[TEvent]>): boolean;
|
||||
listeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
|
||||
rawListeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
|
||||
eventNames(): Array<keyof TEvents | TEventName>;
|
||||
setMaxListeners(maxListeners: number): this;
|
||||
getMaxListeners(): number;
|
||||
listenerCount<TEvent extends keyof TEvents & TEventName>(event: TEvent): number;
|
||||
emit<TEvent extends keyof TEvents & TEventName>(
|
||||
event: TEvent,
|
||||
...args: Arguments<TEvents[TEvent]>
|
||||
): boolean;
|
||||
listeners<TEvent extends keyof TEvents & TEventName>(
|
||||
event: TEvent
|
||||
): Function[];
|
||||
rawListeners<TEvent extends keyof TEvents & TEventName>(
|
||||
event: TEvent
|
||||
): Function[];
|
||||
eventNames(): Array<keyof TEvents | TEventName>;
|
||||
setMaxListeners(maxListeners: number): this;
|
||||
getMaxListeners(): number;
|
||||
listenerCount<TEvent extends keyof TEvents & TEventName>(
|
||||
event: TEvent
|
||||
): number;
|
||||
}
|
||||
|
||||
const TypedEventEmitter = EventEmitter as {
|
||||
new<TEvents extends DefaultEvents = AnyEvents>(): TypedEventEmitter<TEvents>,
|
||||
new <TEvents extends DefaultEvents = AnyEvents>(): TypedEventEmitter<TEvents>;
|
||||
};
|
||||
type TypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> = ITypedEventEmitter<TEvents>;
|
||||
type TypedEventEmitter<
|
||||
TEvents extends DefaultEvents = AnyEvents
|
||||
> = ITypedEventEmitter<TEvents>;
|
||||
|
||||
type Constructable = new (...args: any[]) => any;
|
||||
|
||||
export function typedEventEmitter<TBase extends Constructable, TEvents extends DefaultEvents = AnyEvents>(Base: TBase):
|
||||
TBase & TypedEventEmitter<TEvents> {
|
||||
const NewClass = class extends Base {
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
EventEmitter.call(this);
|
||||
}
|
||||
};
|
||||
Object.getOwnPropertyNames(EventEmitter.prototype).forEach((name) => {
|
||||
NewClass.prototype[name] = (EventEmitter.prototype as any)[name];
|
||||
});
|
||||
return NewClass as any;
|
||||
export function typedEventEmitter<
|
||||
TBase extends Constructable,
|
||||
TEvents extends DefaultEvents = AnyEvents
|
||||
>(Base: TBase): TBase & TypedEventEmitter<TEvents> {
|
||||
const NewClass = class extends Base {
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
EventEmitter.call(this);
|
||||
}
|
||||
};
|
||||
Object.getOwnPropertyNames(EventEmitter.prototype).forEach(name => {
|
||||
NewClass.prototype[name] = (EventEmitter.prototype as any)[name];
|
||||
});
|
||||
return NewClass as any;
|
||||
}
|
||||
|
||||
export { TypedEventEmitter };
|
||||
|
@ -1,31 +1,33 @@
|
||||
export interface TokenGrantPasswordRequest {
|
||||
grant_type: "password";
|
||||
username: string;
|
||||
password: string;
|
||||
grant_type: "password";
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenGrantRefreshRequest {
|
||||
grant_type: "refresh";
|
||||
refresh_token: string;
|
||||
grant_type: "refresh";
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export type TokenGrantRequest = TokenGrantPasswordRequest | TokenGrantRefreshRequest;
|
||||
export type TokenGrantRequest =
|
||||
| TokenGrantPasswordRequest
|
||||
| TokenGrantRefreshRequest;
|
||||
|
||||
export interface TokenGrantResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
devices: ISprinklersDevice[] | undefined;
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
devices: ISprinklersDevice[] | undefined;
|
||||
}
|
||||
|
||||
export interface ISprinklersDevice {
|
||||
id: number;
|
||||
deviceId: string | null;
|
||||
name: string;
|
||||
users: IUser[] | undefined;
|
||||
id: number;
|
||||
deviceId: string | null;
|
||||
name: string;
|
||||
users: IUser[] | undefined;
|
||||
}
|
||||
|
@ -2,9 +2,9 @@
|
||||
export type DefaultRequestTypes = {};
|
||||
export type DefaultResponseTypes = {};
|
||||
export type DefaultErrorType = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
export type DefaultNotificationTypes = {};
|
||||
// tslint:enable:interface-over-type-literal
|
||||
@ -16,145 +16,196 @@ export type DefaultNotificationTypes = {};
|
||||
// ErrorType: DefaultErrorType;
|
||||
// }
|
||||
|
||||
export interface Request<RequestTypes = DefaultRequestTypes,
|
||||
Method extends keyof RequestTypes = keyof RequestTypes> {
|
||||
type: "request";
|
||||
id: number;
|
||||
method: Method;
|
||||
params: RequestTypes[Method];
|
||||
export interface Request<
|
||||
RequestTypes = DefaultRequestTypes,
|
||||
Method extends keyof RequestTypes = keyof RequestTypes
|
||||
> {
|
||||
type: "request";
|
||||
id: number;
|
||||
method: Method;
|
||||
params: RequestTypes[Method];
|
||||
}
|
||||
|
||||
export interface ResponseBase<Method> {
|
||||
type: "response";
|
||||
id: number;
|
||||
method: Method;
|
||||
type: "response";
|
||||
id: number;
|
||||
method: Method;
|
||||
}
|
||||
|
||||
export interface SuccessData<ResponseType> {
|
||||
result: "success";
|
||||
data: ResponseType;
|
||||
result: "success";
|
||||
data: ResponseType;
|
||||
}
|
||||
|
||||
export interface ErrorData<ErrorType> {
|
||||
result: "error";
|
||||
error: ErrorType;
|
||||
result: "error";
|
||||
error: ErrorType;
|
||||
}
|
||||
|
||||
export type ResponseData<ResponseTypes, ErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes> =
|
||||
SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>;
|
||||
export type ResponseData<
|
||||
ResponseTypes,
|
||||
ErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes
|
||||
> = SuccessData<ResponseTypes[Method]> | ErrorData<ErrorType>;
|
||||
|
||||
export type Response<ResponseTypes,
|
||||
ErrorType = DefaultErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes> =
|
||||
ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>;
|
||||
export type Response<
|
||||
ResponseTypes,
|
||||
ErrorType = DefaultErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes
|
||||
> = ResponseBase<Method> & ResponseData<ResponseTypes, ErrorType, Method>;
|
||||
|
||||
export interface Notification<NotificationTypes = DefaultNotificationTypes,
|
||||
Method extends keyof NotificationTypes = keyof NotificationTypes> {
|
||||
type: "notification";
|
||||
method: Method;
|
||||
data: NotificationTypes[Method];
|
||||
export interface Notification<
|
||||
NotificationTypes = DefaultNotificationTypes,
|
||||
Method extends keyof NotificationTypes = keyof NotificationTypes
|
||||
> {
|
||||
type: "notification";
|
||||
method: Method;
|
||||
data: NotificationTypes[Method];
|
||||
}
|
||||
|
||||
export type Message<RequestTypes = DefaultRequestTypes,
|
||||
ResponseTypes = DefaultResponseTypes,
|
||||
ErrorType = DefaultErrorType,
|
||||
NotificationTypes = DefaultNotificationTypes> =
|
||||
Request<RequestTypes> |
|
||||
Response<ResponseTypes, ErrorType> |
|
||||
Notification<NotificationTypes>;
|
||||
export type Message<
|
||||
RequestTypes = DefaultRequestTypes,
|
||||
ResponseTypes = DefaultResponseTypes,
|
||||
ErrorType = DefaultErrorType,
|
||||
NotificationTypes = DefaultNotificationTypes
|
||||
> =
|
||||
| Request<RequestTypes>
|
||||
| Response<ResponseTypes, ErrorType>
|
||||
| Notification<NotificationTypes>;
|
||||
|
||||
// export type TypesMessage<Types extends RpcTypes = RpcTypes> =
|
||||
// Message<Types["RequestTypes"], Types["ResponseTypes"], Types["ErrorType"], Types["NotificationTypes"]>;
|
||||
|
||||
export function isRequestMethod<Method extends keyof RequestTypes, RequestTypes>(
|
||||
message: Request<RequestTypes>, method: Method,
|
||||
export function isRequestMethod<
|
||||
Method extends keyof RequestTypes,
|
||||
RequestTypes
|
||||
>(
|
||||
message: Request<RequestTypes>,
|
||||
method: Method
|
||||
): message is Request<RequestTypes, Method> {
|
||||
return message.method === method;
|
||||
return message.method === method;
|
||||
}
|
||||
|
||||
export function isResponseMethod<Method extends keyof ResponseTypes, ErrorType, ResponseTypes>(
|
||||
message: Response<ResponseTypes, ErrorType>, method: Method,
|
||||
export function isResponseMethod<
|
||||
Method extends keyof ResponseTypes,
|
||||
ErrorType,
|
||||
ResponseTypes
|
||||
>(
|
||||
message: Response<ResponseTypes, ErrorType>,
|
||||
method: Method
|
||||
): message is Response<ResponseTypes, ErrorType, Method> {
|
||||
return message.method === method;
|
||||
return message.method === method;
|
||||
}
|
||||
|
||||
export function isNotificationMethod<Method extends keyof NotificationTypes, NotificationTypes = any>(
|
||||
message: Notification<NotificationTypes>, method: Method,
|
||||
export function isNotificationMethod<
|
||||
Method extends keyof NotificationTypes,
|
||||
NotificationTypes = any
|
||||
>(
|
||||
message: Notification<NotificationTypes>,
|
||||
method: Method
|
||||
): message is Notification<NotificationTypes, Method> {
|
||||
return message.method === method;
|
||||
return message.method === method;
|
||||
}
|
||||
|
||||
export type IRequestHandler<RequestTypes, ResponseTypes extends { [M in Method]: any }, ErrorType,
|
||||
Method extends keyof RequestTypes> =
|
||||
(request: RequestTypes[Method]) => Promise<ResponseData<ResponseTypes, ErrorType, Method>>;
|
||||
export type IRequestHandler<
|
||||
RequestTypes,
|
||||
ResponseTypes extends { [M in Method]: any },
|
||||
ErrorType,
|
||||
Method extends keyof RequestTypes
|
||||
> = (
|
||||
request: RequestTypes[Method]
|
||||
) => Promise<ResponseData<ResponseTypes, ErrorType, Method>>;
|
||||
|
||||
export type RequestHandlers<RequestTypes, ResponseTypes extends { [M in keyof RequestTypes]: any }, ErrorType> = {
|
||||
[Method in keyof RequestTypes]:
|
||||
IRequestHandler<RequestTypes, ResponseTypes, ErrorType, Method>;
|
||||
export type RequestHandlers<
|
||||
RequestTypes,
|
||||
ResponseTypes extends { [M in keyof RequestTypes]: any },
|
||||
ErrorType
|
||||
> = {
|
||||
[Method in keyof RequestTypes]: IRequestHandler<
|
||||
RequestTypes,
|
||||
ResponseTypes,
|
||||
ErrorType,
|
||||
Method
|
||||
>
|
||||
};
|
||||
|
||||
export type IResponseHandler<ResponseTypes, ErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes> =
|
||||
(response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
|
||||
export type IResponseHandler<
|
||||
ResponseTypes,
|
||||
ErrorType,
|
||||
Method extends keyof ResponseTypes = keyof ResponseTypes
|
||||
> = (response: ResponseData<ResponseTypes, ErrorType, Method>) => void;
|
||||
|
||||
export interface ResponseHandlers<ResponseTypes = DefaultResponseTypes, ErrorType = DefaultErrorType> {
|
||||
[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> =
|
||||
(notification: NotificationTypes[Method]) => void;
|
||||
export type NotificationHandler<
|
||||
NotificationTypes,
|
||||
Method extends keyof NotificationTypes
|
||||
> = (notification: NotificationTypes[Method]) => void;
|
||||
|
||||
export type NotificationHandlers<NotificationTypes> = {
|
||||
[Method in keyof NotificationTypes]: NotificationHandler<NotificationTypes, Method>;
|
||||
[Method in keyof NotificationTypes]: NotificationHandler<
|
||||
NotificationTypes,
|
||||
Method
|
||||
>
|
||||
};
|
||||
|
||||
export function listRequestHandlerMethods<RequestTypes,
|
||||
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>(
|
||||
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
|
||||
export function listRequestHandlerMethods<
|
||||
RequestTypes,
|
||||
ResponseTypes extends { [Method in keyof RequestTypes]: any },
|
||||
ErrorType
|
||||
>(
|
||||
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>
|
||||
): Array<keyof RequestTypes> {
|
||||
return Object.keys(handlers) as any;
|
||||
return Object.keys(handlers) as any;
|
||||
}
|
||||
|
||||
export function listNotificationHandlerMethods<NotificationTypes>(
|
||||
handlers: NotificationHandlers<NotificationTypes>,
|
||||
handlers: NotificationHandlers<NotificationTypes>
|
||||
): Array<keyof NotificationTypes> {
|
||||
return Object.keys(handlers) as any;
|
||||
return Object.keys(handlers) as any;
|
||||
}
|
||||
|
||||
export async function handleRequest<RequestTypes,
|
||||
ResponseTypes extends { [Method in keyof RequestTypes]: any }, ErrorType>(
|
||||
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
|
||||
message: Request<RequestTypes>,
|
||||
thisParam?: any,
|
||||
export async function handleRequest<
|
||||
RequestTypes,
|
||||
ResponseTypes extends { [Method in keyof RequestTypes]: any },
|
||||
ErrorType
|
||||
>(
|
||||
handlers: RequestHandlers<RequestTypes, ResponseTypes, ErrorType>,
|
||||
message: Request<RequestTypes>,
|
||||
thisParam?: any
|
||||
): Promise<ResponseData<ResponseTypes, ErrorType>> {
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
throw new Error("No handler for request method " + message.method);
|
||||
}
|
||||
return handler.call(thisParam, message.params);
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
throw new Error("No handler for request method " + message.method);
|
||||
}
|
||||
return handler.call(thisParam, message.params);
|
||||
}
|
||||
|
||||
export function handleResponse<ResponseTypes, ErrorType>(
|
||||
handlers: ResponseHandlers<ResponseTypes, ErrorType>,
|
||||
message: Response<ResponseTypes, ErrorType>,
|
||||
thisParam?: any,
|
||||
handlers: ResponseHandlers<ResponseTypes, ErrorType>,
|
||||
message: Response<ResponseTypes, ErrorType>,
|
||||
thisParam?: any
|
||||
) {
|
||||
const handler = handlers[message.id];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
return handler.call(thisParam, message);
|
||||
const handler = handlers[message.id];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
return handler.call(thisParam, message);
|
||||
}
|
||||
|
||||
export function handleNotification<NotificationTypes>(
|
||||
handlers: NotificationHandlers<NotificationTypes>,
|
||||
message: Notification<NotificationTypes>,
|
||||
thisParam?: any,
|
||||
handlers: NotificationHandlers<NotificationTypes>,
|
||||
message: Notification<NotificationTypes>,
|
||||
thisParam?: any
|
||||
) {
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
throw new Error("No handler for notification method " + message.method);
|
||||
}
|
||||
return handler.call(thisParam, message.data);
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
throw new Error("No handler for notification method " + message.method);
|
||||
}
|
||||
return handler.call(thisParam, message.data);
|
||||
}
|
||||
|
164
common/logger.ts
164
common/logger.ts
@ -4,112 +4,124 @@ import * as pino from "pino";
|
||||
|
||||
type Level = "default" | "60" | "50" | "40" | "30" | "20" | "10";
|
||||
|
||||
const levels: {[level in Level]: string } = {
|
||||
default: "USERLVL",
|
||||
60: "FATAL",
|
||||
50: "ERROR",
|
||||
40: "WARN",
|
||||
30: "INFO",
|
||||
20: "DEBUG",
|
||||
10: "TRACE",
|
||||
const levels: { [level in Level]: string } = {
|
||||
default: "USERLVL",
|
||||
60: "FATAL",
|
||||
50: "ERROR",
|
||||
40: "WARN",
|
||||
30: "INFO",
|
||||
20: "DEBUG",
|
||||
10: "TRACE"
|
||||
};
|
||||
|
||||
const levelColors: {[level in Level]: string } = {
|
||||
default: "text-decoration: underline; color: #000000;",
|
||||
60: "text-decoration: underline; background-color: #FF0000;",
|
||||
50: "text-decoration: underline; color: #FF0000;",
|
||||
40: "text-decoration: underline; color: #FFFF00;",
|
||||
30: "text-decoration: underline; color: #00FF00;",
|
||||
20: "text-decoration: underline; color: #0000FF;",
|
||||
10: "text-decoration: underline; color: #AAAAAA;",
|
||||
const levelColors: { [level in Level]: string } = {
|
||||
default: "text-decoration: underline; color: #000000;",
|
||||
60: "text-decoration: underline; background-color: #FF0000;",
|
||||
50: "text-decoration: underline; color: #FF0000;",
|
||||
40: "text-decoration: underline; color: #FFFF00;",
|
||||
30: "text-decoration: underline; color: #00FF00;",
|
||||
20: "text-decoration: underline; color: #0000FF;",
|
||||
10: "text-decoration: underline; color: #AAAAAA;"
|
||||
};
|
||||
|
||||
interface ColoredString {
|
||||
str: string;
|
||||
args: any[];
|
||||
str: string;
|
||||
args: any[];
|
||||
}
|
||||
|
||||
function makeColored(str: string = ""): ColoredString {
|
||||
return { str, args: [] };
|
||||
return { str, args: [] };
|
||||
}
|
||||
|
||||
function concatColored(...coloredStrings: ColoredString[]): ColoredString {
|
||||
return coloredStrings.reduce((prev, cur) => ({
|
||||
str: prev.str + cur.str,
|
||||
args: prev.args.concat(cur.args),
|
||||
}), makeColored());
|
||||
return coloredStrings.reduce(
|
||||
(prev, cur) => ({
|
||||
str: prev.str + cur.str,
|
||||
args: prev.args.concat(cur.args)
|
||||
}),
|
||||
makeColored()
|
||||
);
|
||||
}
|
||||
|
||||
const standardKeys = ["pid", "hostname", "name", "level", "time", "v", "source", "msg"];
|
||||
const standardKeys = [
|
||||
"pid",
|
||||
"hostname",
|
||||
"name",
|
||||
"level",
|
||||
"time",
|
||||
"v",
|
||||
"source",
|
||||
"msg"
|
||||
];
|
||||
|
||||
function write(value: any) {
|
||||
let line = concatColored(
|
||||
// makeColored(formatTime(value, " ")),
|
||||
formatSource(value),
|
||||
formatLevel(value),
|
||||
makeColored(": "),
|
||||
);
|
||||
let line = concatColored(
|
||||
// makeColored(formatTime(value, " ")),
|
||||
formatSource(value),
|
||||
formatLevel(value),
|
||||
makeColored(": ")
|
||||
);
|
||||
|
||||
if (value.msg) {
|
||||
line = concatColored(line, {
|
||||
str: "%c" + value.msg, args: ["color: #00FFFF"],
|
||||
});
|
||||
}
|
||||
const args = [line.str].concat(line.args)
|
||||
.concat([
|
||||
(value.type === "Error") ? value.stack : filter(value),
|
||||
]);
|
||||
let fn;
|
||||
if (value.level >= 50) {
|
||||
fn = console.error;
|
||||
} else if (value.level >= 40) {
|
||||
fn = console.warn;
|
||||
} else {
|
||||
fn = console.log;
|
||||
}
|
||||
fn.apply(null, args);
|
||||
if (value.msg) {
|
||||
line = concatColored(line, {
|
||||
str: "%c" + value.msg,
|
||||
args: ["color: #00FFFF"]
|
||||
});
|
||||
}
|
||||
const args = [line.str]
|
||||
.concat(line.args)
|
||||
.concat([value.type === "Error" ? value.stack : filter(value)]);
|
||||
let fn;
|
||||
if (value.level >= 50) {
|
||||
fn = console.error;
|
||||
} else if (value.level >= 40) {
|
||||
fn = console.warn;
|
||||
} else {
|
||||
fn = console.log;
|
||||
}
|
||||
fn.apply(null, args);
|
||||
}
|
||||
|
||||
function filter(value: any) {
|
||||
const keys = Object.keys(value);
|
||||
const result: any = {};
|
||||
const keys = Object.keys(value);
|
||||
const result: any = {};
|
||||
|
||||
for (const key of keys) {
|
||||
if (standardKeys.indexOf(key) < 0) {
|
||||
result[key] = value[key];
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (standardKeys.indexOf(key) < 0) {
|
||||
result[key] = value[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatSource(value: any): { str: string, args: any[] } {
|
||||
if (value.source) {
|
||||
return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] };
|
||||
} else {
|
||||
return { str: "", args: [] };
|
||||
}
|
||||
function formatSource(value: any): { str: string; args: any[] } {
|
||||
if (value.source) {
|
||||
return { str: "%c(" + value.source + ") ", args: ["color: #FF00FF"] };
|
||||
} else {
|
||||
return { str: "", args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function formatLevel(value: any): ColoredString {
|
||||
const level = value.level as Level;
|
||||
if (levelColors.hasOwnProperty(level)) {
|
||||
return {
|
||||
str: "%c" + levels[level] + "%c",
|
||||
args: [levelColors[level], ""],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
str: levels.default,
|
||||
args: [levelColors.default],
|
||||
};
|
||||
}
|
||||
const level = value.level as Level;
|
||||
if (levelColors.hasOwnProperty(level)) {
|
||||
return {
|
||||
str: "%c" + levels[level] + "%c",
|
||||
args: [levelColors[level], ""]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
str: levels.default,
|
||||
args: [levelColors.default]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const logger: pino.Logger = pino({
|
||||
serializers: pino.stdSerializers,
|
||||
browser: { serialize: true, write },
|
||||
level: "trace",
|
||||
serializers: pino.stdSerializers,
|
||||
browser: { serialize: true, write },
|
||||
level: "trace"
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
@ -1,73 +1,81 @@
|
||||
import { computed, observable } from "mobx";
|
||||
|
||||
export class ConnectionState {
|
||||
/**
|
||||
* Represents if a client is connected to the sprinklers3 server (eg. via websocket)
|
||||
* Can be null if there is no client involved
|
||||
*/
|
||||
@observable clientToServer: boolean | null = null;
|
||||
/**
|
||||
* Represents if a client is connected to the sprinklers3 server (eg. via websocket)
|
||||
* Can be null if there is no client involved
|
||||
*/
|
||||
@observable
|
||||
clientToServer: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Represents if the sprinklers3 server is connected to the broker (eg. via mqtt)
|
||||
* Can be null if there is no broker involved
|
||||
*/
|
||||
@observable serverToBroker: boolean | null = null;
|
||||
/**
|
||||
* Represents if the sprinklers3 server is connected to the broker (eg. via mqtt)
|
||||
* Can be null if there is no broker involved
|
||||
*/
|
||||
@observable
|
||||
serverToBroker: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Represents if the device is connected to the broker and we can communicate with it (eg. via mqtt)
|
||||
* Can be null if there is no device involved
|
||||
*/
|
||||
@observable brokerToDevice: boolean | null = null;
|
||||
/**
|
||||
* Represents if the device is connected to the broker and we can communicate with it (eg. via mqtt)
|
||||
* Can be null if there is no device involved
|
||||
*/
|
||||
@observable
|
||||
brokerToDevice: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Represents if whoever is trying to access this device has permission to access it.
|
||||
* Is null if there is no concept of access involved.
|
||||
*/
|
||||
@observable hasPermission: boolean | null = null;
|
||||
/**
|
||||
* Represents if whoever is trying to access this device has permission to access it.
|
||||
* Is null if there is no concept of access involved.
|
||||
*/
|
||||
@observable
|
||||
hasPermission: boolean | null = null;
|
||||
|
||||
@computed get noPermission() {
|
||||
return this.hasPermission === false;
|
||||
@computed
|
||||
get noPermission() {
|
||||
return this.hasPermission === false;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isAvailable(): boolean {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@computed get isAvailable(): boolean {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.brokerToDevice != null) {
|
||||
return true;
|
||||
}
|
||||
if (this.serverToBroker != null) {
|
||||
return this.serverToBroker;
|
||||
}
|
||||
if (this.clientToServer != null) {
|
||||
return this.clientToServer;
|
||||
}
|
||||
return false;
|
||||
if (this.brokerToDevice != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@computed get isDeviceConnected(): boolean | null {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.serverToBroker === false || this.clientToServer === false) {
|
||||
return null;
|
||||
}
|
||||
if (this.brokerToDevice != null) {
|
||||
return this.brokerToDevice;
|
||||
}
|
||||
return null;
|
||||
if (this.serverToBroker != null) {
|
||||
return this.serverToBroker;
|
||||
}
|
||||
|
||||
@computed get isServerConnected(): boolean | null {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.serverToBroker != null) {
|
||||
return this.serverToBroker;
|
||||
}
|
||||
if (this.clientToServer != null) {
|
||||
return this.brokerToDevice;
|
||||
}
|
||||
return null;
|
||||
if (this.clientToServer != null) {
|
||||
return this.clientToServer;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDeviceConnected(): boolean | null {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.serverToBroker === false || this.clientToServer === false) {
|
||||
return null;
|
||||
}
|
||||
if (this.brokerToDevice != null) {
|
||||
return this.brokerToDevice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isServerConnected(): boolean | null {
|
||||
if (this.hasPermission === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.serverToBroker != null) {
|
||||
return this.serverToBroker;
|
||||
}
|
||||
if (this.clientToServer != null) {
|
||||
return this.brokerToDevice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -6,59 +6,69 @@ import * as schema from "./schema";
|
||||
import { SprinklersDevice } from "./SprinklersDevice";
|
||||
|
||||
export class ProgramItem {
|
||||
// the section number
|
||||
readonly section!: number;
|
||||
// duration of the run, in seconds
|
||||
readonly duration!: number;
|
||||
// the section number
|
||||
readonly section!: number;
|
||||
// duration of the run, in seconds
|
||||
readonly duration!: number;
|
||||
|
||||
constructor(data?: Partial<ProgramItem>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(data?: Partial<ProgramItem>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Program {
|
||||
readonly device: SprinklersDevice;
|
||||
readonly id: number;
|
||||
readonly device: SprinklersDevice;
|
||||
readonly id: number;
|
||||
|
||||
@observable name: string = "";
|
||||
@observable enabled: boolean = false;
|
||||
@observable schedule: Schedule = new Schedule();
|
||||
@observable.shallow sequence: ProgramItem[] = [];
|
||||
@observable running: boolean = false;
|
||||
@observable
|
||||
name: string = "";
|
||||
@observable
|
||||
enabled: boolean = false;
|
||||
@observable
|
||||
schedule: Schedule = new Schedule();
|
||||
@observable.shallow
|
||||
sequence: ProgramItem[] = [];
|
||||
@observable
|
||||
running: boolean = false;
|
||||
|
||||
constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) {
|
||||
this.device = device;
|
||||
this.id = id;
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(device: SprinklersDevice, id: number, data?: Partial<Program>) {
|
||||
this.device = device;
|
||||
this.id = id;
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
run() {
|
||||
return this.device.runProgram({ programId: this.id });
|
||||
}
|
||||
run() {
|
||||
return this.device.runProgram({ programId: this.id });
|
||||
}
|
||||
|
||||
cancel() {
|
||||
return this.device.cancelProgram({ programId: this.id });
|
||||
}
|
||||
cancel() {
|
||||
return this.device.cancelProgram({ programId: this.id });
|
||||
}
|
||||
|
||||
update() {
|
||||
const data = serialize(schema.program, this);
|
||||
return this.device.updateProgram({ programId: this.id, data });
|
||||
}
|
||||
update() {
|
||||
const data = serialize(schema.program, this);
|
||||
return this.device.updateProgram({ programId: this.id, data });
|
||||
}
|
||||
|
||||
clone(): Program {
|
||||
return new Program(this.device, this.id, {
|
||||
name: this.name, enabled: this.enabled, running: this.running,
|
||||
schedule: this.schedule.clone(),
|
||||
sequence: this.sequence.slice(),
|
||||
});
|
||||
}
|
||||
clone(): Program {
|
||||
return new Program(this.device, this.id, {
|
||||
name: this.name,
|
||||
enabled: this.enabled,
|
||||
running: this.running,
|
||||
schedule: this.schedule.clone(),
|
||||
sequence: this.sequence.slice()
|
||||
});
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Program{name="${this.name}", enabled=${this.enabled}, schedule=${this.schedule}, ` +
|
||||
`sequence=${this.sequence}, running=${this.running}}`;
|
||||
}
|
||||
toString(): string {
|
||||
return (
|
||||
`Program{name="${this.name}", enabled=${this.enabled}, schedule=${
|
||||
this.schedule
|
||||
}, ` + `sequence=${this.sequence}, running=${this.running}}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,20 +2,24 @@ import { ErrorCode } from "@common/ErrorCode";
|
||||
import { IError } from "./websocketData";
|
||||
|
||||
export class RpcError extends Error implements IError {
|
||||
name = "RpcError";
|
||||
code: number;
|
||||
data: any;
|
||||
name = "RpcError";
|
||||
code: number;
|
||||
data: any;
|
||||
|
||||
constructor(message: string, code: number = ErrorCode.BadRequest, data: any = {}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
if (data instanceof Error) {
|
||||
this.data = data.toString();
|
||||
}
|
||||
this.data = data;
|
||||
constructor(
|
||||
message: string,
|
||||
code: number = ErrorCode.BadRequest,
|
||||
data: any = {}
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
if (data instanceof Error) {
|
||||
this.data = data.toString();
|
||||
}
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
toJSON(): IError {
|
||||
return { code: this.code, message: this.message, data: this.data };
|
||||
}
|
||||
toJSON(): IError {
|
||||
return { code: this.code, message: this.message, data: this.data };
|
||||
}
|
||||
}
|
||||
|
@ -2,27 +2,29 @@ import { observable } from "mobx";
|
||||
import { SprinklersDevice } from "./SprinklersDevice";
|
||||
|
||||
export class Section {
|
||||
readonly device: SprinklersDevice;
|
||||
readonly id: number;
|
||||
readonly device: SprinklersDevice;
|
||||
readonly id: number;
|
||||
|
||||
@observable name: string = "";
|
||||
@observable state: boolean = false;
|
||||
@observable
|
||||
name: string = "";
|
||||
@observable
|
||||
state: boolean = false;
|
||||
|
||||
constructor(device: SprinklersDevice, id: number) {
|
||||
this.device = device;
|
||||
this.id = id;
|
||||
}
|
||||
constructor(device: SprinklersDevice, id: number) {
|
||||
this.device = device;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** duration is in seconds */
|
||||
run(duration: number) {
|
||||
return this.device.runSection({ sectionId: this.id, duration });
|
||||
}
|
||||
/** duration is in seconds */
|
||||
run(duration: number) {
|
||||
return this.device.runSection({ sectionId: this.id, duration });
|
||||
}
|
||||
|
||||
cancel() {
|
||||
return this.device.cancelSection({ sectionId: this.id });
|
||||
}
|
||||
cancel() {
|
||||
return this.device.cancelSection({ sectionId: this.id });
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Section ${this.id}: '${this.name}'`;
|
||||
}
|
||||
toString(): string {
|
||||
return `Section ${this.id}: '${this.name}'`;
|
||||
}
|
||||
}
|
||||
|
@ -2,57 +2,69 @@ import { observable } from "mobx";
|
||||
import { SprinklersDevice } from "./SprinklersDevice";
|
||||
|
||||
export class SectionRun {
|
||||
readonly sectionRunner: SectionRunner;
|
||||
readonly id: number;
|
||||
section: number;
|
||||
totalDuration: number = 0;
|
||||
duration: number = 0;
|
||||
startTime: Date | null = null;
|
||||
pauseTime: Date | null = null;
|
||||
unpauseTime: Date | null = null;
|
||||
readonly sectionRunner: SectionRunner;
|
||||
readonly id: number;
|
||||
section: number;
|
||||
totalDuration: number = 0;
|
||||
duration: number = 0;
|
||||
startTime: Date | null = null;
|
||||
pauseTime: Date | null = null;
|
||||
unpauseTime: Date | null = null;
|
||||
|
||||
constructor(sectionRunner: SectionRunner, id: number = 0, section: number = 0) {
|
||||
this.sectionRunner = sectionRunner;
|
||||
this.id = id;
|
||||
this.section = section;
|
||||
}
|
||||
constructor(
|
||||
sectionRunner: SectionRunner,
|
||||
id: number = 0,
|
||||
section: number = 0
|
||||
) {
|
||||
this.sectionRunner = sectionRunner;
|
||||
this.id = id;
|
||||
this.section = section;
|
||||
}
|
||||
|
||||
cancel = () => this.sectionRunner.cancelRunById(this.id);
|
||||
cancel = () => this.sectionRunner.cancelRunById(this.id);
|
||||
|
||||
toString() {
|
||||
return `SectionRun{id=${this.id}, section=${this.section}, duration=${this.duration},` +
|
||||
` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`;
|
||||
}
|
||||
toString() {
|
||||
return (
|
||||
`SectionRun{id=${this.id}, section=${this.section}, duration=${
|
||||
this.duration
|
||||
},` + ` startTime=${this.startTime}, pauseTime=${this.pauseTime}}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SectionRunner {
|
||||
readonly device: SprinklersDevice;
|
||||
readonly device: SprinklersDevice;
|
||||
|
||||
@observable queue: SectionRun[] = [];
|
||||
@observable current: SectionRun | null = null;
|
||||
@observable paused: boolean = false;
|
||||
@observable
|
||||
queue: SectionRun[] = [];
|
||||
@observable
|
||||
current: SectionRun | null = null;
|
||||
@observable
|
||||
paused: boolean = false;
|
||||
|
||||
constructor(device: SprinklersDevice) {
|
||||
this.device = device;
|
||||
}
|
||||
constructor(device: SprinklersDevice) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
cancelRunById(runId: number) {
|
||||
return this.device.cancelSectionRunId({ runId });
|
||||
}
|
||||
cancelRunById(runId: number) {
|
||||
return this.device.cancelSectionRunId({ runId });
|
||||
}
|
||||
|
||||
setPaused(paused: boolean) {
|
||||
return this.device.pauseSectionRunner({ paused });
|
||||
}
|
||||
setPaused(paused: boolean) {
|
||||
return this.device.pauseSectionRunner({ paused });
|
||||
}
|
||||
|
||||
pause() {
|
||||
return this.setPaused(true);
|
||||
}
|
||||
pause() {
|
||||
return this.setPaused(true);
|
||||
}
|
||||
|
||||
unpause() {
|
||||
return this.setPaused(false);
|
||||
}
|
||||
unpause() {
|
||||
return this.setPaused(false);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `SectionRunner{queue="${this.queue}", current="${this.current}", paused=${this.paused}}`;
|
||||
}
|
||||
toString(): string {
|
||||
return `SectionRunner{queue="${this.queue}", current="${
|
||||
this.current
|
||||
}", paused=${this.paused}}`;
|
||||
}
|
||||
}
|
||||
|
@ -7,85 +7,94 @@ import { SectionRunner } from "./SectionRunner";
|
||||
import { SprinklersRPC } from "./SprinklersRPC";
|
||||
|
||||
export abstract class SprinklersDevice {
|
||||
readonly rpc: SprinklersRPC;
|
||||
readonly id: string;
|
||||
readonly rpc: SprinklersRPC;
|
||||
readonly id: string;
|
||||
|
||||
@observable connectionState: ConnectionState = new ConnectionState();
|
||||
@observable sections: Section[] = [];
|
||||
@observable programs: Program[] = [];
|
||||
@observable sectionRunner: SectionRunner;
|
||||
@observable
|
||||
connectionState: ConnectionState = new ConnectionState();
|
||||
@observable
|
||||
sections: Section[] = [];
|
||||
@observable
|
||||
programs: Program[] = [];
|
||||
@observable
|
||||
sectionRunner: SectionRunner;
|
||||
|
||||
@computed get connected(): boolean {
|
||||
return this.connectionState.isDeviceConnected || false;
|
||||
@computed
|
||||
get connected(): boolean {
|
||||
return this.connectionState.isDeviceConnected || false;
|
||||
}
|
||||
|
||||
sectionConstructor: typeof Section = Section;
|
||||
sectionRunnerConstructor: typeof SectionRunner = SectionRunner;
|
||||
programConstructor: typeof Program = Program;
|
||||
|
||||
private references: number = 0;
|
||||
|
||||
protected constructor(rpc: SprinklersRPC, id: string) {
|
||||
this.rpc = rpc;
|
||||
this.id = id;
|
||||
this.sectionRunner = new this.sectionRunnerConstructor(this);
|
||||
}
|
||||
|
||||
abstract makeRequest(request: req.Request): Promise<req.Response>;
|
||||
|
||||
/**
|
||||
* Increase the reference count for this sprinklers device
|
||||
* @returns The new reference count
|
||||
*/
|
||||
acquire(): number {
|
||||
return ++this.references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases one reference to this device. When the reference count reaches 0, the device
|
||||
* will be released and no longer updated.
|
||||
* @returns The reference count after being updated
|
||||
*/
|
||||
release(): number {
|
||||
this.references--;
|
||||
if (this.references <= 0) {
|
||||
this.rpc.releaseDevice(this.id);
|
||||
}
|
||||
return this.references;
|
||||
}
|
||||
|
||||
sectionConstructor: typeof Section = Section;
|
||||
sectionRunnerConstructor: typeof SectionRunner = SectionRunner;
|
||||
programConstructor: typeof Program = Program;
|
||||
runProgram(opts: req.WithProgram) {
|
||||
return this.makeRequest({ ...opts, type: "runProgram" });
|
||||
}
|
||||
|
||||
private references: number = 0;
|
||||
cancelProgram(opts: req.WithProgram) {
|
||||
return this.makeRequest({ ...opts, type: "cancelProgram" });
|
||||
}
|
||||
|
||||
protected constructor(rpc: SprinklersRPC, id: string) {
|
||||
this.rpc = rpc;
|
||||
this.id = id;
|
||||
this.sectionRunner = new (this.sectionRunnerConstructor)(this);
|
||||
}
|
||||
updateProgram(
|
||||
opts: req.UpdateProgramData
|
||||
): Promise<req.UpdateProgramResponse> {
|
||||
return this.makeRequest({ ...opts, type: "updateProgram" }) as Promise<any>;
|
||||
}
|
||||
|
||||
abstract makeRequest(request: req.Request): Promise<req.Response>;
|
||||
runSection(opts: req.RunSectionData): Promise<req.RunSectionResponse> {
|
||||
return this.makeRequest({ ...opts, type: "runSection" }) as Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase the reference count for this sprinklers device
|
||||
* @returns The new reference count
|
||||
*/
|
||||
acquire(): number {
|
||||
return ++this.references;
|
||||
}
|
||||
cancelSection(opts: req.WithSection) {
|
||||
return this.makeRequest({ ...opts, type: "cancelSection" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases one reference to this device. When the reference count reaches 0, the device
|
||||
* will be released and no longer updated.
|
||||
* @returns The reference count after being updated
|
||||
*/
|
||||
release(): number {
|
||||
this.references--;
|
||||
if (this.references <= 0) {
|
||||
this.rpc.releaseDevice(this.id);
|
||||
}
|
||||
return this.references;
|
||||
}
|
||||
cancelSectionRunId(opts: req.CancelSectionRunIdData) {
|
||||
return this.makeRequest({ ...opts, type: "cancelSectionRunId" });
|
||||
}
|
||||
|
||||
runProgram(opts: req.WithProgram) {
|
||||
return this.makeRequest({ ...opts, type: "runProgram" });
|
||||
}
|
||||
pauseSectionRunner(opts: req.PauseSectionRunnerData) {
|
||||
return this.makeRequest({ ...opts, type: "pauseSectionRunner" });
|
||||
}
|
||||
|
||||
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} }`;
|
||||
}
|
||||
toString(): string {
|
||||
return (
|
||||
`SprinklersDevice{id="${this.id}", connected=${this.connected}, ` +
|
||||
`sections=[${this.sections}], ` +
|
||||
`programs=[${this.programs}], ` +
|
||||
`sectionRunner=${this.sectionRunner} }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,30 +2,30 @@ import { ConnectionState } from "./ConnectionState";
|
||||
import { SprinklersDevice } from "./SprinklersDevice";
|
||||
|
||||
export abstract class SprinklersRPC {
|
||||
abstract readonly connectionState: ConnectionState;
|
||||
abstract readonly connected: boolean;
|
||||
abstract readonly connectionState: ConnectionState;
|
||||
abstract readonly connected: boolean;
|
||||
|
||||
abstract start(): void;
|
||||
abstract start(): void;
|
||||
|
||||
/**
|
||||
* Acquires a reference to a device. This reference must be released by calling
|
||||
* SprinklersDevice#release for every time this method was called
|
||||
* @param id The id of the device
|
||||
*/
|
||||
acquireDevice(id: string): SprinklersDevice {
|
||||
const device = this.getDevice(id);
|
||||
device.acquire();
|
||||
return device;
|
||||
}
|
||||
/**
|
||||
* Acquires a reference to a device. This reference must be released by calling
|
||||
* SprinklersDevice#release for every time this method was called
|
||||
* @param id The id of the device
|
||||
*/
|
||||
acquireDevice(id: string): SprinklersDevice {
|
||||
const device = this.getDevice(id);
|
||||
device.acquire();
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a device to be released. The device will no longer be updated.
|
||||
*
|
||||
* This should not be used normally, instead SprinklersDevice#release should be called to manage
|
||||
* each reference to a device.
|
||||
* @param id The id of the device to remove
|
||||
*/
|
||||
abstract releaseDevice(id: string): void;
|
||||
/**
|
||||
* Forces a device to be released. The device will no longer be updated.
|
||||
*
|
||||
* This should not be used normally, instead SprinklersDevice#release should be called to manage
|
||||
* each reference to a device.
|
||||
* @param id The id of the device to remove
|
||||
*/
|
||||
abstract releaseDevice(id: string): void;
|
||||
|
||||
protected abstract getDevice(id: string): SprinklersDevice;
|
||||
protected abstract getDevice(id: string): SprinklersDevice;
|
||||
}
|
||||
|
@ -1,17 +1,22 @@
|
||||
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 CancelProgramRequest = WithProgram & WithType<"cancelProgram">;
|
||||
|
||||
export type UpdateProgramData = WithProgram & { data: any };
|
||||
export type UpdateProgramRequest = UpdateProgramData & WithType<"updateProgram">;
|
||||
export type UpdateProgramRequest = UpdateProgramData &
|
||||
WithType<"updateProgram">;
|
||||
export type UpdateProgramResponse = Response<"updateProgram", { data: any }>;
|
||||
|
||||
export interface WithSection { sectionId: number; }
|
||||
export interface WithSection {
|
||||
sectionId: number;
|
||||
}
|
||||
|
||||
export type RunSectionData = WithSection & { duration: number };
|
||||
export type RunSectionRequest = RunSectionData & WithType<"runSection">;
|
||||
@ -19,30 +24,44 @@ export type RunSectionResponse = Response<"runSection", { runId: number }>;
|
||||
|
||||
export type CancelSectionRequest = WithSection & WithType<"cancelSection">;
|
||||
|
||||
export interface CancelSectionRunIdData { runId: number; }
|
||||
export type CancelSectionRunIdRequest = CancelSectionRunIdData & WithType<"cancelSectionRunId">;
|
||||
export interface CancelSectionRunIdData {
|
||||
runId: number;
|
||||
}
|
||||
export type CancelSectionRunIdRequest = CancelSectionRunIdData &
|
||||
WithType<"cancelSectionRunId">;
|
||||
|
||||
export interface PauseSectionRunnerData { paused: boolean; }
|
||||
export type PauseSectionRunnerRequest = PauseSectionRunnerData & WithType<"pauseSectionRunner">;
|
||||
export interface PauseSectionRunnerData {
|
||||
paused: boolean;
|
||||
}
|
||||
export type PauseSectionRunnerRequest = PauseSectionRunnerData &
|
||||
WithType<"pauseSectionRunner">;
|
||||
|
||||
export type Request = RunProgramRequest | CancelProgramRequest | UpdateProgramRequest |
|
||||
RunSectionRequest | CancelSectionRequest | CancelSectionRunIdRequest | PauseSectionRunnerRequest;
|
||||
export type Request =
|
||||
| RunProgramRequest
|
||||
| CancelProgramRequest
|
||||
| UpdateProgramRequest
|
||||
| RunSectionRequest
|
||||
| CancelSectionRequest
|
||||
| CancelSectionRunIdRequest
|
||||
| PauseSectionRunnerRequest;
|
||||
|
||||
export type RequestType = Request["type"];
|
||||
|
||||
export interface SuccessResponseData<Type extends string = string> extends WithType<Type> {
|
||||
result: "success";
|
||||
message: string;
|
||||
export interface SuccessResponseData<Type extends string = string>
|
||||
extends WithType<Type> {
|
||||
result: "success";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponseData<Type extends string = string> extends WithType<Type> {
|
||||
result: "error";
|
||||
message: string;
|
||||
code: number;
|
||||
name?: string;
|
||||
cause?: any;
|
||||
export interface ErrorResponseData<Type extends string = string>
|
||||
extends WithType<Type> {
|
||||
result: "error";
|
||||
message: string;
|
||||
code: number;
|
||||
name?: string;
|
||||
cause?: any;
|
||||
}
|
||||
|
||||
export type Response<Type extends string = string, Res = {}> =
|
||||
(SuccessResponseData<Type> & Res) |
|
||||
(ErrorResponseData<Type>);
|
||||
| (SuccessResponseData<Type> & Res)
|
||||
| (ErrorResponseData<Type>);
|
||||
|
@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc";
|
||||
import * as schema from "@common/sprinklersRpc/schema";
|
||||
|
||||
export class MqttProgram extends s.Program {
|
||||
onMessage(payload: string, topic: string | undefined) {
|
||||
if (topic === "running") {
|
||||
this.running = (payload === "true");
|
||||
} else if (topic == null) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
onMessage(payload: string, topic: string | undefined) {
|
||||
if (topic === "running") {
|
||||
this.running = payload === "true";
|
||||
} else if (topic == null) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
}
|
||||
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.program, this, json);
|
||||
}
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.program, this, json);
|
||||
}
|
||||
}
|
||||
|
@ -4,15 +4,15 @@ import * as s from "@common/sprinklersRpc";
|
||||
import * as schema from "@common/sprinklersRpc/schema";
|
||||
|
||||
export class MqttSection extends s.Section {
|
||||
onMessage(payload: string, topic: string | undefined) {
|
||||
if (topic === "state") {
|
||||
this.state = (payload === "true");
|
||||
} else if (topic == null) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
onMessage(payload: string, topic: string | undefined) {
|
||||
if (topic === "state") {
|
||||
this.state = payload === "true";
|
||||
} else if (topic == null) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
}
|
||||
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.section, this, json);
|
||||
}
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.section, this, json);
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import * as s from "@common/sprinklersRpc";
|
||||
import * as schema from "@common/sprinklersRpc/schema";
|
||||
|
||||
export class MqttSectionRunner extends s.SectionRunner {
|
||||
onMessage(payload: string) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
onMessage(payload: string) {
|
||||
this.updateFromJSON(JSON.parse(payload));
|
||||
}
|
||||
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.sectionRunner, this, json);
|
||||
}
|
||||
updateFromJSON(json: any) {
|
||||
update(schema.sectionRunner, this, json);
|
||||
}
|
||||
}
|
||||
|
@ -16,296 +16,337 @@ import { MqttSectionRunner } from "./MqttSectionRunner";
|
||||
const log = logger.child({ source: "mqtt" });
|
||||
|
||||
interface WithRid {
|
||||
rid: number;
|
||||
rid: number;
|
||||
}
|
||||
|
||||
export const DEVICE_PREFIX = "devices";
|
||||
const REQUEST_TIMEOUT = 5000;
|
||||
|
||||
export interface MqttRpcClientOptions {
|
||||
mqttUri: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
mqttUri: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class MqttRpcClient extends s.SprinklersRPC implements MqttRpcClientOptions {
|
||||
get connected(): boolean {
|
||||
return this.connectionState.isServerConnected || false;
|
||||
export class MqttRpcClient extends s.SprinklersRPC
|
||||
implements MqttRpcClientOptions {
|
||||
get connected(): boolean {
|
||||
return this.connectionState.isServerConnected || false;
|
||||
}
|
||||
|
||||
private static newClientId() {
|
||||
return "sprinklers3-MqttApiClient-" + getRandomId();
|
||||
}
|
||||
|
||||
mqttUri!: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
client!: mqtt.Client;
|
||||
@observable
|
||||
connectionState: s.ConnectionState = new s.ConnectionState();
|
||||
devices: Map<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() {
|
||||
return "sprinklers3-MqttApiClient-" + getRandomId();
|
||||
protected getDevice(id: string): s.SprinklersDevice {
|
||||
if (/\//.test(id)) {
|
||||
throw new Error("Device id cannot contain a /");
|
||||
}
|
||||
|
||||
mqttUri!: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
client!: mqtt.Client;
|
||||
@observable connectionState: s.ConnectionState = new s.ConnectionState();
|
||||
devices: Map<string, MqttSprinklersDevice> = new Map();
|
||||
|
||||
constructor(opts: MqttRpcClientOptions) {
|
||||
super();
|
||||
Object.assign(this, opts);
|
||||
this.connectionState.serverToBroker = false;
|
||||
let device = this.devices.get(id);
|
||||
if (!device) {
|
||||
this.devices.set(id, (device = new MqttSprinklersDevice(this, id)));
|
||||
if (this.connected) {
|
||||
device.doSubscribe();
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
private onMessageArrived(
|
||||
topic: string,
|
||||
payload: Buffer,
|
||||
packet: mqtt.Packet
|
||||
) {
|
||||
try {
|
||||
this.processMessage(topic, payload, packet);
|
||||
} catch (err) {
|
||||
log.error({ err }, "error while processing mqtt message");
|
||||
}
|
||||
}
|
||||
|
||||
releaseDevice(id: string) {
|
||||
const device = this.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
device.doUnsubscribe();
|
||||
this.devices.delete(id);
|
||||
private processMessage(
|
||||
topic: string,
|
||||
payloadBuf: Buffer,
|
||||
packet: mqtt.Packet
|
||||
) {
|
||||
const payload = payloadBuf.toString("utf8");
|
||||
log.trace({ topic, payload }, "message arrived: ");
|
||||
const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`);
|
||||
const matches = regexp.exec(topic);
|
||||
if (!matches) {
|
||||
return log.warn({ topic }, "received message on invalid topic");
|
||||
}
|
||||
|
||||
protected getDevice(id: string): s.SprinklersDevice {
|
||||
if (/\//.test(id)) {
|
||||
throw new Error("Device id cannot contain a /");
|
||||
}
|
||||
let device = this.devices.get(id);
|
||||
if (!device) {
|
||||
this.devices.set(id, device = new MqttSprinklersDevice(this, id));
|
||||
if (this.connected) {
|
||||
device.doSubscribe();
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
private onMessageArrived(topic: string, payload: Buffer, packet: mqtt.Packet) {
|
||||
try {
|
||||
this.processMessage(topic, payload, packet);
|
||||
} catch (err) {
|
||||
log.error({ err }, "error while processing mqtt message");
|
||||
}
|
||||
}
|
||||
|
||||
private processMessage(topic: string, payloadBuf: Buffer, packet: mqtt.Packet) {
|
||||
const payload = payloadBuf.toString("utf8");
|
||||
log.trace({ topic, payload }, "message arrived: ");
|
||||
const regexp = new RegExp(`^${DEVICE_PREFIX}\\/([^\\/]+)\\/?(.*)$`);
|
||||
const matches = regexp.exec(topic);
|
||||
if (!matches) {
|
||||
return log.warn({ topic }, "received message on invalid topic");
|
||||
}
|
||||
const id = matches[1];
|
||||
const topicSuffix = matches[2];
|
||||
const device = this.devices.get(id);
|
||||
if (!device) {
|
||||
log.debug({ id }, "received message for unknown device");
|
||||
return;
|
||||
}
|
||||
device.onMessage(topicSuffix, payload);
|
||||
const id = matches[1];
|
||||
const topicSuffix = matches[2];
|
||||
const device = this.devices.get(id);
|
||||
if (!device) {
|
||||
log.debug({ id }, "received message for unknown device");
|
||||
return;
|
||||
}
|
||||
device.onMessage(topicSuffix, payload);
|
||||
}
|
||||
}
|
||||
|
||||
type ResponseCallback = (response: requests.Response) => void;
|
||||
|
||||
const subscriptions = [
|
||||
"/connected",
|
||||
"/sections",
|
||||
"/sections/+/#",
|
||||
"/programs",
|
||||
"/programs/+/#",
|
||||
"/responses",
|
||||
"/section_runner",
|
||||
"/connected",
|
||||
"/sections",
|
||||
"/sections/+/#",
|
||||
"/programs",
|
||||
"/programs/+/#",
|
||||
"/responses",
|
||||
"/section_runner"
|
||||
];
|
||||
|
||||
type IHandler = (payload: any, ...matches: string[]) => void;
|
||||
|
||||
interface IHandlerEntry {
|
||||
test: RegExp;
|
||||
handler: IHandler;
|
||||
test: RegExp;
|
||||
handler: IHandler;
|
||||
}
|
||||
|
||||
const handler = (test: RegExp) =>
|
||||
(target: MqttSprinklersDevice, propertyKey: string, descriptor: TypedPropertyDescriptor<IHandler>) => {
|
||||
if (typeof descriptor.value === "function") {
|
||||
const entry = {
|
||||
test, handler: descriptor.value,
|
||||
};
|
||||
(target.handlers || (target.handlers = [])).push(entry);
|
||||
}
|
||||
const handler = (test: RegExp) => (
|
||||
target: MqttSprinklersDevice,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<IHandler>
|
||||
) => {
|
||||
if (typeof descriptor.value === "function") {
|
||||
const entry = {
|
||||
test,
|
||||
handler: descriptor.value
|
||||
};
|
||||
(target.handlers || (target.handlers = [])).push(entry);
|
||||
}
|
||||
};
|
||||
|
||||
class MqttSprinklersDevice extends s.SprinklersDevice {
|
||||
readonly apiClient: MqttRpcClient;
|
||||
readonly apiClient: MqttRpcClient;
|
||||
|
||||
handlers!: IHandlerEntry[];
|
||||
private subscriptions: string[];
|
||||
private nextRequestId: number = Math.floor(Math.random() * 1000000000);
|
||||
private responseCallbacks: Map<number, ResponseCallback> = new Map();
|
||||
handlers!: IHandlerEntry[];
|
||||
private subscriptions: string[];
|
||||
private nextRequestId: number = Math.floor(Math.random() * 1000000000);
|
||||
private responseCallbacks: Map<number, ResponseCallback> = new Map();
|
||||
|
||||
constructor(apiClient: MqttRpcClient, id: string) {
|
||||
super(apiClient, id);
|
||||
this.sectionConstructor = MqttSection;
|
||||
this.sectionRunnerConstructor = MqttSectionRunner;
|
||||
this.programConstructor = MqttProgram;
|
||||
this.apiClient = apiClient;
|
||||
this.sectionRunner = new MqttSectionRunner(this);
|
||||
this.subscriptions = subscriptions.map((filter) => this.prefix + filter);
|
||||
constructor(apiClient: MqttRpcClient, id: string) {
|
||||
super(apiClient, id);
|
||||
this.sectionConstructor = MqttSection;
|
||||
this.sectionRunnerConstructor = MqttSectionRunner;
|
||||
this.programConstructor = MqttProgram;
|
||||
this.apiClient = apiClient;
|
||||
this.sectionRunner = new MqttSectionRunner(this);
|
||||
this.subscriptions = subscriptions.map(filter => this.prefix + filter);
|
||||
|
||||
autorun(() => {
|
||||
const brokerConnected = apiClient.connected;
|
||||
this.connectionState.serverToBroker = brokerConnected;
|
||||
if (brokerConnected) {
|
||||
if (this.connectionState.brokerToDevice == null) {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
}
|
||||
this.doSubscribe();
|
||||
} else {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get prefix(): string {
|
||||
return DEVICE_PREFIX + "/" + this.id;
|
||||
}
|
||||
|
||||
doSubscribe() {
|
||||
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
log.error({ err, id: this.id }, "error subscribing to device");
|
||||
} else {
|
||||
log.debug({ id: this.id }, "subscribed to device");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doUnsubscribe() {
|
||||
this.apiClient.client.unsubscribe(this.subscriptions, (err) => {
|
||||
if (err) {
|
||||
log.error({ err, id: this.id }, "error unsubscribing to device");
|
||||
} else {
|
||||
log.debug({ id: this.id }, "unsubscribed to device");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(topic: string, payload: string) {
|
||||
for (const { test, handler: hndlr } of this.handlers) {
|
||||
const matches = topic.match(test);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
matches.shift();
|
||||
hndlr.call(this, payload, ...matches);
|
||||
return;
|
||||
autorun(() => {
|
||||
const brokerConnected = apiClient.connected;
|
||||
this.connectionState.serverToBroker = brokerConnected;
|
||||
if (brokerConnected) {
|
||||
if (this.connectionState.brokerToDevice == null) {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
}
|
||||
log.warn({ topic }, "MqttSprinklersDevice recieved message on invalid topic");
|
||||
this.doSubscribe();
|
||||
} else {
|
||||
this.connectionState.brokerToDevice = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get prefix(): string {
|
||||
return DEVICE_PREFIX + "/" + this.id;
|
||||
}
|
||||
|
||||
doSubscribe() {
|
||||
this.apiClient.client.subscribe(this.subscriptions, { qos: 1 }, err => {
|
||||
if (err) {
|
||||
log.error({ err, id: this.id }, "error subscribing to device");
|
||||
} else {
|
||||
log.debug({ id: this.id }, "subscribed to device");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doUnsubscribe() {
|
||||
this.apiClient.client.unsubscribe(this.subscriptions, err => {
|
||||
if (err) {
|
||||
log.error({ err, id: this.id }, "error unsubscribing to device");
|
||||
} else {
|
||||
log.debug({ id: this.id }, "unsubscribed to device");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(topic: string, payload: string) {
|
||||
for (const { test, handler: hndlr } of this.handlers) {
|
||||
const matches = topic.match(test);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
matches.shift();
|
||||
hndlr.call(this, payload, ...matches);
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
{ topic },
|
||||
"MqttSprinklersDevice recieved message on invalid topic"
|
||||
);
|
||||
}
|
||||
|
||||
makeRequest(request: requests.Request): Promise<requests.Response> {
|
||||
return new Promise<requests.Response>((resolve, reject) => {
|
||||
const topic = this.prefix + "/requests";
|
||||
const json = seralizeRequest(request);
|
||||
const requestId = json.rid = this.getRequestId();
|
||||
const payloadStr = JSON.stringify(json);
|
||||
makeRequest(request: requests.Request): Promise<requests.Response> {
|
||||
return new Promise<requests.Response>((resolve, reject) => {
|
||||
const topic = this.prefix + "/requests";
|
||||
const json = seralizeRequest(request);
|
||||
const requestId = (json.rid = this.getRequestId());
|
||||
const payloadStr = JSON.stringify(json);
|
||||
|
||||
let timeoutHandle: any;
|
||||
const callback: ResponseCallback = (data) => {
|
||||
if (data.result === "error") {
|
||||
reject(new RpcError(data.message, data.code, data));
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
this.responseCallbacks.delete(requestId);
|
||||
clearTimeout(timeoutHandle);
|
||||
};
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new RpcError("the request has timed out", ErrorCode.Timeout));
|
||||
this.responseCallbacks.delete(requestId);
|
||||
clearTimeout(timeoutHandle);
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
this.responseCallbacks.set(requestId, callback);
|
||||
this.apiClient.client.publish(topic, payloadStr, { qos: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
private getRequestId(): number {
|
||||
return this.nextRequestId++;
|
||||
}
|
||||
|
||||
/* tslint:disable:no-unused-variable */
|
||||
@handler(/^connected$/)
|
||||
private handleConnected(payload: string) {
|
||||
this.connectionState.brokerToDevice = (payload === "true");
|
||||
log.trace(`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/)
|
||||
private handleSectionsUpdate(payload: string, secNumStr?: string, subTopic?: string) {
|
||||
log.trace({ section: secNumStr, topic: subTopic, payload }, "handling section update");
|
||||
if (!secNumStr) { // new number of sections
|
||||
this.sections.length = Number(payload);
|
||||
let timeoutHandle: any;
|
||||
const callback: ResponseCallback = data => {
|
||||
if (data.result === "error") {
|
||||
reject(new RpcError(data.message, data.code, data));
|
||||
} else {
|
||||
const secNum = Number(secNumStr);
|
||||
let section = this.sections[secNum];
|
||||
if (!section) {
|
||||
this.sections[secNum] = section = new MqttSection(this, secNum);
|
||||
}
|
||||
(section as MqttSection).onMessage(payload, subTopic);
|
||||
resolve(data);
|
||||
}
|
||||
}
|
||||
this.responseCallbacks.delete(requestId);
|
||||
clearTimeout(timeoutHandle);
|
||||
};
|
||||
|
||||
@handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/)
|
||||
private handleProgramsUpdate(payload: string, progNumStr?: string, subTopic?: string) {
|
||||
log.trace({ program: progNumStr, topic: subTopic, payload }, "handling program update");
|
||||
if (!progNumStr) { // new number of programs
|
||||
this.programs.length = Number(payload);
|
||||
} else {
|
||||
const progNum = Number(progNumStr);
|
||||
let program = this.programs[progNum];
|
||||
if (!program) {
|
||||
this.programs[progNum] = program = new MqttProgram(this, progNum);
|
||||
}
|
||||
(program as MqttProgram).onMessage(payload, subTopic);
|
||||
}
|
||||
}
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new RpcError("the request has timed out", ErrorCode.Timeout));
|
||||
this.responseCallbacks.delete(requestId);
|
||||
clearTimeout(timeoutHandle);
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
@handler(/^section_runner$/)
|
||||
private handleSectionRunnerUpdate(payload: string) {
|
||||
(this.sectionRunner as MqttSectionRunner).onMessage(payload);
|
||||
}
|
||||
this.responseCallbacks.set(requestId, callback);
|
||||
this.apiClient.client.publish(topic, payloadStr, { qos: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
@handler(/^responses$/)
|
||||
private handleResponse(payload: string) {
|
||||
const data = JSON.parse(payload) as requests.Response & WithRid;
|
||||
log.trace({ rid: data.rid }, "handling request response");
|
||||
const cb = this.responseCallbacks.get(data.rid);
|
||||
if (typeof cb === "function") {
|
||||
delete data.rid;
|
||||
cb(data);
|
||||
}
|
||||
}
|
||||
private getRequestId(): number {
|
||||
return this.nextRequestId++;
|
||||
}
|
||||
|
||||
/* tslint:enable:no-unused-variable */
|
||||
/* tslint:disable:no-unused-variable */
|
||||
@handler(/^connected$/)
|
||||
private handleConnected(payload: string) {
|
||||
this.connectionState.brokerToDevice = payload === "true";
|
||||
log.trace(
|
||||
`MqttSprinklersDevice with prefix ${this.prefix}: ${this.connected}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@handler(/^sections(?:\/(\d+)(?:\/?(.+))?)?$/)
|
||||
private handleSectionsUpdate(
|
||||
payload: string,
|
||||
secNumStr?: string,
|
||||
subTopic?: string
|
||||
) {
|
||||
log.trace(
|
||||
{ section: secNumStr, topic: subTopic, payload },
|
||||
"handling section update"
|
||||
);
|
||||
if (!secNumStr) {
|
||||
// new number of sections
|
||||
this.sections.length = Number(payload);
|
||||
} else {
|
||||
const secNum = Number(secNumStr);
|
||||
let section = this.sections[secNum];
|
||||
if (!section) {
|
||||
this.sections[secNum] = section = new MqttSection(this, secNum);
|
||||
}
|
||||
(section as MqttSection).onMessage(payload, subTopic);
|
||||
}
|
||||
}
|
||||
|
||||
@handler(/^programs(?:\/(\d+)(?:\/?(.+))?)?$/)
|
||||
private handleProgramsUpdate(
|
||||
payload: string,
|
||||
progNumStr?: string,
|
||||
subTopic?: string
|
||||
) {
|
||||
log.trace(
|
||||
{ program: progNumStr, topic: subTopic, payload },
|
||||
"handling program update"
|
||||
);
|
||||
if (!progNumStr) {
|
||||
// new number of programs
|
||||
this.programs.length = Number(payload);
|
||||
} else {
|
||||
const progNum = Number(progNumStr);
|
||||
let program = this.programs[progNum];
|
||||
if (!program) {
|
||||
this.programs[progNum] = program = new MqttProgram(this, progNum);
|
||||
}
|
||||
(program as MqttProgram).onMessage(payload, subTopic);
|
||||
}
|
||||
}
|
||||
|
||||
@handler(/^section_runner$/)
|
||||
private handleSectionRunnerUpdate(payload: string) {
|
||||
(this.sectionRunner as MqttSectionRunner).onMessage(payload);
|
||||
}
|
||||
|
||||
@handler(/^responses$/)
|
||||
private handleResponse(payload: string) {
|
||||
const data = JSON.parse(payload) as requests.Response & WithRid;
|
||||
log.trace({ rid: data.rid }, "handling request response");
|
||||
const cb = this.responseCallbacks.get(data.rid);
|
||||
if (typeof cb === "function") {
|
||||
delete data.rid;
|
||||
cb(data);
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:enable:no-unused-variable */
|
||||
}
|
||||
|
@ -2,101 +2,140 @@ import { observable } from "mobx";
|
||||
import { Moment } from "moment";
|
||||
|
||||
export class TimeOfDay {
|
||||
static fromMoment(m: Moment): TimeOfDay {
|
||||
return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond());
|
||||
}
|
||||
static fromMoment(m: Moment): TimeOfDay {
|
||||
return new TimeOfDay(m.hour(), m.minute(), m.second(), m.millisecond());
|
||||
}
|
||||
|
||||
static fromDate(date: Date): TimeOfDay {
|
||||
return new TimeOfDay(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
|
||||
}
|
||||
static fromDate(date: Date): TimeOfDay {
|
||||
return new TimeOfDay(
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds(),
|
||||
date.getMilliseconds()
|
||||
);
|
||||
}
|
||||
|
||||
static equals(a: TimeOfDay | null | undefined, b: TimeOfDay | null | undefined): boolean {
|
||||
return (a === b) || ((a != null && b != null) && a.hour === b.hour &&
|
||||
a.minute === b.minute &&
|
||||
a.second === b.second &&
|
||||
a.millisecond === b.millisecond);
|
||||
}
|
||||
static equals(
|
||||
a: TimeOfDay | null | undefined,
|
||||
b: TimeOfDay | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
(a != null &&
|
||||
b != null &&
|
||||
a.hour === b.hour &&
|
||||
a.minute === b.minute &&
|
||||
a.second === b.second &&
|
||||
a.millisecond === b.millisecond)
|
||||
);
|
||||
}
|
||||
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
readonly millisecond: number;
|
||||
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;
|
||||
}
|
||||
constructor(
|
||||
hour: number = 0,
|
||||
minute: number = 0,
|
||||
second: number = 0,
|
||||
millisecond: number = 0
|
||||
) {
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
this.second = second;
|
||||
this.millisecond = millisecond;
|
||||
}
|
||||
}
|
||||
|
||||
export enum Weekday {
|
||||
Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday,
|
||||
Sunday,
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday
|
||||
}
|
||||
|
||||
export const WEEKDAYS: Weekday[] = Object.keys(Weekday)
|
||||
.map((weekday) => Number(weekday))
|
||||
.filter((weekday) => !isNaN(weekday));
|
||||
.map(weekday => Number(weekday))
|
||||
.filter(weekday => !isNaN(weekday));
|
||||
|
||||
export enum Month {
|
||||
January = 1,
|
||||
February = 2,
|
||||
March = 3,
|
||||
April = 4,
|
||||
May = 5,
|
||||
June = 6,
|
||||
July = 7,
|
||||
August = 8,
|
||||
September = 9,
|
||||
October = 10,
|
||||
November = 11,
|
||||
December = 12,
|
||||
January = 1,
|
||||
February = 2,
|
||||
March = 3,
|
||||
April = 4,
|
||||
May = 5,
|
||||
June = 6,
|
||||
July = 7,
|
||||
August = 8,
|
||||
September = 9,
|
||||
October = 10,
|
||||
November = 11,
|
||||
December = 12
|
||||
}
|
||||
|
||||
export class DateOfYear {
|
||||
static readonly DEFAULT = new DateOfYear({ day: 1, month: Month.January, year: 0 });
|
||||
static readonly DEFAULT = new DateOfYear({
|
||||
day: 1,
|
||||
month: Month.January,
|
||||
year: 0
|
||||
});
|
||||
|
||||
static equals(a: DateOfYear | null | undefined, b: DateOfYear | null | undefined): boolean {
|
||||
return (a === b) || ((a instanceof DateOfYear && b instanceof DateOfYear) &&
|
||||
a.day === b.day &&
|
||||
a.month === b.month &&
|
||||
a.year === b.year);
|
||||
}
|
||||
static equals(
|
||||
a: DateOfYear | null | undefined,
|
||||
b: DateOfYear | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
(a instanceof DateOfYear &&
|
||||
b instanceof DateOfYear &&
|
||||
a.day === b.day &&
|
||||
a.month === b.month &&
|
||||
a.year === b.year)
|
||||
);
|
||||
}
|
||||
|
||||
static fromMoment(m: Moment): DateOfYear {
|
||||
return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() });
|
||||
}
|
||||
static fromMoment(m: Moment): DateOfYear {
|
||||
return new DateOfYear({ day: m.date(), month: m.month(), year: m.year() });
|
||||
}
|
||||
|
||||
readonly day!: number;
|
||||
readonly month!: Month;
|
||||
readonly year!: number;
|
||||
readonly day!: number;
|
||||
readonly month!: Month;
|
||||
readonly year!: number;
|
||||
|
||||
constructor(data?: Partial<DateOfYear>) {
|
||||
Object.assign(this, DateOfYear.DEFAULT, data);
|
||||
}
|
||||
constructor(data?: Partial<DateOfYear>) {
|
||||
Object.assign(this, DateOfYear.DEFAULT, data);
|
||||
}
|
||||
|
||||
with(data: Partial<DateOfYear>): DateOfYear {
|
||||
return new DateOfYear(Object.assign({}, this, data));
|
||||
}
|
||||
with(data: Partial<DateOfYear>): DateOfYear {
|
||||
return new DateOfYear(Object.assign({}, this, data));
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${Month[this.month]} ${this.day}, ${this.year}`;
|
||||
}
|
||||
toString() {
|
||||
return `${Month[this.month]} ${this.day}, ${this.year}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class Schedule {
|
||||
@observable times: TimeOfDay[] = [];
|
||||
@observable weekdays: Weekday[] = [];
|
||||
@observable from: DateOfYear | null = null;
|
||||
@observable to: DateOfYear | null = null;
|
||||
@observable
|
||||
times: TimeOfDay[] = [];
|
||||
@observable
|
||||
weekdays: Weekday[] = [];
|
||||
@observable
|
||||
from: DateOfYear | null = null;
|
||||
@observable
|
||||
to: DateOfYear | null = null;
|
||||
|
||||
constructor(data?: Partial<Schedule>) {
|
||||
if (typeof data === "object") {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(data?: Partial<Schedule>) {
|
||||
if (typeof data === "object") {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
clone(): Schedule {
|
||||
return new Schedule(this);
|
||||
}
|
||||
clone(): Schedule {
|
||||
return new Schedule(this);
|
||||
}
|
||||
}
|
||||
|
@ -1,40 +1,38 @@
|
||||
import {
|
||||
ModelSchema, primitive, PropSchema,
|
||||
} from "serializr";
|
||||
import { ModelSchema, primitive, PropSchema } from "serializr";
|
||||
import * as s from "..";
|
||||
|
||||
export const duration: PropSchema = primitive();
|
||||
|
||||
export const date: PropSchema = {
|
||||
serializer: (jsDate: Date | null) => jsDate != null ?
|
||||
jsDate.toISOString() : null,
|
||||
deserializer: (json: any, done) => {
|
||||
if (json === null) {
|
||||
return done(null, null);
|
||||
}
|
||||
try {
|
||||
done(null, new Date(json));
|
||||
} catch (e) {
|
||||
done(e, undefined);
|
||||
}
|
||||
},
|
||||
serializer: (jsDate: Date | null) =>
|
||||
jsDate != null ? jsDate.toISOString() : null,
|
||||
deserializer: (json: any, done) => {
|
||||
if (json === null) {
|
||||
return done(null, null);
|
||||
}
|
||||
try {
|
||||
done(null, new Date(json));
|
||||
} catch (e) {
|
||||
done(e, undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const dateOfYear: ModelSchema<s.DateOfYear> = {
|
||||
factory: () => new s.DateOfYear(),
|
||||
props: {
|
||||
year: primitive(),
|
||||
month: primitive(), // this only works if it is represented as a # from 0-12
|
||||
day: primitive(),
|
||||
},
|
||||
factory: () => new s.DateOfYear(),
|
||||
props: {
|
||||
year: primitive(),
|
||||
month: primitive(), // this only works if it is represented as a # from 0-12
|
||||
day: primitive()
|
||||
}
|
||||
};
|
||||
|
||||
export const timeOfDay: ModelSchema<s.TimeOfDay> = {
|
||||
factory: () => new s.TimeOfDay(),
|
||||
props: {
|
||||
hour: primitive(),
|
||||
minute: primitive(),
|
||||
second: primitive(),
|
||||
millisecond: primitive(),
|
||||
},
|
||||
factory: () => new s.TimeOfDay(),
|
||||
props: {
|
||||
hour: primitive(),
|
||||
minute: primitive(),
|
||||
second: primitive(),
|
||||
millisecond: primitive()
|
||||
}
|
||||
};
|
||||
|
@ -1,6 +1,4 @@
|
||||
import {
|
||||
createSimpleSchema, ModelSchema, object, primitive,
|
||||
} from "serializr";
|
||||
import { createSimpleSchema, ModelSchema, object, primitive } from "serializr";
|
||||
import * as s from "..";
|
||||
import list from "./list";
|
||||
|
||||
@ -11,81 +9,89 @@ import * as common from "./common";
|
||||
export * from "./common";
|
||||
|
||||
export const connectionState: ModelSchema<s.ConnectionState> = {
|
||||
factory: (c) => new s.ConnectionState(),
|
||||
props: {
|
||||
clientToServer: primitive(),
|
||||
serverToBroker: primitive(),
|
||||
brokerToDevice: primitive(),
|
||||
},
|
||||
factory: c => new s.ConnectionState(),
|
||||
props: {
|
||||
clientToServer: primitive(),
|
||||
serverToBroker: primitive(),
|
||||
brokerToDevice: primitive()
|
||||
}
|
||||
};
|
||||
|
||||
export const section: ModelSchema<s.Section> = {
|
||||
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionConstructor(
|
||||
c.parentContext.target, c.json.id),
|
||||
props: {
|
||||
id: primitive(),
|
||||
name: primitive(),
|
||||
state: primitive(),
|
||||
},
|
||||
factory: c =>
|
||||
new (c.parentContext.target as s.SprinklersDevice).sectionConstructor(
|
||||
c.parentContext.target,
|
||||
c.json.id
|
||||
),
|
||||
props: {
|
||||
id: primitive(),
|
||||
name: primitive(),
|
||||
state: primitive()
|
||||
}
|
||||
};
|
||||
|
||||
export const sectionRun: ModelSchema<s.SectionRun> = {
|
||||
factory: (c) => new s.SectionRun(c.parentContext.target, c.json.id),
|
||||
props: {
|
||||
id: primitive(),
|
||||
section: primitive(),
|
||||
totalDuration: common.duration,
|
||||
duration: common.duration,
|
||||
startTime: common.date,
|
||||
pauseTime: common.date,
|
||||
unpauseTime: common.date,
|
||||
},
|
||||
factory: c => new s.SectionRun(c.parentContext.target, c.json.id),
|
||||
props: {
|
||||
id: primitive(),
|
||||
section: primitive(),
|
||||
totalDuration: common.duration,
|
||||
duration: common.duration,
|
||||
startTime: common.date,
|
||||
pauseTime: common.date,
|
||||
unpauseTime: common.date
|
||||
}
|
||||
};
|
||||
|
||||
export const sectionRunner: ModelSchema<s.SectionRunner> = {
|
||||
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor(
|
||||
c.parentContext.target),
|
||||
props: {
|
||||
queue: list(object(sectionRun)),
|
||||
current: object(sectionRun),
|
||||
paused: primitive(),
|
||||
},
|
||||
factory: c =>
|
||||
new (c.parentContext.target as s.SprinklersDevice).sectionRunnerConstructor(
|
||||
c.parentContext.target
|
||||
),
|
||||
props: {
|
||||
queue: list(object(sectionRun)),
|
||||
current: object(sectionRun),
|
||||
paused: primitive()
|
||||
}
|
||||
};
|
||||
|
||||
export const schedule: ModelSchema<s.Schedule> = {
|
||||
factory: () => new s.Schedule(),
|
||||
props: {
|
||||
times: list(object(common.timeOfDay)),
|
||||
weekdays: list(primitive()),
|
||||
from: object(common.dateOfYear),
|
||||
to: object(common.dateOfYear),
|
||||
},
|
||||
factory: () => new s.Schedule(),
|
||||
props: {
|
||||
times: list(object(common.timeOfDay)),
|
||||
weekdays: list(primitive()),
|
||||
from: object(common.dateOfYear),
|
||||
to: object(common.dateOfYear)
|
||||
}
|
||||
};
|
||||
|
||||
export const programItem: ModelSchema<s.ProgramItem> = {
|
||||
factory: () => new s.ProgramItem(),
|
||||
props: {
|
||||
section: primitive(),
|
||||
duration: common.duration,
|
||||
},
|
||||
factory: () => new s.ProgramItem(),
|
||||
props: {
|
||||
section: primitive(),
|
||||
duration: common.duration
|
||||
}
|
||||
};
|
||||
|
||||
export const program: ModelSchema<s.Program> = {
|
||||
factory: (c) => new (c.parentContext.target as s.SprinklersDevice).programConstructor(
|
||||
c.parentContext.target, c.json.id),
|
||||
props: {
|
||||
id: primitive(),
|
||||
name: primitive(),
|
||||
enabled: primitive(),
|
||||
schedule: object(schedule),
|
||||
sequence: list(object(programItem)),
|
||||
running: primitive(),
|
||||
},
|
||||
factory: c =>
|
||||
new (c.parentContext.target as s.SprinklersDevice).programConstructor(
|
||||
c.parentContext.target,
|
||||
c.json.id
|
||||
),
|
||||
props: {
|
||||
id: primitive(),
|
||||
name: primitive(),
|
||||
enabled: primitive(),
|
||||
schedule: object(schedule),
|
||||
sequence: list(object(programItem)),
|
||||
running: primitive()
|
||||
}
|
||||
};
|
||||
|
||||
export const sprinklersDevice = createSimpleSchema({
|
||||
connectionState: object(connectionState),
|
||||
sections: list(object(section)),
|
||||
sectionRunner: object(sectionRunner),
|
||||
programs: list(object(program)),
|
||||
connectionState: object(connectionState),
|
||||
sections: list(object(section)),
|
||||
sectionRunner: object(sectionRunner),
|
||||
programs: list(object(program))
|
||||
});
|
||||
|
@ -1,65 +1,75 @@
|
||||
import { primitive, PropSchema } from "serializr";
|
||||
|
||||
function invariant(cond: boolean, message?: string) {
|
||||
if (!cond) {
|
||||
throw new Error("[serializr] " + (message || "Illegal ServerState"));
|
||||
}
|
||||
if (!cond) {
|
||||
throw new Error("[serializr] " + (message || "Illegal ServerState"));
|
||||
}
|
||||
}
|
||||
|
||||
function isPropSchema(thing: any) {
|
||||
return thing && thing.serializer && thing.deserializer;
|
||||
return thing && thing.serializer && thing.deserializer;
|
||||
}
|
||||
|
||||
function isAliasedPropSchema(propSchema: any) {
|
||||
return typeof propSchema === "object" && !!propSchema.jsonname;
|
||||
return typeof propSchema === "object" && !!propSchema.jsonname;
|
||||
}
|
||||
|
||||
function parallel(ar: any[], processor: (item: any, done: any) => void, cb: any) {
|
||||
if (ar.length === 0) {
|
||||
return void cb(null, []);
|
||||
function parallel(
|
||||
ar: any[],
|
||||
processor: (item: any, done: any) => void,
|
||||
cb: any
|
||||
) {
|
||||
if (ar.length === 0) {
|
||||
return void cb(null, []);
|
||||
}
|
||||
let left = ar.length;
|
||||
const resultArray: any[] = [];
|
||||
let failed = false;
|
||||
const processorCb = (idx: number, err: any, result: any) => {
|
||||
if (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
cb(err);
|
||||
}
|
||||
} else if (!failed) {
|
||||
resultArray[idx] = result;
|
||||
if (--left === 0) {
|
||||
cb(null, resultArray);
|
||||
}
|
||||
}
|
||||
let left = ar.length;
|
||||
const resultArray: any[] = [];
|
||||
let failed = false;
|
||||
const processorCb = (idx: number, err: any, result: any) => {
|
||||
if (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
cb(err);
|
||||
}
|
||||
} else if (!failed) {
|
||||
resultArray[idx] = result;
|
||||
if (--left === 0) {
|
||||
cb(null, resultArray);
|
||||
}
|
||||
}
|
||||
};
|
||||
ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx)));
|
||||
};
|
||||
ar.forEach((value, idx) => processor(value, processorCb.bind(null, idx)));
|
||||
}
|
||||
|
||||
export default function list(propSchema: PropSchema): PropSchema {
|
||||
propSchema = propSchema || primitive();
|
||||
invariant(isPropSchema(propSchema), "expected prop schema as first argument");
|
||||
invariant(!isAliasedPropSchema(propSchema), "provided prop is aliased, please put aliases first");
|
||||
return {
|
||||
serializer(ar) {
|
||||
invariant(ar && typeof ar.length === "number" && typeof ar.map === "function",
|
||||
"expected array (like) object");
|
||||
return ar.map(propSchema.serializer);
|
||||
},
|
||||
deserializer(jsonArray, done, context) {
|
||||
if (jsonArray === null) { // sometimes go will return null in place of empty array
|
||||
return void done(null, []);
|
||||
}
|
||||
if (!Array.isArray(jsonArray)) {
|
||||
return void done("[serializr] expected JSON array", null);
|
||||
}
|
||||
parallel(
|
||||
jsonArray,
|
||||
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) =>
|
||||
propSchema.deserializer(item, itemDone, context, undefined),
|
||||
done,
|
||||
);
|
||||
},
|
||||
};
|
||||
propSchema = propSchema || primitive();
|
||||
invariant(isPropSchema(propSchema), "expected prop schema as first argument");
|
||||
invariant(
|
||||
!isAliasedPropSchema(propSchema),
|
||||
"provided prop is aliased, please put aliases first"
|
||||
);
|
||||
return {
|
||||
serializer(ar) {
|
||||
invariant(
|
||||
ar && typeof ar.length === "number" && typeof ar.map === "function",
|
||||
"expected array (like) object"
|
||||
);
|
||||
return ar.map(propSchema.serializer);
|
||||
},
|
||||
deserializer(jsonArray, done, context) {
|
||||
if (jsonArray === null) {
|
||||
// sometimes go will return null in place of empty array
|
||||
return void done(null, []);
|
||||
}
|
||||
if (!Array.isArray(jsonArray)) {
|
||||
return void done("[serializr] expected JSON array", null);
|
||||
}
|
||||
parallel(
|
||||
jsonArray,
|
||||
(item: any, itemDone: (err: any, targetPropertyValue: any) => void) =>
|
||||
propSchema.deserializer(item, itemDone, context, undefined),
|
||||
done
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,69 +1,89 @@
|
||||
import { createSimpleSchema, deserialize, ModelSchema, primitive, serialize } from "serializr";
|
||||
import {
|
||||
createSimpleSchema,
|
||||
deserialize,
|
||||
ModelSchema,
|
||||
primitive,
|
||||
serialize
|
||||
} from "serializr";
|
||||
|
||||
import * as requests from "@common/sprinklersRpc/deviceRequests";
|
||||
import * as common from "./common";
|
||||
|
||||
export const withType: ModelSchema<requests.WithType> = createSimpleSchema({
|
||||
type: primitive(),
|
||||
type: primitive()
|
||||
});
|
||||
|
||||
export const withProgram: ModelSchema<requests.WithProgram> = createSimpleSchema({
|
||||
...withType.props,
|
||||
programId: primitive(),
|
||||
export const withProgram: ModelSchema<
|
||||
requests.WithProgram
|
||||
> = createSimpleSchema({
|
||||
...withType.props,
|
||||
programId: primitive()
|
||||
});
|
||||
|
||||
export const withSection: ModelSchema<requests.WithSection> = createSimpleSchema({
|
||||
...withType.props,
|
||||
sectionId: primitive(),
|
||||
export const withSection: ModelSchema<
|
||||
requests.WithSection
|
||||
> = createSimpleSchema({
|
||||
...withType.props,
|
||||
sectionId: primitive()
|
||||
});
|
||||
|
||||
export const updateProgram: ModelSchema<requests.UpdateProgramData> = createSimpleSchema({
|
||||
...withProgram.props,
|
||||
data: {
|
||||
serializer: (data) => data,
|
||||
deserializer: (json, done) => { done(null, json); },
|
||||
},
|
||||
export const updateProgram: ModelSchema<
|
||||
requests.UpdateProgramData
|
||||
> = createSimpleSchema({
|
||||
...withProgram.props,
|
||||
data: {
|
||||
serializer: data => data,
|
||||
deserializer: (json, done) => {
|
||||
done(null, json);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const runSection: ModelSchema<requests.RunSectionData> = createSimpleSchema({
|
||||
...withSection.props,
|
||||
duration: common.duration,
|
||||
export const runSection: ModelSchema<
|
||||
requests.RunSectionData
|
||||
> = createSimpleSchema({
|
||||
...withSection.props,
|
||||
duration: common.duration
|
||||
});
|
||||
|
||||
export const cancelSectionRunId: ModelSchema<requests.CancelSectionRunIdData> = createSimpleSchema({
|
||||
...withType.props,
|
||||
runId: primitive(),
|
||||
export const cancelSectionRunId: ModelSchema<
|
||||
requests.CancelSectionRunIdData
|
||||
> = createSimpleSchema({
|
||||
...withType.props,
|
||||
runId: primitive()
|
||||
});
|
||||
|
||||
export const pauseSectionRunner: ModelSchema<requests.PauseSectionRunnerData> = createSimpleSchema({
|
||||
...withType.props,
|
||||
paused: primitive(),
|
||||
export const pauseSectionRunner: ModelSchema<
|
||||
requests.PauseSectionRunnerData
|
||||
> = createSimpleSchema({
|
||||
...withType.props,
|
||||
paused: primitive()
|
||||
});
|
||||
|
||||
export function getRequestSchema(request: requests.WithType): ModelSchema<any> {
|
||||
switch (request.type as requests.RequestType) {
|
||||
case "runProgram":
|
||||
case "cancelProgram":
|
||||
return withProgram;
|
||||
case "updateProgram":
|
||||
return updateProgram;
|
||||
case "runSection":
|
||||
return runSection;
|
||||
case "cancelSection":
|
||||
return withSection;
|
||||
case "cancelSectionRunId":
|
||||
return cancelSectionRunId;
|
||||
case "pauseSectionRunner":
|
||||
return pauseSectionRunner;
|
||||
default:
|
||||
throw new Error(`Cannot serialize request with type "${request.type}"`);
|
||||
}
|
||||
switch (request.type as requests.RequestType) {
|
||||
case "runProgram":
|
||||
case "cancelProgram":
|
||||
return withProgram;
|
||||
case "updateProgram":
|
||||
return updateProgram;
|
||||
case "runSection":
|
||||
return runSection;
|
||||
case "cancelSection":
|
||||
return withSection;
|
||||
case "cancelSectionRunId":
|
||||
return cancelSectionRunId;
|
||||
case "pauseSectionRunner":
|
||||
return pauseSectionRunner;
|
||||
default:
|
||||
throw new Error(`Cannot serialize request with type "${request.type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function seralizeRequest(request: requests.Request): any {
|
||||
return serialize(getRequestSchema(request), request);
|
||||
return serialize(getRequestSchema(request), request);
|
||||
}
|
||||
|
||||
export function deserializeRequest(json: any): requests.Request {
|
||||
return deserialize(getRequestSchema(json), json);
|
||||
return deserialize(getRequestSchema(json), json);
|
||||
}
|
||||
|
@ -3,76 +3,92 @@ import * as rpc from "@common/jsonRpc/index";
|
||||
import { Response as ResponseData } from "@common/sprinklersRpc/deviceRequests";
|
||||
|
||||
export interface IAuthenticateRequest {
|
||||
accessToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface IDeviceSubscribeRequest {
|
||||
deviceId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface IDeviceCallRequest {
|
||||
deviceId: string;
|
||||
data: any;
|
||||
deviceId: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface IClientRequestTypes {
|
||||
"authenticate": IAuthenticateRequest;
|
||||
"deviceSubscribe": IDeviceSubscribeRequest;
|
||||
"deviceUnsubscribe": IDeviceSubscribeRequest;
|
||||
"deviceCall": IDeviceCallRequest;
|
||||
authenticate: IAuthenticateRequest;
|
||||
deviceSubscribe: IDeviceSubscribeRequest;
|
||||
deviceUnsubscribe: IDeviceSubscribeRequest;
|
||||
deviceCall: IDeviceCallRequest;
|
||||
}
|
||||
|
||||
export interface IAuthenticateResponse {
|
||||
authenticated: boolean;
|
||||
message: string;
|
||||
user: IUser;
|
||||
authenticated: boolean;
|
||||
message: string;
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
export interface IDeviceSubscribeResponse {
|
||||
deviceId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface IDeviceCallResponse {
|
||||
data: ResponseData;
|
||||
data: ResponseData;
|
||||
}
|
||||
|
||||
export interface IServerResponseTypes {
|
||||
"authenticate": IAuthenticateResponse;
|
||||
"deviceSubscribe": IDeviceSubscribeResponse;
|
||||
"deviceUnsubscribe": IDeviceSubscribeResponse;
|
||||
"deviceCall": IDeviceCallResponse;
|
||||
authenticate: IAuthenticateResponse;
|
||||
deviceSubscribe: IDeviceSubscribeResponse;
|
||||
deviceUnsubscribe: IDeviceSubscribeResponse;
|
||||
deviceCall: IDeviceCallResponse;
|
||||
}
|
||||
|
||||
export type ClientRequestMethods = keyof IClientRequestTypes;
|
||||
|
||||
export interface IBrokerConnectionUpdate {
|
||||
brokerConnected: boolean;
|
||||
brokerConnected: boolean;
|
||||
}
|
||||
|
||||
export interface IDeviceUpdate {
|
||||
deviceId: string;
|
||||
data: any;
|
||||
deviceId: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface IServerNotificationTypes {
|
||||
"brokerConnectionUpdate": IBrokerConnectionUpdate;
|
||||
"deviceUpdate": IDeviceUpdate;
|
||||
"error": IError;
|
||||
brokerConnectionUpdate: IBrokerConnectionUpdate;
|
||||
deviceUpdate: IDeviceUpdate;
|
||||
error: IError;
|
||||
}
|
||||
export type ServerNotificationMethod = keyof IServerNotificationTypes;
|
||||
|
||||
export type IError = rpc.DefaultErrorType;
|
||||
export type ErrorData = rpc.ErrorData<IError>;
|
||||
|
||||
export type ServerMessage = rpc.Message<{}, IServerResponseTypes, IError, IServerNotificationTypes>;
|
||||
export type ServerMessage = rpc.Message<
|
||||
{},
|
||||
IServerResponseTypes,
|
||||
IError,
|
||||
IServerNotificationTypes
|
||||
>;
|
||||
export type ServerNotification = rpc.Notification<IServerNotificationTypes>;
|
||||
export type ServerResponse = rpc.Response<IServerResponseTypes, IError>;
|
||||
export type ServerResponseData<Method extends keyof IServerResponseTypes = keyof IServerResponseTypes> =
|
||||
rpc.ResponseData<IServerResponseTypes, IError, Method>;
|
||||
export type ServerResponseHandlers = rpc.ResponseHandlers<IServerResponseTypes, IError>;
|
||||
export type ServerNotificationHandlers = rpc.NotificationHandlers<IServerNotificationTypes>;
|
||||
export type ServerResponseData<
|
||||
Method extends keyof IServerResponseTypes = keyof IServerResponseTypes
|
||||
> = rpc.ResponseData<IServerResponseTypes, IError, Method>;
|
||||
export type ServerResponseHandlers = rpc.ResponseHandlers<
|
||||
IServerResponseTypes,
|
||||
IError
|
||||
>;
|
||||
export type ServerNotificationHandlers = rpc.NotificationHandlers<
|
||||
IServerNotificationTypes
|
||||
>;
|
||||
|
||||
export type ClientRequest<Method extends keyof IClientRequestTypes = keyof IClientRequestTypes> =
|
||||
rpc.Request<IClientRequestTypes, Method>;
|
||||
export type ClientRequest<
|
||||
Method extends keyof IClientRequestTypes = keyof IClientRequestTypes
|
||||
> = rpc.Request<IClientRequestTypes, Method>;
|
||||
export type ClientMessage = rpc.Message<IClientRequestTypes, {}, IError, {}>;
|
||||
export type ClientRequestHandlers = rpc.RequestHandlers<IClientRequestTypes, IServerResponseTypes, IError>;
|
||||
export type ClientRequestHandlers = rpc.RequestHandlers<
|
||||
IClientRequestTypes,
|
||||
IServerResponseTypes,
|
||||
IError
|
||||
>;
|
||||
|
@ -1,26 +1,30 @@
|
||||
export function checkedIndexOf<T>(o: T | number, arr: T[], type: string = "object"): number {
|
||||
let idx: number;
|
||||
if (typeof o === "number") {
|
||||
idx = o;
|
||||
} else if (typeof (o as any).id === "number") {
|
||||
idx = (o as any).id;
|
||||
} else {
|
||||
idx = arr.indexOf(o);
|
||||
}
|
||||
if (idx < 0 || idx > arr.length) {
|
||||
throw new Error(`Invalid ${type} specified: ${o}`);
|
||||
}
|
||||
return idx;
|
||||
export function checkedIndexOf<T>(
|
||||
o: T | number,
|
||||
arr: T[],
|
||||
type: string = "object"
|
||||
): number {
|
||||
let idx: number;
|
||||
if (typeof o === "number") {
|
||||
idx = o;
|
||||
} else if (typeof (o as any).id === "number") {
|
||||
idx = (o as any).id;
|
||||
} else {
|
||||
idx = arr.indexOf(o);
|
||||
}
|
||||
if (idx < 0 || idx > arr.length) {
|
||||
throw new Error(`Invalid ${type} specified: ${o}`);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
export function getRandomId() {
|
||||
return Math.floor(Math.random() * 1000000000);
|
||||
return Math.floor(Math.random() * 1000000000);
|
||||
}
|
||||
|
||||
export function applyMixins(derivedCtor: any, baseCtors: any[]) {
|
||||
baseCtors.forEach((baseCtor) => {
|
||||
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
|
||||
derivedCtor.prototype[name] = baseCtor.prototype[name];
|
||||
});
|
||||
baseCtors.forEach(baseCtor => {
|
||||
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
|
||||
derivedCtor.prototype[name] = baseCtor.prototype[name];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -39,4 +39,4 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV
|
||||
- POSTGRES_PASSWORD=8JN4w0UsN5dbjMjNvPe452P2yYOqg5PV
|
||||
|
@ -124,6 +124,8 @@
|
||||
"style-loader": "^0.22.1",
|
||||
"ts-loader": "^4.5.0",
|
||||
"tslint": "^5.11.0",
|
||||
"tslint-config-prettier": "^1.15.0",
|
||||
"tslint-consistent-codestyle": "^1.13.3",
|
||||
"tslint-react": "^3.6.0",
|
||||
"typescript": "^3.0.1",
|
||||
"uglify-es": "^3.3.9",
|
||||
|
2
paths.js
2
paths.js
@ -33,4 +33,4 @@ exports.clientBuildDir = resolveRoot("public");
|
||||
exports.publicDir = exports.clientBuildDir;
|
||||
|
||||
exports.serverDir = resolveRoot("server");
|
||||
exports.serverBuildDir = resolveRoot("dist");
|
||||
exports.serverBuildDir = resolveRoot("dist");
|
@ -1,5 +1,11 @@
|
||||
import * as path from "path";
|
||||
import { Connection, createConnection, EntityManager, getConnectionOptions, Repository } from "typeorm";
|
||||
import {
|
||||
Connection,
|
||||
createConnection,
|
||||
EntityManager,
|
||||
getConnectionOptions,
|
||||
Repository
|
||||
} from "typeorm";
|
||||
|
||||
import logger from "@common/logger";
|
||||
|
||||
@ -7,80 +13,84 @@ import { SprinklersDevice, User } from "./entities";
|
||||
import { SprinklersDeviceRepository, UserRepository } from "./repositories/";
|
||||
|
||||
export class Database {
|
||||
users!: UserRepository;
|
||||
sprinklersDevices!: SprinklersDeviceRepository;
|
||||
users!: UserRepository;
|
||||
sprinklersDevices!: SprinklersDeviceRepository;
|
||||
|
||||
private _conn: Connection | null = null;
|
||||
private _conn: Connection | null = null;
|
||||
|
||||
get conn(): Connection {
|
||||
if (this._conn == null) {
|
||||
throw new Error("Not connected to rethinkDB");
|
||||
}
|
||||
return this._conn;
|
||||
get conn(): Connection {
|
||||
if (this._conn == null) {
|
||||
throw new Error("Not connected to rethinkDB");
|
||||
}
|
||||
return this._conn;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const options = await getConnectionOptions();
|
||||
Object.assign(options, {
|
||||
entities: [
|
||||
path.resolve(__dirname, "entities", "*.js"),
|
||||
],
|
||||
async connect() {
|
||||
const options = await getConnectionOptions();
|
||||
Object.assign(options, {
|
||||
entities: [path.resolve(__dirname, "entities", "*.js")]
|
||||
});
|
||||
this._conn = await createConnection(options);
|
||||
this.users = this._conn.getCustomRepository(UserRepository);
|
||||
this.sprinklersDevices = this._conn.getCustomRepository(
|
||||
SprinklersDeviceRepository
|
||||
);
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this._conn) {
|
||||
return this._conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
async createAll() {
|
||||
await this.conn.synchronize();
|
||||
if (process.env.INSERT_TEST_DATA) {
|
||||
await this.insertData();
|
||||
}
|
||||
}
|
||||
|
||||
async insertData() {
|
||||
const NUM = 100;
|
||||
const users: User[] = [];
|
||||
for (let i = 0; i < NUM; i++) {
|
||||
const username = "alex" + i;
|
||||
let user = await this.users.findByUsername(username);
|
||||
if (!user) {
|
||||
user = await this.users.create({
|
||||
name: "Alex Mikhalev" + i,
|
||||
username
|
||||
});
|
||||
this._conn = await createConnection(options);
|
||||
this.users = this._conn.getCustomRepository(UserRepository);
|
||||
this.sprinklersDevices = this._conn.getCustomRepository(SprinklersDeviceRepository);
|
||||
}
|
||||
await user.setPassword("kakashka" + i);
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this._conn) {
|
||||
return this._conn.close();
|
||||
}
|
||||
for (let i = 0; i < NUM; i++) {
|
||||
const name = "Test" + i;
|
||||
let device = await this.sprinklersDevices.findByName(name);
|
||||
if (!device) {
|
||||
device = await this.sprinklersDevices.create();
|
||||
}
|
||||
Object.assign(device, {
|
||||
name,
|
||||
deviceId: "grinklers" + (i === 1 ? "" : i)
|
||||
});
|
||||
await this.sprinklersDevices.save(device);
|
||||
for (let j = 0; j < 5; j++) {
|
||||
const userIdx = (i + j * 10) % NUM;
|
||||
const user = users[userIdx];
|
||||
user.devices = (user.devices || []).concat([device]);
|
||||
}
|
||||
}
|
||||
logger.info("inserted/updated devices");
|
||||
|
||||
async createAll() {
|
||||
await this.conn.synchronize();
|
||||
if (process.env.INSERT_TEST_DATA) {
|
||||
await this.insertData();
|
||||
}
|
||||
}
|
||||
await this.users.save(users);
|
||||
logger.info("inserted/updated users");
|
||||
|
||||
async insertData() {
|
||||
const NUM = 100;
|
||||
const users: User[] = [];
|
||||
for (let i = 0; i < NUM; i++) {
|
||||
const username = "alex" + i;
|
||||
let user = await this.users.findByUsername(username);
|
||||
if (!user) {
|
||||
user = await this.users.create({
|
||||
name: "Alex Mikhalev" + i,
|
||||
username,
|
||||
});
|
||||
}
|
||||
await user.setPassword("kakashka" + i);
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
for (let i = 0; i < NUM; i++) {
|
||||
const name = "Test" + i;
|
||||
let device = await this.sprinklersDevices.findByName(name);
|
||||
if (!device) {
|
||||
device = await this.sprinklersDevices.create();
|
||||
}
|
||||
Object.assign(device, { name, deviceId: "grinklers" + (i === 1 ? "" : i) });
|
||||
await this.sprinklersDevices.save(device);
|
||||
for (let j = 0; j < 5; j++) {
|
||||
const userIdx = (i + j * 10) % NUM;
|
||||
const user = users[userIdx];
|
||||
user.devices = (user.devices || []).concat([device]);
|
||||
}
|
||||
}
|
||||
logger.info("inserted/updated devices");
|
||||
|
||||
await this.users.save(users);
|
||||
logger.info("inserted/updated users");
|
||||
|
||||
const alex2 = await this.users.findOne({ username: "alex0" });
|
||||
logger.info("password valid: " + await alex2!.comparePassword("kakashka0"));
|
||||
|
||||
}
|
||||
const alex2 = await this.users.findOne({ username: "alex0" });
|
||||
logger.info(
|
||||
"password valid: " + (await alex2!.comparePassword("kakashka0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,10 +5,10 @@ import * as jwt from "jsonwebtoken";
|
||||
import ApiError from "@common/ApiError";
|
||||
import { ErrorCode } from "@common/ErrorCode";
|
||||
import {
|
||||
TokenGrantPasswordRequest,
|
||||
TokenGrantRefreshRequest,
|
||||
TokenGrantRequest,
|
||||
TokenGrantResponse,
|
||||
TokenGrantPasswordRequest,
|
||||
TokenGrantRefreshRequest,
|
||||
TokenGrantRequest,
|
||||
TokenGrantResponse
|
||||
} from "@common/httpApi";
|
||||
import * as tok from "@common/TokenClaims";
|
||||
import { User } from "@server/entities";
|
||||
@ -16,96 +16,117 @@ import { ServerState } from "@server/state";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error("Must specify JWT_SECRET environment variable");
|
||||
throw new Error("Must specify JWT_SECRET environment variable");
|
||||
}
|
||||
|
||||
const ISSUER = "sprinklers3";
|
||||
|
||||
const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes
|
||||
const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours
|
||||
const ACCESS_TOKEN_LIFETIME = 30 * 60; // 30 minutes
|
||||
const REFRESH_TOKEN_LIFETIME = 24 * 60 * 60; // 24 hours
|
||||
|
||||
function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise<string> {
|
||||
const options: jwt.SignOptions = {
|
||||
issuer: ISSUER,
|
||||
...opts,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(encoded);
|
||||
}
|
||||
});
|
||||
function signToken(
|
||||
claims: tok.TokenClaimTypes,
|
||||
opts?: jwt.SignOptions
|
||||
): Promise<string> {
|
||||
const options: jwt.SignOptions = {
|
||||
issuer: ISSUER,
|
||||
...opts
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(encoded);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyToken<TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes>(
|
||||
token: string, type?: TClaims["type"],
|
||||
): Promise<TClaims & tok.BaseClaims> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, JWT_SECRET, {
|
||||
issuer: ISSUER,
|
||||
}, (err, decoded) => {
|
||||
if (err) {
|
||||
if (err.name === "TokenExpiredError") {
|
||||
reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err));
|
||||
} else if (err.name === "JsonWebTokenError") {
|
||||
reject(new ApiError("Invalid token", ErrorCode.BadToken, err));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
const claims: tok.TokenClaims = decoded as any;
|
||||
if (type != null && claims.type !== type) {
|
||||
reject(new ApiError(`Expected a "${type}" token, received a "${claims.type}" token`,
|
||||
ErrorCode.BadToken));
|
||||
}
|
||||
resolve(claims as TClaims & tok.BaseClaims);
|
||||
}
|
||||
});
|
||||
});
|
||||
export function verifyToken<
|
||||
TClaims extends tok.TokenClaimTypes = tok.TokenClaimTypes
|
||||
>(token: string, type?: TClaims["type"]): Promise<TClaims & tok.BaseClaims> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(
|
||||
token,
|
||||
JWT_SECRET,
|
||||
{
|
||||
issuer: ISSUER
|
||||
},
|
||||
(err, decoded) => {
|
||||
if (err) {
|
||||
if (err.name === "TokenExpiredError") {
|
||||
reject(
|
||||
new ApiError(
|
||||
"The specified token is expired",
|
||||
ErrorCode.BadToken,
|
||||
err
|
||||
)
|
||||
);
|
||||
} else if (err.name === "JsonWebTokenError") {
|
||||
reject(new ApiError("Invalid token", ErrorCode.BadToken, err));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
const claims: tok.TokenClaims = decoded as any;
|
||||
if (type != null && claims.type !== type) {
|
||||
reject(
|
||||
new ApiError(
|
||||
`Expected a "${type}" token, received a "${claims.type}" token`,
|
||||
ErrorCode.BadToken
|
||||
)
|
||||
);
|
||||
}
|
||||
resolve(claims as TClaims & tok.BaseClaims);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function generateAccessToken(user: User): Promise<string> {
|
||||
const access_token_claims: tok.AccessToken = {
|
||||
aud: user.id,
|
||||
name: user.name,
|
||||
type: "access",
|
||||
};
|
||||
const accessTokenClaims: tok.AccessToken = {
|
||||
aud: user.id,
|
||||
name: user.name,
|
||||
type: "access"
|
||||
};
|
||||
|
||||
return signToken(access_token_claims, { expiresIn: ACCESS_TOKEN_LIFETIME });
|
||||
return signToken(accessTokenClaims, { expiresIn: ACCESS_TOKEN_LIFETIME });
|
||||
}
|
||||
|
||||
export function generateRefreshToken(user: User): Promise<string> {
|
||||
const refresh_token_claims: tok.RefreshToken = {
|
||||
aud: user.id,
|
||||
name: user.name,
|
||||
type: "refresh",
|
||||
};
|
||||
const refreshTokenClaims: tok.RefreshToken = {
|
||||
aud: user.id,
|
||||
name: user.name,
|
||||
type: "refresh"
|
||||
};
|
||||
|
||||
return signToken(refresh_token_claims, { expiresIn: REFRESH_TOKEN_LIFETIME });
|
||||
return signToken(refreshTokenClaims, { expiresIn: REFRESH_TOKEN_LIFETIME });
|
||||
}
|
||||
|
||||
export function generateDeviceRegistrationToken(): Promise<string> {
|
||||
const device_reg_token_claims: tok.DeviceRegistrationToken = {
|
||||
type: "device_reg",
|
||||
};
|
||||
return signToken(device_reg_token_claims);
|
||||
const deviceRegTokenClaims: tok.DeviceRegistrationToken = {
|
||||
type: "device_reg"
|
||||
};
|
||||
return signToken(deviceRegTokenClaims);
|
||||
}
|
||||
|
||||
export function generateDeviceToken(id: number, deviceId: string): Promise<string> {
|
||||
const device_token_claims: tok.DeviceToken = {
|
||||
type: "device",
|
||||
aud: deviceId,
|
||||
id,
|
||||
};
|
||||
return signToken(device_token_claims);
|
||||
export function generateDeviceToken(
|
||||
id: number,
|
||||
deviceId: string
|
||||
): Promise<string> {
|
||||
const deviceTokenClaims: tok.DeviceToken = {
|
||||
type: "device",
|
||||
aud: deviceId,
|
||||
id
|
||||
};
|
||||
return signToken(deviceTokenClaims);
|
||||
}
|
||||
|
||||
export function generateSuperuserToken(): Promise<string> {
|
||||
const superuser_claims: tok.SuperuserToken = {
|
||||
type: "superuser",
|
||||
};
|
||||
return signToken(superuser_claims);
|
||||
const superuserClaims: tok.SuperuserToken = {
|
||||
type: "superuser"
|
||||
};
|
||||
return signToken(superuserClaims);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import log from "@common/logger";
|
||||
Object.assign(log, {
|
||||
name: "sprinklers3/server",
|
||||
level: process.env.LOG_LEVEL || "debug",
|
||||
name: "sprinklers3/server",
|
||||
level: process.env.LOG_LEVEL || "debug"
|
||||
});
|
||||
|
@ -5,38 +5,38 @@ import { User } from "./User";
|
||||
|
||||
@Entity()
|
||||
export class SprinklersDevice implements ISprinklersDevice {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ unique: true, nullable: true, type: "varchar" })
|
||||
deviceId: string | null = null;
|
||||
@Column({ unique: true, nullable: true, type: "varchar" })
|
||||
deviceId: string | null = null;
|
||||
|
||||
@Column()
|
||||
name: string = "";
|
||||
@Column()
|
||||
name: string = "";
|
||||
|
||||
@ManyToMany((type) => User)
|
||||
users: User[] | undefined;
|
||||
@ManyToMany(type => User)
|
||||
users: User[] | undefined;
|
||||
|
||||
constructor(data?: Partial<SprinklersDevice>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(data?: Partial<SprinklersDevice>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Entity()
|
||||
export class UserSprinklersDevice {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column()
|
||||
userId: string = "";
|
||||
@Column()
|
||||
sprinklersDeviceId: string = "";
|
||||
@Column()
|
||||
userId: string = "";
|
||||
@Column()
|
||||
sprinklersDeviceId: string = "";
|
||||
|
||||
constructor(data?: UserSprinklersDevice) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(data?: UserSprinklersDevice) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +1,51 @@
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { omit } from "lodash";
|
||||
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryGeneratedColumn
|
||||
} from "typeorm";
|
||||
|
||||
import { IUser } from "@common/httpApi";
|
||||
import { SprinklersDevice} from "./SprinklersDevice";
|
||||
import { SprinklersDevice } from "./SprinklersDevice";
|
||||
|
||||
const HASH_ROUNDS = 1;
|
||||
|
||||
@Entity()
|
||||
export class User implements IUser {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string = "";
|
||||
@Column({ unique: true })
|
||||
username: string = "";
|
||||
|
||||
@Column()
|
||||
name: string = "";
|
||||
@Column()
|
||||
name: string = "";
|
||||
|
||||
@Column()
|
||||
passwordHash: string = "";
|
||||
@Column()
|
||||
passwordHash: string = "";
|
||||
|
||||
@ManyToMany((type) => SprinklersDevice)
|
||||
@JoinTable({ name: "user_sprinklers_device" })
|
||||
devices: SprinklersDevice[] | undefined;
|
||||
@ManyToMany(type => SprinklersDevice)
|
||||
@JoinTable({ name: "user_sprinklers_device" })
|
||||
devices: SprinklersDevice[] | undefined;
|
||||
|
||||
constructor(data?: Partial<User>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
constructor(data?: Partial<User>) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
async setPassword(newPassword: string): Promise<void> {
|
||||
this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS);
|
||||
}
|
||||
async setPassword(newPassword: string): Promise<void> {
|
||||
this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS);
|
||||
}
|
||||
|
||||
async comparePassword(password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, this.passwordHash);
|
||||
}
|
||||
async comparePassword(password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, this.passwordHash);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return omit(this, "passwordHash");
|
||||
}
|
||||
toJSON() {
|
||||
return omit(this, "passwordHash");
|
||||
}
|
||||
}
|
||||
|
@ -12,13 +12,13 @@ const dotenvFiles: string[] = [
|
||||
// since normally you expect tests to produce the same
|
||||
// results for everyone
|
||||
NODE_ENV !== "test" && `${paths.dotenv}.local`,
|
||||
paths.dotenv,
|
||||
paths.dotenv
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
dotenvFiles.forEach((dotenvFile) => {
|
||||
dotenvFiles.forEach(dotenvFile => {
|
||||
if (fs.existsSync(dotenvFile)) {
|
||||
require("dotenv").config({
|
||||
path: dotenvFile,
|
||||
path: dotenvFile
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import PromiseRouter from "express-promise-router";
|
||||
import { serialize} from "serializr";
|
||||
import { serialize } from "serializr";
|
||||
|
||||
import ApiError from "@common/ApiError";
|
||||
import { ErrorCode } from "@common/ErrorCode";
|
||||
@ -11,59 +11,76 @@ import { ServerState } from "@server/state";
|
||||
const DEVICE_ID_LEN = 20;
|
||||
|
||||
function randomDeviceId(): string {
|
||||
let deviceId = "";
|
||||
for (let i = 0; i < DEVICE_ID_LEN; i++) {
|
||||
const j = Math.floor(Math.random() * 36);
|
||||
let ch; // tslint:disable-next-line
|
||||
if (j < 10) { // 0-9
|
||||
ch = String.fromCharCode(48 + j);
|
||||
} else { // a-z
|
||||
ch = String.fromCharCode(97 + (j - 10));
|
||||
}
|
||||
deviceId += ch;
|
||||
let deviceId = "";
|
||||
for (let i = 0; i < DEVICE_ID_LEN; i++) {
|
||||
const j = Math.floor(Math.random() * 36);
|
||||
let ch; // tslint:disable-next-line
|
||||
if (j < 10) {
|
||||
// 0-9
|
||||
ch = String.fromCharCode(48 + j);
|
||||
} else {
|
||||
// a-z
|
||||
ch = String.fromCharCode(97 + (j - 10));
|
||||
}
|
||||
return deviceId;
|
||||
deviceId += ch;
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
export function devices(state: ServerState) {
|
||||
const router = PromiseRouter();
|
||||
const router = PromiseRouter();
|
||||
|
||||
router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
|
||||
const token = req.token!;
|
||||
const userId = token.aud;
|
||||
const deviceId = req.params.deviceId;
|
||||
const userDevice = await state.database.sprinklersDevices
|
||||
.findUserDevice(userId, deviceId);
|
||||
if (!userDevice) {
|
||||
throw new ApiError("User does not have access to the specified device", ErrorCode.NoPermission);
|
||||
}
|
||||
const device = state.mqttClient.acquireDevice(req.params.deviceId);
|
||||
const j = serialize(schema.sprinklersDevice, device);
|
||||
res.send(j);
|
||||
device.release();
|
||||
});
|
||||
router.get("/:deviceId", verifyAuthorization(), async (req, res) => {
|
||||
const token = req.token!;
|
||||
const userId = token.aud;
|
||||
const deviceId = req.params.deviceId;
|
||||
const userDevice = await state.database.sprinklersDevices.findUserDevice(
|
||||
userId,
|
||||
deviceId
|
||||
);
|
||||
if (!userDevice) {
|
||||
throw new ApiError(
|
||||
"User does not have access to the specified device",
|
||||
ErrorCode.NoPermission
|
||||
);
|
||||
}
|
||||
const device = state.mqttClient.acquireDevice(req.params.deviceId);
|
||||
const j = serialize(schema.sprinklersDevice, device);
|
||||
res.send(j);
|
||||
device.release();
|
||||
});
|
||||
|
||||
router.post("/register", verifyAuthorization({
|
||||
type: "device_reg",
|
||||
}), async (req, res) => {
|
||||
const deviceId = randomDeviceId();
|
||||
const newDevice = state.database.sprinklersDevices.create({
|
||||
name: "Sprinklers Device", deviceId,
|
||||
});
|
||||
await state.database.sprinklersDevices.save(newDevice);
|
||||
const token = await generateDeviceToken(newDevice.id, deviceId);
|
||||
res.send({
|
||||
data: newDevice, token,
|
||||
});
|
||||
});
|
||||
router.post(
|
||||
"/register",
|
||||
verifyAuthorization({
|
||||
type: "device_reg"
|
||||
}),
|
||||
async (req, res) => {
|
||||
const deviceId = randomDeviceId();
|
||||
const newDevice = state.database.sprinklersDevices.create({
|
||||
name: "Sprinklers Device",
|
||||
deviceId
|
||||
});
|
||||
await state.database.sprinklersDevices.save(newDevice);
|
||||
const token = await generateDeviceToken(newDevice.id, deviceId);
|
||||
res.send({
|
||||
data: newDevice,
|
||||
token
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post("/connect", verifyAuthorization({
|
||||
type: "device",
|
||||
}), async (req, res) => {
|
||||
res.send({
|
||||
url: state.mqttUrl,
|
||||
});
|
||||
});
|
||||
router.post(
|
||||
"/connect",
|
||||
verifyAuthorization({
|
||||
type: "device"
|
||||
}),
|
||||
async (req, res) => {
|
||||
res.send({
|
||||
url: state.mqttUrl
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
return router;
|
||||
}
|
||||
|
@ -10,16 +10,16 @@ import { token } from "./token";
|
||||
import { users } from "./users";
|
||||
|
||||
export default function createApi(state: ServerState) {
|
||||
const router = PromiseRouter();
|
||||
const router = PromiseRouter();
|
||||
|
||||
router.use("/devices", devices(state));
|
||||
router.use("/users", users(state));
|
||||
router.use("/mosquitto", mosquitto(state));
|
||||
router.use("/token", token(state));
|
||||
router.use("/devices", devices(state));
|
||||
router.use("/users", users(state));
|
||||
router.use("/mosquitto", mosquitto(state));
|
||||
router.use("/token", token(state));
|
||||
|
||||
router.use("*", (req, res) => {
|
||||
throw new ApiError("API endpoint not found", ErrorCode.NotFound);
|
||||
});
|
||||
router.use("*", (req, res) => {
|
||||
throw new ApiError("API endpoint not found", ErrorCode.NotFound);
|
||||
});
|
||||
|
||||
return router;
|
||||
return router;
|
||||
}
|
||||
|
@ -10,49 +10,56 @@ import { ServerState } from "@server/state";
|
||||
export const SUPERUSER = "sprinklers3";
|
||||
|
||||
export function mosquitto(state: ServerState) {
|
||||
const router = PromiseRouter();
|
||||
const router = PromiseRouter();
|
||||
|
||||
router.post("/auth", async (req, res) => {
|
||||
const body = req.body;
|
||||
const { username, password, topic, acc } = body;
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
throw new ApiError("Must specify a username and password", ErrorCode.BadRequest);
|
||||
}
|
||||
if (username === SUPERUSER) {
|
||||
await verifyToken<SuperuserToken>(password, "superuser");
|
||||
return res.status(200).send({ username });
|
||||
}
|
||||
const claims = await verifyToken<DeviceToken>(password, "device");
|
||||
if (claims.aud !== username) {
|
||||
throw new ApiError("Username does not match token", ErrorCode.BadRequest);
|
||||
}
|
||||
res.status(200).send({
|
||||
username, id: claims.id,
|
||||
});
|
||||
router.post("/auth", async (req, res) => {
|
||||
const body = req.body;
|
||||
const { username, password, topic, acc } = body;
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
throw new ApiError(
|
||||
"Must specify a username and password",
|
||||
ErrorCode.BadRequest
|
||||
);
|
||||
}
|
||||
if (username === SUPERUSER) {
|
||||
await verifyToken<SuperuserToken>(password, "superuser");
|
||||
return res.status(200).send({ username });
|
||||
}
|
||||
const claims = await verifyToken<DeviceToken>(password, "device");
|
||||
if (claims.aud !== username) {
|
||||
throw new ApiError("Username does not match token", ErrorCode.BadRequest);
|
||||
}
|
||||
res.status(200).send({
|
||||
username,
|
||||
id: claims.id
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/superuser", async (req, res) => {
|
||||
const { username } = req.body;
|
||||
if (typeof username !== "string") {
|
||||
throw new ApiError("Must specify a username", ErrorCode.BadRequest);
|
||||
}
|
||||
if (username !== SUPERUSER) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
res.status(200).send();
|
||||
});
|
||||
router.post("/superuser", async (req, res) => {
|
||||
const { username } = req.body;
|
||||
if (typeof username !== "string") {
|
||||
throw new ApiError("Must specify a username", ErrorCode.BadRequest);
|
||||
}
|
||||
if (username !== SUPERUSER) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
router.post("/acl", async (req, res) => {
|
||||
const { username, topic, clientid, acc } = req.body;
|
||||
if (typeof username !== "string" || typeof topic !== "string") {
|
||||
throw new ApiError("username and topic must be specified as strings", ErrorCode.BadRequest);
|
||||
}
|
||||
const prefix = DEVICE_PREFIX + "/" + username;
|
||||
if (!topic.startsWith(prefix)) {
|
||||
throw new ApiError(`device ${username} cannot access topic ${topic}`);
|
||||
}
|
||||
res.status(200).send();
|
||||
});
|
||||
router.post("/acl", async (req, res) => {
|
||||
const { username, topic, clientid, acc } = req.body;
|
||||
if (typeof username !== "string" || typeof topic !== "string") {
|
||||
throw new ApiError(
|
||||
"username and topic must be specified as strings",
|
||||
ErrorCode.BadRequest
|
||||
);
|
||||
}
|
||||
const prefix = DEVICE_PREFIX + "/" + username;
|
||||
if (!topic.startsWith(prefix)) {
|
||||
throw new ApiError(`device ${username} cannot access topic ${topic}`);
|
||||
}
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
return router;
|
||||
}
|
||||
|
@ -9,73 +9,81 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization";
|
||||
import { ServerState } from "@server/state";
|
||||
|
||||
export function token(state: ServerState) {
|
||||
const router = PromiseRouter();
|
||||
const router = PromiseRouter();
|
||||
|
||||
async function passwordGrant(body: httpApi.TokenGrantPasswordRequest, res: Express.Response): Promise<User> {
|
||||
const { username, password } = body;
|
||||
if (!body || !username || !password) {
|
||||
throw new ApiError("Must specify username and password");
|
||||
}
|
||||
const user = await state.database.users.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new ApiError("User does not exist");
|
||||
}
|
||||
const passwordMatches = await user.comparePassword(password);
|
||||
if (passwordMatches) {
|
||||
return user;
|
||||
} else {
|
||||
throw new ApiError("Invalid user credentials");
|
||||
}
|
||||
async function passwordGrant(
|
||||
body: httpApi.TokenGrantPasswordRequest,
|
||||
res: Express.Response
|
||||
): Promise<User> {
|
||||
const { username, password } = body;
|
||||
if (!body || !username || !password) {
|
||||
throw new ApiError("Must specify username and password");
|
||||
}
|
||||
|
||||
async function refreshGrant(body: httpApi.TokenGrantRefreshRequest, res: Express.Response): Promise<User> {
|
||||
const { refresh_token } = body;
|
||||
if (!body || !refresh_token) {
|
||||
throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken);
|
||||
}
|
||||
const claims = await authentication.verifyToken(refresh_token);
|
||||
if (claims.type !== "refresh") {
|
||||
throw new ApiError("Not a refresh token", ErrorCode.BadToken);
|
||||
}
|
||||
const user = await state.database.users.findOne(claims.aud);
|
||||
if (!user) {
|
||||
throw new ApiError("User no longer exists", ErrorCode.BadToken);
|
||||
}
|
||||
return user;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/grant", async (req, res) => {
|
||||
const body: httpApi.TokenGrantRequest = req.body;
|
||||
let user: User;
|
||||
if (body.grant_type === "password") {
|
||||
user = await passwordGrant(body, res);
|
||||
} else if (body.grant_type === "refresh") {
|
||||
user = await refreshGrant(body, res);
|
||||
} else {
|
||||
throw new ApiError("Invalid grant_type");
|
||||
}
|
||||
const [access_token, refresh_token] = await Promise.all([
|
||||
await authentication.generateAccessToken(user),
|
||||
await authentication.generateRefreshToken(user),
|
||||
]);
|
||||
const response: httpApi.TokenGrantResponse = {
|
||||
access_token, refresh_token,
|
||||
};
|
||||
res.json(response);
|
||||
async function refreshGrant(
|
||||
body: httpApi.TokenGrantRefreshRequest,
|
||||
res: Express.Response
|
||||
): Promise<User> {
|
||||
const { refresh_token } = body;
|
||||
if (!body || !refresh_token) {
|
||||
throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken);
|
||||
}
|
||||
const claims = await authentication.verifyToken(refresh_token);
|
||||
if (claims.type !== "refresh") {
|
||||
throw new ApiError("Not a refresh token", ErrorCode.BadToken);
|
||||
}
|
||||
const user = await state.database.users.findOne(claims.aud);
|
||||
if (!user) {
|
||||
throw new ApiError("User no longer exists", ErrorCode.BadToken);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
router.post("/grant", async (req, res) => {
|
||||
const body: httpApi.TokenGrantRequest = req.body;
|
||||
let user: User;
|
||||
if (body.grant_type === "password") {
|
||||
user = await passwordGrant(body, res);
|
||||
} else if (body.grant_type === "refresh") {
|
||||
user = await refreshGrant(body, res);
|
||||
} else {
|
||||
throw new ApiError("Invalid grant_type");
|
||||
}
|
||||
// tslint:disable-next-line:variable-name
|
||||
const [access_token, refresh_token] = await Promise.all([
|
||||
await authentication.generateAccessToken(user),
|
||||
await authentication.generateRefreshToken(user)
|
||||
]);
|
||||
const response: httpApi.TokenGrantResponse = {
|
||||
access_token,
|
||||
refresh_token
|
||||
};
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => {
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
const token = await authentication.generateDeviceRegistrationToken();
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
router.post("/verify", verifyAuthorization(), async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
token: req.token
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => {
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
const token = await authentication.generateDeviceRegistrationToken();
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
router.post("/verify", verifyAuthorization(), async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
token: req.token,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
return router;
|
||||
}
|
||||
|
@ -7,42 +7,42 @@ import { verifyAuthorization } from "@server/express/verifyAuthorization";
|
||||
import { ServerState } from "@server/state";
|
||||
|
||||
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;
|
||||
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) => {
|
||||
state.database.users.findAll()
|
||||
.then((users_) => {
|
||||
res.json({
|
||||
data: users_,
|
||||
});
|
||||
});
|
||||
router.get("/", (req, res) => {
|
||||
state.database.users.findAll().then(_users => {
|
||||
res.json({
|
||||
data: _users
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:username", async (req, res) => {
|
||||
const user = await getUser(req.params);
|
||||
res.json({
|
||||
data: user,
|
||||
});
|
||||
router.get("/:username", async (req, res) => {
|
||||
const user = await getUser(req.params);
|
||||
res.json({
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:username/devices", async (req, res) => {
|
||||
const user = await getUser(req.params);
|
||||
res.json({
|
||||
data: user.devices,
|
||||
});
|
||||
router.get("/:username/devices", async (req, res) => {
|
||||
const user = await getUser(req.params);
|
||||
res.json({
|
||||
data: user.devices
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
return router;
|
||||
}
|
||||
|
@ -2,13 +2,17 @@ import * as express from "express";
|
||||
|
||||
import ApiError from "@common/ApiError";
|
||||
|
||||
const errorHandler: express.ErrorRequestHandler =
|
||||
(err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.statusCode).json(err.toJSON());
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
const errorHandler: express.ErrorRequestHandler = (
|
||||
err: any,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.statusCode).json(err.toJSON());
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default errorHandler;
|
||||
|
@ -8,17 +8,17 @@ import requestLogger from "./requestLogger";
|
||||
import serveApp from "./serveApp";
|
||||
|
||||
export function createApp(state: ServerState) {
|
||||
const app = express();
|
||||
const app = express();
|
||||
|
||||
app.use(requestLogger);
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(requestLogger);
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
|
||||
app.use("/api", createApi(state));
|
||||
app.use("/api", createApi(state));
|
||||
|
||||
serveApp(app);
|
||||
serveApp(app);
|
||||
|
||||
app.use(errorHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
return app;
|
||||
}
|
||||
|
@ -8,8 +8,8 @@ const paths = require("paths");
|
||||
const index = path.join(paths.publicDir, "index.html");
|
||||
|
||||
export default function serveApp(app: Express) {
|
||||
app.use(serveStatic(paths.clientBuildDir));
|
||||
app.get("/*", (req, res) => {
|
||||
res.sendFile(index);
|
||||
});
|
||||
app.use(serveStatic(paths.clientBuildDir));
|
||||
app.get("/*", (req, res) => {
|
||||
res.sendFile(index);
|
||||
});
|
||||
}
|
||||
|
@ -6,36 +6,44 @@ import * as tok from "@common/TokenClaims";
|
||||
import { verifyToken } from "@server/authentication";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
token?: tok.AccessToken;
|
||||
}
|
||||
namespace Express {
|
||||
interface Request {
|
||||
token?: tok.AccessToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifyAuthorizationOpts {
|
||||
type: tok.TokenClaims["type"];
|
||||
type: tok.TokenClaims["type"];
|
||||
}
|
||||
|
||||
export function verifyAuthorization(options?: Partial<VerifyAuthorizationOpts>): Express.RequestHandler {
|
||||
const opts: VerifyAuthorizationOpts = {
|
||||
type: "access",
|
||||
...options,
|
||||
};
|
||||
return (req, res, next) => {
|
||||
const fun = async () => {
|
||||
const bearer = req.headers.authorization;
|
||||
if (!bearer) {
|
||||
throw new ApiError("No Authorization header specified", ErrorCode.BadToken);
|
||||
}
|
||||
const matches = /^Bearer (.*)$/.exec(bearer);
|
||||
if (!matches || !matches[1]) {
|
||||
throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken);
|
||||
}
|
||||
const token = matches[1];
|
||||
export function verifyAuthorization(
|
||||
options?: Partial<VerifyAuthorizationOpts>
|
||||
): Express.RequestHandler {
|
||||
const opts: VerifyAuthorizationOpts = {
|
||||
type: "access",
|
||||
...options
|
||||
};
|
||||
return (req, res, next) => {
|
||||
const fun = async () => {
|
||||
const bearer = req.headers.authorization;
|
||||
if (!bearer) {
|
||||
throw new ApiError(
|
||||
"No Authorization header specified",
|
||||
ErrorCode.BadToken
|
||||
);
|
||||
}
|
||||
const matches = /^Bearer (.*)$/.exec(bearer);
|
||||
if (!matches || !matches[1]) {
|
||||
throw new ApiError(
|
||||
"Invalid Authorization header, must be Bearer",
|
||||
ErrorCode.BadToken
|
||||
);
|
||||
}
|
||||
const token = matches[1];
|
||||
|
||||
req.token = await verifyToken(token, opts.type) as any;
|
||||
};
|
||||
fun().then(() => next(null), (err) => next(err));
|
||||
req.token = (await verifyToken(token, opts.type)) as any;
|
||||
};
|
||||
fun().then(() => next(null), err => next(err));
|
||||
};
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import "./env";
|
||||
import "./configureLogger";
|
||||
|
||||
import log from "@common/logger";
|
||||
import {Server} from "http";
|
||||
import { Server } from "http";
|
||||
import * as WebSocket from "ws";
|
||||
|
||||
import { ServerState } from "./state";
|
||||
@ -20,15 +20,16 @@ const port = +(process.env.PORT || 8080);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
|
||||
const server = new Server(app);
|
||||
const webSocketServer = new WebSocket.Server({server});
|
||||
const webSocketServer = new WebSocket.Server({ server });
|
||||
webSocketApi.listen(webSocketServer);
|
||||
|
||||
state.start()
|
||||
.then(() => {
|
||||
server.listen(port, host, () => {
|
||||
log.info(`listening at ${host}:${port}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "error starting server");
|
||||
state
|
||||
.start()
|
||||
.then(() => {
|
||||
server.listen(port, host, () => {
|
||||
log.info(`listening at ${host}:${port}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
log.error({ err }, "error starting server");
|
||||
});
|
||||
|
@ -6,137 +6,149 @@ import { Transform, TransformCallback } from "stream";
|
||||
type Level = "default" | 60 | 50 | 40 | 30 | 20 | 10;
|
||||
|
||||
const levels = {
|
||||
default: "USERLVL",
|
||||
60: "FATAL",
|
||||
50: "ERROR",
|
||||
40: "WARN",
|
||||
30: "INFO",
|
||||
20: "DEBUG",
|
||||
10: "TRACE",
|
||||
default: "USERLVL",
|
||||
60: "FATAL",
|
||||
50: "ERROR",
|
||||
40: "WARN",
|
||||
30: "INFO",
|
||||
20: "DEBUG",
|
||||
10: "TRACE"
|
||||
};
|
||||
|
||||
const levelColors = {
|
||||
default: chalk.white.underline,
|
||||
60: chalk.bgRed.underline,
|
||||
50: chalk.red.underline,
|
||||
40: chalk.yellow.underline,
|
||||
30: chalk.green.underline,
|
||||
20: chalk.blue.underline,
|
||||
10: chalk.grey.underline,
|
||||
default: chalk.white.underline,
|
||||
60: chalk.bgRed.underline,
|
||||
50: chalk.red.underline,
|
||||
40: chalk.yellow.underline,
|
||||
30: chalk.green.underline,
|
||||
20: chalk.blue.underline,
|
||||
10: chalk.grey.underline
|
||||
};
|
||||
|
||||
const standardKeys = ["pid", "hostname", "name", "level", "time", "v", "source", "msg"];
|
||||
const standardKeys = [
|
||||
"pid",
|
||||
"hostname",
|
||||
"name",
|
||||
"level",
|
||||
"time",
|
||||
"v",
|
||||
"source",
|
||||
"msg"
|
||||
];
|
||||
|
||||
function formatter(value: any) {
|
||||
let line = formatTime(value, " ");
|
||||
line += formatSource(value);
|
||||
line += asColoredLevel(value);
|
||||
let line = formatTime(value, " ");
|
||||
line += formatSource(value);
|
||||
line += asColoredLevel(value);
|
||||
|
||||
// line += " (";
|
||||
// if (value.name) {
|
||||
// line += value.name + "/";
|
||||
// }
|
||||
// line += value.pid + " on " + value.hostname + ")";
|
||||
// line += " (";
|
||||
// if (value.name) {
|
||||
// line += value.name + "/";
|
||||
// }
|
||||
// line += value.pid + " on " + value.hostname + ")";
|
||||
|
||||
const isRequest = value.req && value.res;
|
||||
const isRequest = value.req && value.res;
|
||||
|
||||
line += ": ";
|
||||
if (isRequest) {
|
||||
line += chalk.reset(formatRequest(value));
|
||||
return line;
|
||||
}
|
||||
if (value.msg) {
|
||||
line += chalk.cyan(value.msg);
|
||||
}
|
||||
if (value.err) {
|
||||
line += "\n " + withSpaces(value.err.stack) + "\n";
|
||||
} else {
|
||||
line += filter(value);
|
||||
}
|
||||
line += "\n";
|
||||
line += ": ";
|
||||
if (isRequest) {
|
||||
line += chalk.reset(formatRequest(value));
|
||||
return line;
|
||||
}
|
||||
if (value.msg) {
|
||||
line += chalk.cyan(value.msg);
|
||||
}
|
||||
if (value.err) {
|
||||
line += "\n " + withSpaces(value.err.stack) + "\n";
|
||||
} else {
|
||||
line += filter(value);
|
||||
}
|
||||
line += "\n";
|
||||
return line;
|
||||
}
|
||||
|
||||
function formatRequest(value: any): string {
|
||||
const matches = /Content-Length: (\d+)/.exec(value.res.header);
|
||||
const contentLength = matches ? matches[1] : null;
|
||||
return `${value.req.remoteAddress} - ` +
|
||||
`"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` +
|
||||
`${value.responseTime} ms - ${contentLength}`;
|
||||
const matches = /Content-Length: (\d+)/.exec(value.res.header);
|
||||
const contentLength = matches ? matches[1] : null;
|
||||
return (
|
||||
`${value.req.remoteAddress} - ` +
|
||||
`"${value.req.method} ${value.req.url} ${value.res.statusCode}" ` +
|
||||
`${value.responseTime} ms - ${contentLength}`
|
||||
);
|
||||
}
|
||||
|
||||
function withSpaces(value: string): string {
|
||||
const lines = value.split("\n");
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
lines[i] = " " + lines[i];
|
||||
}
|
||||
return lines.join("\n");
|
||||
const lines = value.split("\n");
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
lines[i] = " " + lines[i];
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function filter(value: any) {
|
||||
const keys = Object.keys(value);
|
||||
const filteredKeys = standardKeys;
|
||||
let result = "";
|
||||
const keys = Object.keys(value);
|
||||
const filteredKeys = standardKeys;
|
||||
let result = "";
|
||||
|
||||
for (const key of keys) {
|
||||
if (filteredKeys.indexOf(key) < 0) {
|
||||
result += "\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2));
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (filteredKeys.indexOf(key) < 0) {
|
||||
result +=
|
||||
"\n " + key + ": " + withSpaces(JSON.stringify(value[key], null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function asISODate(time: string) {
|
||||
return new Date(time).toISOString();
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function formatTime(value: any, after?: string) {
|
||||
after = after || "";
|
||||
try {
|
||||
if (!value || !value.time) {
|
||||
return "";
|
||||
} else {
|
||||
return "[" + asISODate(value.time) + "]" + after;
|
||||
}
|
||||
} catch (_) {
|
||||
return "";
|
||||
after = after || "";
|
||||
try {
|
||||
if (!value || !value.time) {
|
||||
return "";
|
||||
} else {
|
||||
return "[" + asISODate(value.time) + "]" + after;
|
||||
}
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatSource(value: any) {
|
||||
if (value.source) {
|
||||
return chalk.magenta("(" + value.source + ") ");
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
if (value.source) {
|
||||
return chalk.magenta("(" + value.source + ") ");
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function asColoredLevel(value: any) {
|
||||
const level = value.level as Level;
|
||||
if (levelColors.hasOwnProperty(level)) {
|
||||
return levelColors[level](levels[level]);
|
||||
} else {
|
||||
return levelColors.default(levels.default);
|
||||
}
|
||||
const level = value.level as Level;
|
||||
if (levelColors.hasOwnProperty(level)) {
|
||||
return levelColors[level](levels[level]);
|
||||
} else {
|
||||
return levelColors.default(levels.default);
|
||||
}
|
||||
}
|
||||
|
||||
class PrettyPrintTranform extends Transform {
|
||||
_transform(chunk: any, encoding: string, cb: TransformCallback) {
|
||||
let value: any;
|
||||
try {
|
||||
value = JSON.parse(chunk.toString());
|
||||
} catch (e) {
|
||||
process.stdout.write(chunk.toString() + "\n");
|
||||
return cb();
|
||||
}
|
||||
const line = formatter(value);
|
||||
if (!line) {
|
||||
return cb();
|
||||
}
|
||||
process.stdout.write(line);
|
||||
cb();
|
||||
_transform(chunk: any, encoding: string, cb: TransformCallback) {
|
||||
let value: any;
|
||||
try {
|
||||
value = JSON.parse(chunk.toString());
|
||||
} catch (e) {
|
||||
process.stdout.write(chunk.toString() + "\n");
|
||||
return cb();
|
||||
}
|
||||
const line = formatter(value);
|
||||
if (!line) {
|
||||
return cb();
|
||||
}
|
||||
process.stdout.write(line);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
pump(process.stdin, split(), new PrettyPrintTranform());
|
||||
|
@ -4,30 +4,39 @@ import { SprinklersDevice, User } from "@server/entities";
|
||||
|
||||
@EntityRepository(SprinklersDevice)
|
||||
export class SprinklersDeviceRepository extends Repository<SprinklersDevice> {
|
||||
findByName(name: string) {
|
||||
return this.findOne({ name });
|
||||
}
|
||||
findByName(name: string) {
|
||||
return this.findOne({ name });
|
||||
}
|
||||
|
||||
async userHasAccess(userId: number, deviceId: number): Promise<boolean> {
|
||||
const count = await this.manager
|
||||
.createQueryBuilder(User, "user")
|
||||
.innerJoinAndSelect("user.devices", "sprinklers_device",
|
||||
"user.id = :userId AND sprinklers_device.id = :deviceId",
|
||||
{ userId, deviceId })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
async userHasAccess(userId: number, deviceId: number): Promise<boolean> {
|
||||
const count = await this.manager
|
||||
.createQueryBuilder(User, "user")
|
||||
.innerJoinAndSelect(
|
||||
"user.devices",
|
||||
"sprinklers_device",
|
||||
"user.id = :userId AND sprinklers_device.id = :deviceId",
|
||||
{ userId, deviceId }
|
||||
)
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async findUserDevice(userId: number, deviceId: number): Promise<SprinklersDevice | null> {
|
||||
const user = await this.manager
|
||||
.createQueryBuilder(User, "user")
|
||||
.innerJoinAndSelect("user.devices", "sprinklers_device",
|
||||
"user.id = :userId AND sprinklers_device.id = :deviceId",
|
||||
{ userId, deviceId })
|
||||
.getOne();
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return user.devices![0];
|
||||
async findUserDevice(
|
||||
userId: number,
|
||||
deviceId: number
|
||||
): Promise<SprinklersDevice | null> {
|
||||
const user = await this.manager
|
||||
.createQueryBuilder(User, "user")
|
||||
.innerJoinAndSelect(
|
||||
"user.devices",
|
||||
"sprinklers_device",
|
||||
"user.id = :userId AND sprinklers_device.id = :deviceId",
|
||||
{ userId, deviceId }
|
||||
)
|
||||
.getOne();
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return user.devices![0];
|
||||
}
|
||||
}
|
||||
|
@ -3,30 +3,31 @@ import { EntityRepository, FindOneOptions, Repository } from "typeorm";
|
||||
import { User } from "@server/entities";
|
||||
|
||||
export interface FindUserOptions {
|
||||
devices: boolean;
|
||||
devices: boolean;
|
||||
}
|
||||
|
||||
function applyDefaultOptions(options?: Partial<FindUserOptions>): FindOneOptions<User> {
|
||||
const opts: FindUserOptions = { devices: false, ...options };
|
||||
const relations = [opts.devices && "devices"]
|
||||
.filter(Boolean) as string[];
|
||||
return { relations };
|
||||
function applyDefaultOptions(
|
||||
options?: Partial<FindUserOptions>
|
||||
): FindOneOptions<User> {
|
||||
const opts: FindUserOptions = { devices: false, ...options };
|
||||
const relations = [opts.devices && "devices"].filter(Boolean) as string[];
|
||||
return { relations };
|
||||
}
|
||||
|
||||
@EntityRepository(User)
|
||||
export class UserRepository extends Repository<User> {
|
||||
findAll(options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return super.find(opts);
|
||||
}
|
||||
findAll(options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return super.find(opts);
|
||||
}
|
||||
|
||||
findById(id: number, options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return super.findOne(id, opts);
|
||||
}
|
||||
findById(id: number, options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return super.findOne(id, opts);
|
||||
}
|
||||
|
||||
findByUsername(username: string, options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return this.findOne({ username }, opts);
|
||||
}
|
||||
findByUsername(username: string, options?: Partial<FindUserOptions>) {
|
||||
const opts = applyDefaultOptions(options);
|
||||
return this.findOne({ username }, opts);
|
||||
}
|
||||
}
|
||||
|
@ -4,23 +4,23 @@ import { ServerState } from "@server/state";
|
||||
import { WebSocketConnection } from "./WebSocketConnection";
|
||||
|
||||
export class WebSocketApi {
|
||||
state: ServerState;
|
||||
clients: Set<WebSocketConnection> = new Set();
|
||||
state: ServerState;
|
||||
clients: Set<WebSocketConnection> = new Set();
|
||||
|
||||
constructor(state: ServerState) {
|
||||
this.state = state;
|
||||
}
|
||||
constructor(state: ServerState) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
listen(webSocketServer: WebSocket.Server) {
|
||||
webSocketServer.on("connection", this.handleConnection);
|
||||
}
|
||||
listen(webSocketServer: WebSocket.Server) {
|
||||
webSocketServer.on("connection", this.handleConnection);
|
||||
}
|
||||
|
||||
handleConnection = (socket: WebSocket) => {
|
||||
const client = new WebSocketConnection(this, socket);
|
||||
this.clients.add(client);
|
||||
}
|
||||
handleConnection = (socket: WebSocket) => {
|
||||
const client = new WebSocketConnection(this, socket);
|
||||
this.clients.add(client);
|
||||
};
|
||||
|
||||
removeClient(client: WebSocketConnection) {
|
||||
return this.clients.delete(client);
|
||||
}
|
||||
removeClient(client: WebSocketConnection) {
|
||||
return this.clients.delete(client);
|
||||
}
|
||||
}
|
||||
|
@ -18,245 +18,299 @@ import { WebSocketApi } from "./WebSocketApi";
|
||||
type Disposer = () => void;
|
||||
|
||||
export class WebSocketConnection {
|
||||
api: WebSocketApi;
|
||||
socket: WebSocket;
|
||||
api: WebSocketApi;
|
||||
socket: WebSocket;
|
||||
|
||||
disposers: Array<() => void> = [];
|
||||
// map of device id to disposer function
|
||||
deviceSubscriptions: Map<string, Disposer> = new Map();
|
||||
disposers: Array<() => void> = [];
|
||||
// map of device id to disposer function
|
||||
deviceSubscriptions: Map<string, Disposer> = new Map();
|
||||
|
||||
/// This shall be the user id if the client has been authenticated, null otherwise
|
||||
userId: number | null = null;
|
||||
user: User | null = null;
|
||||
/// This shall be the user id if the client has been authenticated, null otherwise
|
||||
userId: number | null = null;
|
||||
user: User | null = null;
|
||||
|
||||
private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers();
|
||||
private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers();
|
||||
|
||||
get state() {
|
||||
return this.api.state;
|
||||
get state() {
|
||||
return this.api.state;
|
||||
}
|
||||
|
||||
constructor(api: WebSocketApi, socket: WebSocket) {
|
||||
this.api = api;
|
||||
this.socket = socket;
|
||||
|
||||
this.socket.on("message", this.handleSocketMessage);
|
||||
this.socket.on("close", this.onClose);
|
||||
}
|
||||
|
||||
stop = () => {
|
||||
this.socket.close();
|
||||
};
|
||||
|
||||
onClose = (code: number, reason: string) => {
|
||||
log.debug({ code, reason }, "WebSocketConnection closing");
|
||||
this.disposers.forEach(disposer => disposer());
|
||||
this.deviceSubscriptions.forEach(disposer => disposer());
|
||||
this.api.removeClient(this);
|
||||
};
|
||||
|
||||
subscribeBrokerConnection() {
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const updateData: ws.IBrokerConnectionUpdate = {
|
||||
brokerConnected: this.state.mqttClient.connected
|
||||
};
|
||||
this.sendNotification("brokerConnectionUpdate", updateData);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
checkAuthorization() {
|
||||
if (!this.userId || !this.user) {
|
||||
throw new RpcError(
|
||||
"this WebSocket session has not been authenticated",
|
||||
ErrorCode.Unauthorized
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(api: WebSocketApi, socket: WebSocket) {
|
||||
this.api = api;
|
||||
this.socket = socket;
|
||||
|
||||
this.socket.on("message", this.handleSocketMessage);
|
||||
this.socket.on("close", this.onClose);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
stop = () => {
|
||||
this.socket.close();
|
||||
const deviceId = userDevice.deviceId;
|
||||
if (!deviceId) {
|
||||
throw new RpcError(
|
||||
"device has no associated device prefix",
|
||||
ErrorCode.Internal
|
||||
);
|
||||
}
|
||||
return userDevice;
|
||||
}
|
||||
|
||||
onClose = (code: number, reason: string) => {
|
||||
log.debug({ code, reason }, "WebSocketConnection closing");
|
||||
this.disposers.forEach((disposer) => disposer());
|
||||
this.deviceSubscriptions.forEach((disposer) => disposer());
|
||||
this.api.removeClient(this);
|
||||
}
|
||||
sendMessage(data: ws.ServerMessage) {
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
subscribeBrokerConnection() {
|
||||
this.disposers.push(autorun(() => {
|
||||
const updateData: ws.IBrokerConnectionUpdate = {
|
||||
brokerConnected: this.state.mqttClient.connected,
|
||||
};
|
||||
this.sendNotification("brokerConnectionUpdate", updateData);
|
||||
}));
|
||||
}
|
||||
sendNotification<Method extends ws.ServerNotificationMethod>(
|
||||
method: Method,
|
||||
data: ws.IServerNotificationTypes[Method]
|
||||
) {
|
||||
this.sendMessage({ type: "notification", method, data });
|
||||
}
|
||||
|
||||
checkAuthorization() {
|
||||
if (!this.userId || !this.user) {
|
||||
throw new RpcError("this WebSocket session has not been authenticated",
|
||||
ErrorCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
sendResponse<Method extends ws.ClientRequestMethods>(
|
||||
method: Method,
|
||||
id: number,
|
||||
data: ws.ServerResponseData<Method>
|
||||
) {
|
||||
this.sendMessage({ type: "response", method, id, ...data });
|
||||
}
|
||||
|
||||
checkDevice(devId: string) {
|
||||
const userDevice = this.user!.devices!.find((dev) => dev.deviceId === devId);
|
||||
if (userDevice == null) {
|
||||
throw new RpcError("you do not have permission to subscribe to device",
|
||||
ErrorCode.NoPermission, { id: devId });
|
||||
}
|
||||
const deviceId = userDevice.deviceId;
|
||||
if (!deviceId) {
|
||||
throw new RpcError("device has no associated device prefix", ErrorCode.Internal);
|
||||
}
|
||||
return userDevice;
|
||||
}
|
||||
handleSocketMessage = (socketData: WebSocket.Data) => {
|
||||
this.doHandleSocketMessage(socketData).catch(err => {
|
||||
this.onError({ err }, "unhandled error on handling socket message");
|
||||
});
|
||||
};
|
||||
|
||||
sendMessage(data: ws.ServerMessage) {
|
||||
this.socket.send(JSON.stringify(data));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
sendNotification<Method extends ws.ServerNotificationMethod>(
|
||||
method: Method,
|
||||
data: ws.IServerNotificationTypes[Method]) {
|
||||
this.sendMessage({ type: "notification", method, data });
|
||||
private async doHandleSocketMessage(socketData: WebSocket.Data) {
|
||||
if (typeof socketData !== "string") {
|
||||
return this.onError(
|
||||
{ type: typeof socketData },
|
||||
"received invalid socket data type from client",
|
||||
ErrorCode.Parse
|
||||
);
|
||||
}
|
||||
let data: ws.ClientMessage;
|
||||
try {
|
||||
data = JSON.parse(socketData);
|
||||
} catch (err) {
|
||||
return this.onError(
|
||||
{ socketData, err },
|
||||
"received invalid websocket message from client",
|
||||
ErrorCode.Parse
|
||||
);
|
||||
}
|
||||
switch (data.type) {
|
||||
case "request":
|
||||
await this.handleRequest(data);
|
||||
break;
|
||||
default:
|
||||
return this.onError(
|
||||
{ data },
|
||||
"received invalid message type from client",
|
||||
ErrorCode.BadRequest
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sendResponse<Method extends ws.ClientRequestMethods>(
|
||||
method: Method,
|
||||
id: number,
|
||||
data: ws.ServerResponseData<Method>) {
|
||||
this.sendMessage({ type: "response", method, id, ...data });
|
||||
private async handleRequest(request: ws.ClientRequest) {
|
||||
let response: ws.ServerResponseData;
|
||||
try {
|
||||
if (!this.requestHandlers[request.method]) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new RpcError("received invalid client request method");
|
||||
}
|
||||
response = await rpc.handleRequest(this.requestHandlers, request, this);
|
||||
} catch (err) {
|
||||
if (err instanceof RpcError) {
|
||||
log.debug({ err }, "rpc error");
|
||||
response = { result: "error", error: err.toJSON() };
|
||||
} else {
|
||||
log.error(
|
||||
{ method: request.method, err },
|
||||
"unhandled error during processing of client request"
|
||||
);
|
||||
response = {
|
||||
result: "error",
|
||||
error: {
|
||||
code: ErrorCode.Internal,
|
||||
message: "unhandled error during processing of client request",
|
||||
data: err.toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
this.sendResponse(request.method, request.id, response);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async doHandleSocketMessage(socketData: WebSocket.Data) {
|
||||
if (typeof socketData !== "string") {
|
||||
return this.onError({ type: typeof socketData },
|
||||
"received invalid socket data type from client", ErrorCode.Parse);
|
||||
}
|
||||
let data: ws.ClientMessage;
|
||||
try {
|
||||
data = JSON.parse(socketData);
|
||||
} catch (err) {
|
||||
return this.onError({ socketData, err }, "received invalid websocket message from client",
|
||||
ErrorCode.Parse);
|
||||
}
|
||||
switch (data.type) {
|
||||
case "request":
|
||||
await this.handleRequest(data);
|
||||
break;
|
||||
default:
|
||||
return this.onError({ data }, "received invalid message type from client",
|
||||
ErrorCode.BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequest(request: ws.ClientRequest) {
|
||||
let response: ws.ServerResponseData;
|
||||
try {
|
||||
if (!this.requestHandlers[request.method]) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new RpcError("received invalid client request method");
|
||||
}
|
||||
response = await rpc.handleRequest(this.requestHandlers, request, this);
|
||||
} catch (err) {
|
||||
if (err instanceof RpcError) {
|
||||
log.debug({ err }, "rpc error");
|
||||
response = { result: "error", error: err.toJSON() };
|
||||
} else {
|
||||
log.error({ method: request.method, err }, "unhandled error during processing of client request");
|
||||
response = {
|
||||
result: "error", error: {
|
||||
code: ErrorCode.Internal, message: "unhandled error during processing of client request",
|
||||
data: err.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
this.sendResponse(request.method, request.id, response);
|
||||
}
|
||||
|
||||
private onError(data: any, message: string, code: number = ErrorCode.Internal) {
|
||||
log.error(data, message);
|
||||
const errorData: ws.IError = { code, message, data };
|
||||
this.sendNotification("error", errorData);
|
||||
}
|
||||
private onError(
|
||||
data: any,
|
||||
message: string,
|
||||
code: number = ErrorCode.Internal
|
||||
) {
|
||||
log.error(data, message);
|
||||
const errorData: ws.IError = { code, message, data };
|
||||
this.sendNotification("error", errorData);
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketRequestHandlers implements ws.ClientRequestHandlers {
|
||||
async authenticate(this: WebSocketConnection, data: ws.IAuthenticateRequest):
|
||||
Promise<ws.ServerResponseData<"authenticate">> {
|
||||
if (!data.accessToken) {
|
||||
throw new RpcError("no token specified", ErrorCode.BadRequest);
|
||||
}
|
||||
let claims: AccessToken;
|
||||
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() },
|
||||
};
|
||||
async authenticate(
|
||||
this: WebSocketConnection,
|
||||
data: ws.IAuthenticateRequest
|
||||
): Promise<ws.ServerResponseData<"authenticate">> {
|
||||
if (!data.accessToken) {
|
||||
throw new RpcError("no token specified", ErrorCode.BadRequest);
|
||||
}
|
||||
let claims: AccessToken;
|
||||
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()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 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 response: ws.IDeviceSubscribeResponse = {
|
||||
deviceId
|
||||
};
|
||||
return { result: "success", data: response };
|
||||
}
|
||||
|
||||
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 });
|
||||
async deviceUnsubscribe(
|
||||
this: WebSocketConnection,
|
||||
data: ws.IDeviceSubscribeRequest
|
||||
): Promise<ws.ServerResponseData<"deviceUnsubscribe">> {
|
||||
this.checkAuthorization();
|
||||
const userDevice = this.checkDevice(data.deviceId);
|
||||
const deviceId = userDevice.deviceId!;
|
||||
const disposer = this.deviceSubscriptions.get(deviceId);
|
||||
|
||||
this.deviceSubscriptions.set(deviceId, () => {
|
||||
autorunDisposer();
|
||||
device.release();
|
||||
this.deviceSubscriptions.delete(deviceId);
|
||||
});
|
||||
}
|
||||
|
||||
const response: ws.IDeviceSubscribeResponse = {
|
||||
deviceId,
|
||||
};
|
||||
return { result: "success", data: response };
|
||||
if (disposer) {
|
||||
disposer();
|
||||
}
|
||||
|
||||
async deviceUnsubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest):
|
||||
Promise<ws.ServerResponseData<"deviceUnsubscribe">> {
|
||||
this.checkAuthorization();
|
||||
const userDevice = this.checkDevice(data.deviceId);
|
||||
const deviceId = userDevice.deviceId!;
|
||||
const disposer = this.deviceSubscriptions.get(deviceId);
|
||||
const response: ws.IDeviceSubscribeResponse = {
|
||||
deviceId
|
||||
};
|
||||
return { result: "success", data: response };
|
||||
}
|
||||
|
||||
if (disposer) {
|
||||
disposer();
|
||||
}
|
||||
|
||||
const response: ws.IDeviceSubscribeResponse = {
|
||||
deviceId,
|
||||
};
|
||||
return { result: "success", data: response };
|
||||
}
|
||||
|
||||
async deviceCall(this: WebSocketConnection, data: ws.IDeviceCallRequest):
|
||||
Promise<ws.ServerResponseData<"deviceCall">> {
|
||||
this.checkAuthorization();
|
||||
try {
|
||||
const response = await this.doDeviceCallRequest(data);
|
||||
const resData: ws.IDeviceCallResponse = {
|
||||
data: response,
|
||||
};
|
||||
return { result: "success", data: resData };
|
||||
} catch (err) {
|
||||
const e: deviceRequests.ErrorResponseData = err;
|
||||
throw new RpcError(e.message, e.code, e);
|
||||
}
|
||||
async deviceCall(
|
||||
this: WebSocketConnection,
|
||||
data: ws.IDeviceCallRequest
|
||||
): Promise<ws.ServerResponseData<"deviceCall">> {
|
||||
this.checkAuthorization();
|
||||
try {
|
||||
const response = await this.doDeviceCallRequest(data);
|
||||
const resData: ws.IDeviceCallResponse = {
|
||||
data: response
|
||||
};
|
||||
return { result: "success", data: resData };
|
||||
} catch (err) {
|
||||
const e: deviceRequests.ErrorResponseData = err;
|
||||
throw new RpcError(e.message, e.code, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,29 +5,29 @@ import { SUPERUSER } from "@server/express/api/mosquitto";
|
||||
import { Database } from "./Database";
|
||||
|
||||
export class ServerState {
|
||||
mqttUrl: string;
|
||||
mqttClient: mqtt.MqttRpcClient;
|
||||
database: Database;
|
||||
mqttUrl: string;
|
||||
mqttClient: mqtt.MqttRpcClient;
|
||||
database: Database;
|
||||
|
||||
constructor() {
|
||||
const mqttUrl = process.env.MQTT_URL;
|
||||
if (!mqttUrl) {
|
||||
throw new Error("Must specify a MQTT_URL to connect to");
|
||||
}
|
||||
this.mqttUrl = mqttUrl;
|
||||
this.mqttClient = new mqtt.MqttRpcClient({
|
||||
mqttUri: mqttUrl,
|
||||
});
|
||||
this.database = new Database();
|
||||
constructor() {
|
||||
const mqttUrl = process.env.MQTT_URL;
|
||||
if (!mqttUrl) {
|
||||
throw new Error("Must specify a MQTT_URL to connect to");
|
||||
}
|
||||
this.mqttUrl = mqttUrl;
|
||||
this.mqttClient = new mqtt.MqttRpcClient({
|
||||
mqttUri: mqttUrl
|
||||
});
|
||||
this.database = new Database();
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.database.connect();
|
||||
await this.database.createAll();
|
||||
logger.info("created database and tables");
|
||||
async start() {
|
||||
await this.database.connect();
|
||||
await this.database.createAll();
|
||||
logger.info("created database and tables");
|
||||
|
||||
this.mqttClient.username = SUPERUSER;
|
||||
this.mqttClient.password = await generateSuperuserToken();
|
||||
this.mqttClient.start();
|
||||
}
|
||||
this.mqttClient.username = SUPERUSER;
|
||||
this.mqttClient.password = await generateSuperuserToken();
|
||||
this.mqttClient.start();
|
||||
}
|
||||
}
|
||||
|
@ -26,11 +26,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
"references": [{
|
||||
"path": "../common"
|
||||
}],
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
]
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user