Merge remote-tracking branch 'upstream/master' into patch-13

This commit is contained in:
Ranlvor 2018-06-28 21:48:31 +02:00
commit b868156deb
Signed by untrusted user who does not match committer: Ranlvor
GPG key ID: 5E12D04750EF6F8E
24 changed files with 1145 additions and 1940 deletions

View file

@ -8,9 +8,9 @@
1. run `yarn` to install all dependencies. 1. run `yarn` to install all dependencies.
2. run `yarn watch CONFIG` to run a local build server that automatically builds 2. run `yarn watch CONFIG` to run a local build server that automatically builds
your the mqtt control map for the given CONFIG everytime something changes. your the mqtt control map for the given CONFIG everytime something changes.
3. run `yarn build CONFIG` to create just a single build of the mqtt control map 3. run `yarn dev CONFIG` to create just a single build of the mqtt control map
for the given config. for the given config.
4. run `yarn production-build CONFIG` to generate all files for production use. 4. run `yarn build CONFIG` to generate all files for production use.
## Config ## Config

View file

@ -1,6 +1,8 @@
// @flow // @flow
import type { Config } from "config/flowtypes"; import type { Config } from "config/flowtypes";
import { hex, rgb, rgba, rainbow } from "config/colors"; import { hex, rgb, rgba, rainbow } from "config/colors";
import * as types from "config/types";
import { mdi } from "config/icon";
import { esper_topics, esper_statistics } from "./utils"; import { esper_topics, esper_statistics } from "./utils";
const config : Config = { const config : Config = {
@ -12,16 +14,18 @@ const config : Config = {
topics: [ topics: [
{ {
hauptraum_table_light: { hauptraum_table_light: {
command: "/public/sensoren/TPH/leinwand/control", command: {
state: "test", name: "/public/sensoren/TPH/leinwand/control",
defaultValue: "A1 ON", type: types.option({ "A1 ON": "on", "A1 OFF": "off" })
values: { on: "A1 ON", off: "A1 OFF" } },
defaultValue: "off"
}, },
hauptraum_table_light_on_hack: { hauptraum_table_light_on_hack: {
command: "/public/sensoren/TPH/leinwand/control", command: {
state: "test", name: "/public/sensoren/TPH/leinwand/control",
defaultValue: "A1 OFF", type: types.option({ "A1 ON": "on", "A1 OFF": "off" })
values: { on: "A1 ON", off: "A1 OFF" } },
defaultValue: "on"
} }
} }
], ],
@ -29,20 +33,20 @@ const config : Config = {
hauptraum_table_light: { hauptraum_table_light: {
name: "Hauptraum Tisch", name: "Hauptraum Tisch",
position: [450, 450], position: [450, 450],
icon: "white-balance-iridescent", icon: mdi("white-balance-iridescent"),
iconColor: () => hex("#000000"), iconColor: () => hex("#000000"),
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
text: "Licht", text: "Licht",
topic: "hauptraum_table_light", topic: "hauptraum_table_light",
icon: "power" icon: mdi("power")
}, },
{ {
type: "toggle", type: "toggle",
text: "Licht", text: "Licht",
topic: "hauptraum_table_light_on_hack", topic: "hauptraum_table_light_on_hack",
icon: "power" icon: mdi("power")
} }
] ]
} }

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,44 @@
// @flow // @flow
import type { ControlUI } from "config/flowtypes"; import type { ControlUI } from "config/flowtypes";
import { mdi } from "config/icon";
import * as types from "config/types";
export const esper_topics = (chip_id: string, name: string) => ({ export const esper_topics = (chip_id: string, name: string) => ({
[ `esper_${name}_version` ]: { [ `esper_${name}_version` ]: {
state: `/service/esper/${chip_id}/info`, state: {
command: "", name: `/service/esper/${chip_id}/info`,
defaultValue: "UNKNOWN", type: types.json("version.esper")
values: {}, },
type: msg => JSON.parse(msg.toString()).version.esper defaultValue: "UNKNOWN"
}, },
[ `esper_${name}_ip` ]: { [ `esper_${name}_ip` ]: {
state: `/service/esper/${chip_id}/info`, state: {
command: "", name: `/service/esper/${chip_id}/info`,
defaultValue: "UNKNOWN", type: types.json("network.ip")
values: {}, },
type: msg => JSON.parse(msg.toString()).network.ip defaultValue: "UNKNOWN"
}, },
[ `esper_${name}_rssi` ]: { [ `esper_${name}_rssi` ]: {
state: `/service/esper/${chip_id}/info`, state: {
command: "", name: `/service/esper/${chip_id}/info`,
defaultValue: "UNKNOWN", type: types.json("wifi.rssi")
values: {}, },
type: msg => JSON.parse(msg.toString()).wifi.rssi defaultValue: "UNKNOWN"
}, },
[ `esper_${name}_uptime` ]: { [ `esper_${name}_uptime` ]: {
state: `/service/esper/${chip_id}/info`, state: {
command: "", name: `/service/esper/${chip_id}/info`,
defaultValue: "UNKNOWN",
values: {},
type: msg => new Date(JSON.parse(msg.toString()).time.startup * 1000) type: msg => new Date(JSON.parse(msg.toString()).time.startup * 1000)
.toLocaleString() .toLocaleString()
}, },
[ `esper_${name}_device` ]: {
state: `/service/esper/${chip_id}/info`,
command: "",
defaultValue: "UNKNOWN", defaultValue: "UNKNOWN",
values: {}, },
type: msg => JSON.parse(msg.toString()).device [ `esper_${name}_device` ]: {
state: {
name: `/service/esper/${chip_id}/info`,
type: types.json("device")
},
defaultValue: "UNKNOWN"
} }
}); });
@ -45,22 +47,53 @@ export const floalt = {
brightness: (light_id: string) => `floalt_${light_id}_brightness`, brightness: (light_id: string) => `floalt_${light_id}_brightness`,
topics: (light_id: string) => ({ topics: (light_id: string) => ({
[ `floalt_${light_id}_color` ]: { [ `floalt_${light_id}_color` ]: {
state: `/service/openhab/out/tradfri_0220_gwb8d7af2b448f_${light_id}_color_temperature/state`, state: {
command: `/service/openhab/in/tradfri_0220_gwb8d7af2b448f_${light_id}_color_temperature/command`, name: `/service/openhab/out/tradfri_0220_gwb8d7af2b448f_${light_id}_color_temperature/state`,
defaultValue: "0", type: types.string
values: {} },
command: {
name: `/service/openhab/in/tradfri_0220_gwb8d7af2b448f_${light_id}_color_temperature/command`,
type: types.string
},
defaultValue: "0"
}, },
[ `floalt_${light_id}_brightness` ]: { [ `floalt_${light_id}_brightness` ]: {
state: `/service/openhab/out/tradfri_0220_gwb8d7af2b448f_${light_id}_brightness/state`, state: {
command: `/service/openhab/in/tradfri_0220_gwb8d7af2b448f_${light_id}_brightness/command`, name: `/service/openhab/out/tradfri_0220_gwb8d7af2b448f_${light_id}_brightness/state`,
defaultValue: "0", type: types.string
values: {} },
command: {
name: `/service/openhab/in/tradfri_0220_gwb8d7af2b448f_${light_id}_brightness/command`,
type: types.string
},
defaultValue: "0"
}
})
}
export const tradfri_remote = {
level: (remote_id: string) => `tradfri_remote_${remote_id}_level`,
low: (remote_id: string) => `tradfri_remote_${remote_id}_low`,
topics: (remote_id: string) => ({
[ `tradfri_remote_${remote_id}_level` ]: {
state: {
name: `/service/openhab/out/tradfri_0830_gwb8d7af2b448f_${remote_id}_battery_level/state`,
type: types.string
},
defaultValue: "0"
},
[ `tradfri_remote_${remote_id}_low` ]: {
state: {
name: `/service/openhab/out/tradfri_0830_gwb8d7af2b448f_${remote_id}_battery_low/state`,
type: types.option({ ON: "true", OFF: "false" })
},
defaultValue: "false",
} }
}) })
} }
export const esper_statistics = (name: string, export const esper_statistics = (name: string,
prev_ui: Array<ControlUI> = []) => ( prev_ui: Array<ControlUI> = []): Array<ControlUI> => (
prev_ui.concat([ prev_ui.concat([
{ {
type: "section", type: "section",
@ -69,31 +102,31 @@ export const esper_statistics = (name: string,
{ {
type: "text", type: "text",
text: "Device Variant", text: "Device Variant",
icon: "chart-donut", icon: mdi("chart-donut"),
topic: `esper_${name}_device` topic: `esper_${name}_device`
}, },
{ {
type: "text", type: "text",
text: "Version", text: "Version",
icon: "source-branch", icon: mdi("source-branch"),
topic: `esper_${name}_version` topic: `esper_${name}_version`
}, },
{ {
type: "text", type: "text",
text: "IP", text: "IP",
icon: "access-point-network", icon: mdi("access-point-network"),
topic: `esper_${name}_ip` topic: `esper_${name}_ip`
}, },
{ {
type: "text", type: "text",
text: "RSSI", text: "RSSI",
icon: "wifi", icon: mdi("wifi"),
topic: `esper_${name}_rssi` topic: `esper_${name}_rssi`
}, },
{ {
type: "text", type: "text",
text: "Running since…", text: "Running since…",
icon: "av-timer", icon: mdi("av-timer"),
topic: `esper_${name}_uptime` topic: `esper_${name}_uptime`
} }
]) ])

View file

@ -5,6 +5,7 @@
.leaflet-container { .leaflet-container {
height: calc(100vh - 64px); height: calc(100vh - 64px);
max-height: 100%; max-height: 100%;
background: rgba(239,239,203,.59);
} }
body { body {
margin: 0; margin: 0;

View file

@ -4,20 +4,19 @@
"author": "uwap <me+mqttmap.package.json@uwap.name>", "author": "uwap <me+mqttmap.package.json@uwap.name>",
"description": "control devices via mqtt on a beautiful map of your space", "description": "control devices via mqtt on a beautiful map of your space",
"scripts": { "scripts": {
"build": "webpack --bail --config webpack.dev.js --env", "build": "webpack --bail --config webpack.config.js -p --env",
"production-build": "webpack --bail --config webpack.prod.js --env", "dev": "webpack --bail --config webpack.config.js --mode development --env",
"watch": "webpack-dev-server --open --config webpack.dev.js --env", "watch": "webpack-dev-server --open --config webpack.config.js --mode development --env",
"travis": "./travis.sh", "travis": "./travis.sh",
"lint": "eslint -- --ext js --ext jsx src/", "lint": "eslint --ext js --ext jsx src/",
"precommit": "yarn lint" "precommit": "yarn lint"
}, },
"dependencies": { "dependencies": {
"babel-preset-env": "^1.6.0", "@material-ui/core": "^1.2.1",
"@material-ui/lab": "^1.0.0-alpha.5",
"@mdi/font": "^2.0.46",
"leaflet": "^1.3.1", "leaflet": "^1.3.1",
"lodash-es": "^4.17.4", "lodash-es": "^4.17.4",
"material-ui": "npm:material-ui@next",
"material-ui-old": "npm:material-ui@latest",
"mdi": "^2.0.46",
"mqtt": "^2.14.0", "mqtt": "^2.14.0",
"react": "^16.0.0", "react": "^16.0.0",
"react-dom": "^16.0.0", "react-dom": "^16.0.0",
@ -31,25 +30,23 @@
"babel-eslint": "^8.0.1", "babel-eslint": "^8.0.1",
"babel-loader": "^7.1.1", "babel-loader": "^7.1.1",
"babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1",
"babel-preset-env": "^1.6.0",
"babel-preset-react": "^6.24.1", "babel-preset-react": "^6.24.1",
"clean-webpack-plugin": "^0.1.18", "clean-webpack-plugin": "^0.1.18",
"css-loader": "^0.28.9", "css-loader": "^0.28.9",
"eslint": "^4.16.0", "eslint": "^5.0.1",
"eslint-plugin-flowtype": "^2.42.0", "eslint-plugin-flowtype": "^2.42.0",
"eslint-plugin-react": "^7.6.1", "eslint-plugin-react": "^7.6.1",
"extract-text-webpack-plugin": "next",
"file-loader": "^1.1.5", "file-loader": "^1.1.5",
"flow": "^0.2.3", "flow": "^0.2.3",
"flow-bin": "^0.69.0", "flow-bin": "^0.75.0",
"flow-typed": "^2.3.0", "flow-typed": "^2.3.0",
"html-webpack-plugin": "^3.1.0", "html-webpack-plugin": "^3.1.0",
"husky": "^0.14.3", "husky": "^0.14.3",
"lodash-webpack-plugin": "^0.11.4",
"style-loader": "^0.21.0", "style-loader": "^0.21.0",
"webpack": "^4.3.0", "webpack": "^4.3.0",
"webpack-cli": "^2.0.13", "webpack-cli": "^3.0.0",
"webpack-dev-server": "^3.1.1", "webpack-dev-server": "^3.1.1",
"webpack-merge": "^4.1.1",
"webpack-shell-plugin": "^0.5.0" "webpack-shell-plugin": "^0.5.0"
}, },
"license": "MIT" "license": "MIT"

View file

@ -8,19 +8,16 @@ import merge from "lodash/merge";
import type { Config, Control, Topics } from "config/flowtypes"; import type { Config, Control, Topics } from "config/flowtypes";
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; import MuiThemeProvider from "@material-ui/core/styles/MuiThemeProvider";
import createMuiTheme from "material-ui/styles/createMuiTheme"; import createMuiTheme from "@material-ui/core/styles/createMuiTheme";
import withStyles from "material-ui/styles/withStyles"; import withStyles from "@material-ui/core/styles/withStyles";
import * as Colors from "material-ui/colors"; import * as Colors from "@material-ui/core/colors";
import SideBar from "components/SideBar"; import SideBar from "components/SideBar";
import ControlMap from "components/ControlMap"; import ControlMap from "components/ControlMap";
import TopBar from "components/TopBar"; import TopBar from "components/TopBar";
import UiItemList from "components/UiItemList"; import UiItemList from "components/UiItemList";
import keyOf from "utils/keyOf";
import { controlGetIcon } from "utils/parseIconName";
import connectMqtt from "../connectMqtt"; import connectMqtt from "../connectMqtt";
export type AppProps = { export type AppProps = {
@ -31,7 +28,7 @@ export type AppState = {
selectedControl: ?Control, selectedControl: ?Control,
drawerOpened: boolean, drawerOpened: boolean,
mqttState: State, mqttState: State,
mqttSend: (topic: string, value: Actual) => void, mqttSend: (topic: string, value: Buffer) => void,
mqttConnected: boolean, mqttConnected: boolean,
}; };
@ -41,16 +38,15 @@ class App extends React.PureComponent<AppProps & Classes, AppState> {
this.state = { this.state = {
selectedControl: null, selectedControl: null,
drawerOpened: false, drawerOpened: false,
mqttState: mapValues(this.topics, (topic) => ({ mqttState: mapValues(this.topics, (topic) => topic.defaultValue),
actual: topic.defaultValue,
internal: keyOf(topic.values, topic.defaultValue)
})),
mqttSend: connectMqtt(props.config.space.mqtt, { mqttSend: connectMqtt(props.config.space.mqtt, {
onMessage: this.receiveMessage.bind(this), onMessage: this.receiveMessage.bind(this),
onConnect: () => this.setState({ mqttConnected: true }), onConnect: () => this.setState({ mqttConnected: true }),
onReconnect: () => this.setState({ mqttConnected: false }), onReconnect: () => this.setState({ mqttConnected: false }),
onDisconnect: () => this.setState({ mqttConnected: false }), onDisconnect: () => this.setState({ mqttConnected: false }),
subscribe: map(this.topics, (x) => x.state) subscribe: map(
filter(keys(this.topics), (x) => this.topics[x].state != null),
(x) => this.topics[x].state.name)
}), }),
mqttConnected: false mqttConnected: false
}; };
@ -77,23 +73,23 @@ class App extends React.PureComponent<AppProps & Classes, AppState> {
}); });
} }
receiveMessage(rawTopic: string, message: Object) { receiveMessage(rawTopic: string, message: Buffer) {
const topics = filter( const topics = filter(
keys(this.topics), keys(this.topics),
(k) => this.topics[k].state === rawTopic (k) => this.topics[k].state != null &&
this.topics[k].state.name === rawTopic
); );
if (topics.length === 0) { if (topics.length === 0) {
return; return;
} }
for (let i in topics) { for (let i in topics) {
// TODO: Remove FlowFixMe
const topic = topics[i]; const topic = topics[i];
const parseValue = this.topics[topic].type; // $FlowFixMe
const parseValue = this.topics[topic].state.type;
const val = parseValue == null ? message.toString() : parseValue(message); const val = parseValue == null ? message.toString() : parseValue(message);
this.setState({mqttState: Object.assign({}, merge(this.state.mqttState, this.setState({mqttState: Object.assign({}, merge(this.state.mqttState,
{ [topic]: { { [topic]: val}))});
actual: val,
internal: keyOf(this.topics[topic].values, val) || val
}}))});
} }
} }
@ -105,15 +101,15 @@ class App extends React.PureComponent<AppProps & Classes, AppState> {
this.setState({drawerOpened: false}); this.setState({drawerOpened: false});
} }
changeState(topic: string, value: Actual) { changeState(topic: string, value: string) {
const rawTopic = this.topics[topic].command; if (this.topics[topic].command == null) {
if (rawTopic == null) {
return; return;
} }
this.state.mqttSend( const rawTopic = this.topics[topic].command.name;
rawTopic, const transformValue = this.topics[topic].command.type;
String(this.topics[topic].values[value] || value) const val =
); transformValue == null ? value : transformValue(Buffer.from(value));
this.state.mqttSend(rawTopic, Buffer.from(val));
} }
render() { render() {
@ -127,8 +123,7 @@ class App extends React.PureComponent<AppProps & Classes, AppState> {
control={this.state.selectedControl} control={this.state.selectedControl}
onCloseRequest={this.closeDrawer.bind(this)} onCloseRequest={this.closeDrawer.bind(this)}
icon={this.state.selectedControl == null ? null : icon={this.state.selectedControl == null ? null :
controlGetIcon(this.state.selectedControl, this.state.selectedControl.icon(this.state.mqttState)}
this.state.mqttState)}
> >
{this.state.selectedControl == null {this.state.selectedControl == null
|| <UiItemList state={this.state.mqttState} || <UiItemList state={this.state.mqttState}

View file

@ -3,8 +3,6 @@ import React from "react";
import { Map, ImageOverlay, Marker, LayersControl } from "react-leaflet"; import { Map, ImageOverlay, Marker, LayersControl } from "react-leaflet";
import { CRS, point, divIcon } from "leaflet"; import { CRS, point, divIcon } from "leaflet";
import map from "lodash/map"; import map from "lodash/map";
import mapValues from "lodash/mapValues";
import parseIconName, { controlGetIcon } from "utils/parseIconName";
import type { Controls, Control } from "config/flowtypes"; import type { Controls, Control } from "config/flowtypes";
@ -50,8 +48,8 @@ export default class ControlMap extends React.PureComponent<ControlMapProps> {
} }
createLeafletIcon(control: Control) { createLeafletIcon(control: Control) {
const icon = controlGetIcon(control, this.props.state); const icon = control.icon(this.props.state);
const iconClass = parseIconName(`${icon} 36px`); const iconClass = `${icon} mdi-36px`;
return divIcon({ return divIcon({
iconSize: point(36, 36), iconSize: point(36, 36),
iconAnchor: point(18, 18), iconAnchor: point(18, 18),
@ -61,10 +59,8 @@ export default class ControlMap extends React.PureComponent<ControlMapProps> {
} }
iconColor(control: Control): string { iconColor(control: Control): string {
const ints = mapValues(this.props.state, (x) => x.internal || x.actual);
const acts = mapValues(this.props.state, (x) => x.actual);
if (control.iconColor != null) { if (control.iconColor != null) {
return control.iconColor(ints, acts, this.props.state); return control.iconColor(this.props.state);
} }
return "#000"; return "#000";
} }

View file

@ -1,31 +1,33 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import withStyles from "material-ui/styles/withStyles"; import withStyles from "@material-ui/core/styles/withStyles";
import Drawer from "material-ui/Drawer"; import Drawer from "@material-ui/core/Drawer";
import Typography from "material-ui/Typography"; import Typography from "@material-ui/core/Typography";
import IconButton from "material-ui/IconButton"; import IconButton from "@material-ui/core/IconButton";
import AppBar from "material-ui/AppBar"; import AppBar from "@material-ui/core/AppBar";
import Toolbar from "material-ui/Toolbar"; import Toolbar from "@material-ui/core/Toolbar";
import List from "material-ui/List"; import List from "@material-ui/core/List";
import { renderIcon } from "utils/parseIconName"; import { renderIcon } from "config/icon";
import type { RawIcon } from "config/icon";
import type { Control } from "config/flowtypes"; import type { Control } from "config/flowtypes";
export type SideBarProps = { export type SideBarProps = {
control: ?Control, control: ?Control,
open: boolean, open: boolean,
onCloseRequest: () => void, onCloseRequest: () => void,
icon?: ?string, icon?: ?RawIcon,
children?: React.Node children?: React.Node
}; };
export type SideBarState = { export type SideBarState = {
}; };
class SideBar extends React.PureComponent<SideBarProps & Classes, SideBarState> type Props = SideBarProps & Classes;
{
constructor(props: SideBarProps & Classes) { class SideBar extends React.PureComponent<Props, SideBarState> {
constructor(props: Props) {
super(props); super(props);
} }

View file

@ -1,10 +1,10 @@
// @flow // @flow
import React from "react"; import React from "react";
import AppBar from "material-ui/AppBar"; import AppBar from "@material-ui/core/AppBar";
import Toolbar from "material-ui/Toolbar"; import Toolbar from "@material-ui/core/Toolbar";
import Typography from "material-ui/Typography"; import Typography from "@material-ui/core/Typography";
import { CircularProgress } from "material-ui/Progress"; import CircularProgress from "@material-ui/core/CircularProgress";
export type TopBarProps = { export type TopBarProps = {
title: string, title: string,

View file

@ -2,31 +2,30 @@
import React from "react"; import React from "react";
import keys from "lodash/keys"; import keys from "lodash/keys";
import map from "lodash/map"; import map from "lodash/map";
import { import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction";
ListItemSecondaryAction, import ListItemText from "@material-ui/core/ListItemText";
ListItemText, import ListSubheader from "@material-ui/core/ListSubheader";
ListSubheader import Switch from "@material-ui/core/Switch";
} from "material-ui/List"; import Input from "@material-ui/core/Input";
import Switch from "material-ui/Switch"; import InputLabel from "@material-ui/core/InputLabel";
import Input, { InputLabel } from "material-ui/Input"; import FormControl from "@material-ui/core/FormControl";
import { FormControl } from "material-ui/Form"; import Select from "@material-ui/core/Select";
import Select from "material-ui/Select"; import MenuItem from "@material-ui/core/MenuItem";
import { MenuItem } from "material-ui/Menu"; import Button from "@material-ui/core/Button";
import Button from "material-ui/Button"; import LinearProgress from "@material-ui/core/LinearProgress";
import { LinearProgress } from "material-ui/Progress"; import SliderComponent from "@material-ui/lab/Slider";
import type { import type {
UIControl, UIToggle, UIDropDown, UILink, UIControl, UIToggle, UIDropDown, UILink,
UISection, UIText, UIProgress UISection, UIText, UIProgress, UISlider
} from "config/flowtypes"; } from "config/flowtypes";
import keyOf from "utils/keyOf"; import keyOf from "utils/keyOf";
import { getInternals, getActuals } from "utils/state";
type UiItemProps<I> = { type UiItemProps<I> = {
item: I, item: I,
state: State, state: State,
onChangeState: (topic: string, nextState: Actual) => void onChangeState: (topic: string, nextState: string) => void
}; };
// eslint-disable-next-line flowtype/no-weak-types // eslint-disable-next-line flowtype/no-weak-types
@ -54,9 +53,7 @@ export default class UiItem<I:Object>
typeof this.props.item.enableCondition == "function") { typeof this.props.item.enableCondition == "function") {
const enableCondition = this.props.item.enableCondition; const enableCondition = this.props.item.enableCondition;
const state = this.props.state; const state = this.props.state;
const internals = getInternals(state); return enableCondition(state);
const actuals = getActuals(state);
return enableCondition(internals, actuals, state);
} else { } else {
return true; return true;
} }
@ -68,7 +65,7 @@ export class UiControl<I: UIControl> extends UiItem<I> {
super(props); super(props);
} }
changeState(next: Actual) { changeState(next: string) {
if (this.props.item.topic == null) { if (this.props.item.topic == null) {
throw new Error( throw new Error(
`Missing topic in ${this.props.item.type} "${this.props.item.text}"` `Missing topic in ${this.props.item.type} "${this.props.item.text}"`
@ -93,19 +90,6 @@ export class UiControl<I: UIControl> extends UiItem<I> {
} }
return value; return value;
} }
isEnabled() {
if (Object.keys(this.props.item).includes("enableCondition") &&
// $FlowFixMe
typeof this.props.item.enableCondition == "function") {
const enableCondition = this.props.item.enableCondition;
const value = this.getValue();
return enableCondition(
value.internal || value.actual, value.actual, this.props.state);
} else {
return true;
}
}
} }
export class Toggle extends UiControl<UIToggle> { export class Toggle extends UiControl<UIToggle> {
@ -113,9 +97,8 @@ export class Toggle extends UiControl<UIToggle> {
const value = this.getValue(); const value = this.getValue();
const control = this.props.item; const control = this.props.item;
const isChecked = control.toggled || const isChecked = control.toggled ||
((i, _a, _s) => i === (control.on || "on")); ((i, _s) => i === (control.on || "on"));
const checked = isChecked( const checked = isChecked(value, this.props.state);
value.internal || value.actual, value.actual, this.props.state);
return checked; return checked;
} }
@ -145,7 +128,7 @@ export class Toggle extends UiControl<UIToggle> {
} }
export class DropDown extends UiControl<UIDropDown> { export class DropDown extends UiControl<UIDropDown> {
runPrimaryAction = (next?: Actual) => { runPrimaryAction = (next?: string) => {
if (this.isEnabled()) { if (this.isEnabled()) {
const control = this.props.item; const control = this.props.item;
const optionKeys = keys(control.options); const optionKeys = keys(control.options);
@ -172,7 +155,7 @@ export class DropDown extends UiControl<UIDropDown> {
return ( return (
<FormControl> <FormControl>
<InputLabel htmlFor={id}>{control.text}</InputLabel> <InputLabel htmlFor={id}>{control.text}</InputLabel>
<Select value={value.internal || value.actual} <Select value={value}
onChange={(event) => this.runPrimaryAction(event.target.value)} onChange={(event) => this.runPrimaryAction(event.target.value)}
disabled={!this.isEnabled()} disabled={!this.isEnabled()}
input={<Input id={id} />} input={<Input id={id} />}
@ -184,6 +167,28 @@ export class DropDown extends UiControl<UIDropDown> {
} }
} }
export class Slider extends UiControl<UISlider> {
runPrimaryAction = (e: ?Event, v: ?number) => {
if (v != null) {
this.changeState(v.toString());
}
}
render() {
return [
<ListItemText key="label" primary={this.props.item.text} />,
<SliderComponent key="slidercomponent"
value={parseFloat(this.getValue())}
min={this.props.item.min || 0} max={this.props.item.max || 0}
step={this.props.item.step || 1}
onChange={(e, v) =>
this.props.item.delayedApply || this.runPrimaryAction(e, v)}
onDragEnd={this.runPrimaryAction}
disabled={!this.isEnabled()} />
];
}
}
export class Link extends UiItem<UILink> { export class Link extends UiItem<UILink> {
runPrimaryAction = () => { runPrimaryAction = () => {
const control = this.props.item; const control = this.props.item;
@ -223,7 +228,7 @@ export class Text extends UiControl<UIText> {
render() { render() {
return [ return [
<ListItemText key="label" secondary={this.props.item.text} />, <ListItemText key="label" secondary={this.props.item.text} />,
<ListItemText key="vr" primary={this.getValue().internal} align="right" /> <ListItemText key="vr" primary={this.getValue()} align="right" />
]; ];
} }
} }
@ -232,7 +237,7 @@ export class Progress extends UiControl<UIProgress> {
render() { render() {
const min = this.props.item.min || 0; const min = this.props.item.min || 0;
const max = this.props.item.max || 100; const max = this.props.item.max || 100;
const val = parseFloat(this.getValue().internal || this.getValue().actual); const val = parseFloat(this.getValue());
const value = val * 100 / max - min; const value = val * 100 / max - min;
return [ return [
<ListItemText key="label" secondary={this.props.item.text} />, <ListItemText key="label" secondary={this.props.item.text} />,
@ -242,3 +247,4 @@ export class Progress extends UiControl<UIProgress> {
]; ];
} }
} }

View file

@ -1,25 +1,18 @@
// @flow // @flow
import React from "react"; import React from "react";
import { import ListItem from "@material-ui/core/ListItem";
ListItem, import ListItemIcon from "@material-ui/core/ListItemIcon";
ListItemIcon, import { renderIcon } from "config/icon";
ListItemSecondaryAction,
ListItemText
} from "material-ui/List";
import { renderIcon } from "utils/parseIconName";
import type { ControlUI, UIControl, UISlider } from "config/flowtypes"; import type { ControlUI } from "config/flowtypes";
// TODO: Use something else import { Toggle, DropDown, Link,
import Slider from "material-ui-old/Slider"; Section, Text, Progress, Slider } from "./UiItem";
import MuiThemeProvider from "material-ui-old/styles/MuiThemeProvider";
import { Toggle, DropDown, Link, Section, Text, Progress } from "./UiItem";
export type UiItemListProps = { export type UiItemListProps = {
controls: Array<ControlUI>, controls: Array<ControlUI>,
state: State, state: State,
onChangeState: (topic: string, nextState: Actual) => void onChangeState: (topic: string, nextState: string) => void
}; };
export default class UiItemList extends React.PureComponent<UiItemListProps> { export default class UiItemList extends React.PureComponent<UiItemListProps> {
@ -40,7 +33,9 @@ export default class UiItemList extends React.PureComponent<UiItemListProps> {
return ( return (
<ListItem key={key}> <ListItem key={key}>
{control.icon == null || {control.icon == null ||
<ListItemIcon>{renderIcon(control.icon, "mdi-24px")}</ListItemIcon>} <ListItemIcon>
{renderIcon(control.icon(this.props.state), "mdi-24px")}
</ListItemIcon>}
{this.renderControl(control)} {this.renderControl(control)}
</ListItem> </ListItem>
); );
@ -70,7 +65,9 @@ export default class UiItemList extends React.PureComponent<UiItemListProps> {
onChangeState={this.props.onChangeState} />; onChangeState={this.props.onChangeState} />;
} }
case "slider": { case "slider": {
return this.renderSlider(control); return <Slider item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
} }
case "text": { case "text": {
return <Text item={control} return <Text item={control}
@ -89,43 +86,4 @@ export default class UiItemList extends React.PureComponent<UiItemListProps> {
} }
} }
} }
getValue(control: UIControl) {
const value = this.props.state[control.topic];
if (value == null) {
throw new Error(
`Unknown topic "${control.topic}" in ${control.type} "${control.text}"`
);
}
return value;
}
renderSlider(control: UISlider) {
const value = this.getValue(control);
const on = (dontApply: ?boolean) => () => {
if (dontApply == null || dontApply === false) {
this.props.onChangeState(control.topic,
// $FlowFixMe
this.val);
}
};
return [
<ListItemText primary={control.text} key="text" />,
<ListItemSecondaryAction key="action">
<MuiThemeProvider>
<Slider value={value.internal || value.actual}
min={control.min || 0}
max={control.max || 100}
step={control.step || 1}
onChange={(_event, next) => {
// $FlowFixMe
this.val = next;
on(control.delayedApply)();
}}
onDragStop={on(false)}
style={{width: 100}}
/></MuiThemeProvider>
</ListItemSecondaryAction>
];
}
} }

View file

@ -1,24 +1,21 @@
// @flow // @flow
import type { Color } from "config/colors"; import type { Color } from "config/colors";
import type { Icon } from "config/icon";
export type TopicType = (msg: Buffer) => any; export type TopicType = (msg: Buffer) => string;
export type StateCommand = {
name: string,
type: TopicType
}
export type Topic = { export type Topic = {
state: string, state?: StateCommand,
command: string, command?: StateCommand,
defaultValue: Actual, defaultValue: string
values: Map<Internal, Actual>,
type?: TopicType
}; };
export type Topics = Map<string, Topic>; export type Topics = Map<string, Topic>;
export type TopicDependentOption<T> = (
internal: Internal, actual: Actual, state: State
) => T;
export type StateDependentOption<T> = (
internals: Map<string, Internal>, actuals: Map<string, Actual>, state: State
) => T;
export interface UIControl { export interface UIControl {
+type: string, +type: string,
+text: string, +text: string,
@ -26,27 +23,27 @@ export interface UIControl {
} }
export interface Enableable { export interface Enableable {
enableCondition?: TopicDependentOption<boolean> enableCondition?: (s: State) => boolean
} }
export type UIToggle = $ReadOnly<{| export type UIToggle = $ReadOnly<{|
type: "toggle", type: "toggle",
text: string, text: string,
topic: string, topic: string,
icon?: string, icon?: Icon,
enableCondition?: TopicDependentOption<boolean>, enableCondition?: (s: State) => boolean,
on?: Actual, on?: string,
off?: Actual, off?: string,
toggled?: TopicDependentOption<boolean> toggled?: (v: string, s: State) => boolean
|}>; |}>;
export type UIDropDown = $ReadOnly<{| export type UIDropDown = $ReadOnly<{|
type: "dropDown", type: "dropDown",
text: string, text: string,
topic: string, topic: string,
icon?: string, icon?: Icon,
enableCondition?: TopicDependentOption<boolean>, enableCondition?: (s: State) => boolean,
options: Map<string, any>, options: Map<string, string>,
renderValue?: (value: string) => string renderValue?: (value: string) => string
|}>; |}>;
@ -54,8 +51,8 @@ export type UISlider = $ReadOnly<{|
type: "slider", type: "slider",
text: string, text: string,
topic: string, topic: string,
icon?: string, icon?: Icon,
enableCondition?: TopicDependentOption<boolean>, enableCondition?: (s: State) => boolean,
min?: number, min?: number,
max?: number, max?: number,
step?: number, step?: number,
@ -71,24 +68,24 @@ export type UILink = $ReadOnly<{|
type: "link", type: "link",
text: string, text: string,
link: string, link: string,
enableCondition?: StateDependentOption<boolean>, enableCondition?: (s: State) => boolean,
// TODO: check if both the following options are implemented // TODO: check if both the following options are implemented
icon?: string icon?: Icon
|}>; |}>;
export type UIText = $ReadOnly<{| export type UIText = $ReadOnly<{|
type: "text", type: "text",
text: string, text: string,
topic: string, topic: string,
icon?: string icon?: Icon
|}>; |}>;
export type UIProgress = $ReadOnly<{| export type UIProgress = $ReadOnly<{|
type: "progress", type: "progress",
text: string, text: string,
topic: string, topic: string,
icon?: string, icon?: Icon,
min?: number, min?: number,
max?: number max?: number
|}>; |}>;
@ -105,16 +102,8 @@ export type ControlUI =
export type Control = { export type Control = {
name: string, name: string,
position: [number, number], position: [number, number],
icon: string | ( icon: Icon,
internals: Map<string, Internal>, iconColor?: (state: State) => Color,
actuals: Map<string, Actual>,
state: State
) => string,
iconColor?: (
internals: Map<string, Internal>,
actuals: Map<string, Actual>,
state: State
) => Color,
ui: Array<ControlUI> ui: Array<ControlUI>
}; };
export type Controls = Map<string, Control>; export type Controls = Map<string, Control>;

45
src/config/icon.js Normal file
View file

@ -0,0 +1,45 @@
// @flow
import * as React from "react";
export opaque type RawIcon: string = string;
export type Icon = (State) => RawIcon;
export const raw_mdi = (name: string): RawIcon => {
return `mdi ${name.split(" ").map((icon) => "mdi-".concat(icon)).join(" ")}`;
};
export const mdi = (icon: string) => () => raw_mdi(icon);
export const mdi_battery = (topic: string) => (state: State) => {
const rawval = state[topic];
const val = parseInt(rawval);
if (isNaN(val)) {
return raw_mdi("battery-unknown");
} else if (val > 95) {
return raw_mdi("battery");
} else if (val > 85) {
return raw_mdi("battery-90");
} else if (val > 75) {
return raw_mdi("battery-80");
} else if (val > 65) {
return raw_mdi("battery-70");
} else if (val > 55) {
return raw_mdi("battery-60");
} else if (val > 45) {
return raw_mdi("battery-50");
} else if (val > 35) {
return raw_mdi("battery-40");
} else if (val > 25) {
return raw_mdi("battery-30");
} else if (val > 15) {
return raw_mdi("battery-20");
} else {
return raw_mdi("battery-10");
}
};
export const renderIcon =
(icon: RawIcon, extraClass?: string): React.Node => {
return <i className={`${extraClass || ""} ${icon}`}></i>;
};

View file

@ -1,8 +1,21 @@
// @flow // @flow
import type { TopicType } from "config/flowtypes"; import type { TopicType } from "config/flowtypes";
import at from "lodash/at";
export const string: TopicType = (msg: Buffer) => msg.toString();
export const string: TopicType = msg => msg.toString();
export const json = (path: string, innerType?: TopicType): TopicType => { export const json = (path: string, innerType?: TopicType): TopicType => {
const parseAgain = innerType == null ? x => x : innerType; const parseAgain = innerType == null ? (x) => x.toString() : innerType;
return msg => parseAgain(JSON.parse(msg.toString())[path]); return (msg) => parseAgain(Buffer.from(
at(JSON.parse(msg.toString()), path)[0].toString()));
}; };
export type TypeOptionParam = { otherwise?: string, [string]: string };
export const option = (values: TypeOptionParam): TopicType => {
// TODO: error
const defaultValue = values.otherwise != null ? values.otherwise : "";
const mapVal = (x) => (values[x] != null ? values[x] : defaultValue);
return (x) => mapVal(x.toString());
};
export const jsonArray = (msg: Buffer) => JSON.parse(msg.toString()).join(", ");

View file

@ -5,14 +5,16 @@ import injectTapEventPlugin from "react-tap-event-plugin";
import App from "components/App"; import App from "components/App";
import "../node_modules/mdi/css/materialdesignicons.min.css"; import "../node_modules/@mdi/font/css/materialdesignicons.min.css";
import "../css/styles.css"; import "../css/styles.css";
const Config : Config = window.config; import type { Config } from "config/flowtypes";
const config : Config = window.config;
injectTapEventPlugin(); injectTapEventPlugin();
document.title = `${Config.space.name} Map`; document.title = `${config.space.name} Map`;
// $FlowFixMe // $FlowFixMe
const contentElement: Element = document.getElementById("content"); const contentElement: Element = document.getElementById("content");
ReactDOM.render(<App config={Config} />, contentElement); ReactDOM.render(<App config={config} />, contentElement);

View file

@ -1,25 +0,0 @@
// @flow
import * as React from "react";
import { getInternals, getActuals } from "utils/state";
import type { Control } from "config/flowtypes";
export default function parseIconName(name: string): string {
return `mdi ${name.split(" ").map((icon) => "mdi-".concat(icon)).join(" ")}`;
}
export const renderIcon = (name: string, extraClass?: string): React.Node => {
return <i className={`${extraClass || ""} ${parseIconName(name)}`}></i>;
};
export const controlGetIcon = (control: Control, state: State): string => {
const internals: Map<string, Internal> = getInternals(state);
const actuals: Map<string, Actual> = getActuals(state);
return typeof control.icon !== "function" ? control.icon
: control.icon(internals, actuals, state);
};
export const renderControlIcon = (control: Control,
state: State, extraClass?: string): React.Node => {
return renderIcon(controlGetIcon(control, state), extraClass);
};

View file

@ -1,8 +0,0 @@
// @flow
import mapValues from "lodash/mapValues";
export const getInternals = (state: State): Map<string, Internal> =>
mapValues(state, (x) => x.internal || x.actual);
export const getActuals = (state: State): Map<string, Actual> =>
mapValues(state, (x) => x.actual);

View file

@ -5,7 +5,7 @@ for conf in $(ls config/); do
if [ "$conf" = "utils.js" ]; then if [ "$conf" = "utils.js" ]; then
continue continue
fi fi
yarn dev $conf
yarn build $conf yarn build $conf
yarn production-build $conf
mv dist artifacts/$conf mv dist artifacts/$conf
done done

View file

@ -7,26 +7,7 @@ declare type Classes = {
classes: Map<string, string> classes: Map<string, string>
}; };
declare type Internal = string; declare type State = Map<string,string>;
declare type Actual = any;
declare type StateValue = {
internal: string,
actual: any
};
declare type State = Map<string,StateValue>;
//declare type State = {
// mqtt: ?any,
// uiOpened: ?string,
// A map of the actual state values for each topic.
// internal is the internal term for the value,
// that is equal to the key in the values section of that
// topic, for example given by:
// values: { off: "OFF", on: "ON" }
// and actual is the value of that or whatever is given by mqtt.
// values: Map<string, { internal: ?string, actual: any }>,
// visibleLayers: Array<string>
//};
declare type Point = [number, number]; declare type Point = [number, number];

View file

@ -2,14 +2,23 @@ const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
const WebpackShellPlugin = require('webpack-shell-plugin'); const WebpackShellPlugin = require('webpack-shell-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const CleanWebpackPlugin = require('clean-webpack-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin');
const preBuildScripts = process.env.NO_FLOW == undefined ? const preBuildScripts = process.env.NO_FLOW == undefined ?
process.env.FLOW_PATH != undefined ? [process.env.FLOW_PATH] : ['flow'] process.env.FLOW_PATH != undefined ? [process.env.FLOW_PATH] : ['flow']
: []; : [];
module.exports = { const configPath = env => {
if (env === true) {
throw "No config file was provided.";
}
return path.resolve(__dirname, `config/${env}`);
};
module.exports = env => ({
entry: {
main: [configPath(env),
path.resolve(__dirname, 'src/index.jsx')]
},
resolve: { resolve: {
modules: [path.resolve(__dirname, "src"), "node_modules"], modules: [path.resolve(__dirname, "src"), "node_modules"],
extensions: ['.js', '.jsx'], extensions: ['.js', '.jsx'],
@ -23,7 +32,10 @@ module.exports = {
}, },
module: { module: {
rules: [ rules: [
{ test: /\.(woff2?|eot|ttf|svg)$/, loader: "file-loader" } // TODO: CSS follow imports and minify + sourcemap on production
{ test: /\.css$/, use: [ 'style-loader', 'css-loader' ] },
{ test: /\.(woff2?|eot|ttf|svg)$/, loader: "file-loader" },
{ test: /\.js(x)?$/, exclude: /node_modules/, loader: "babel-loader?cacheDirectory=true" }
] ]
}, },
plugins: [ plugins: [
@ -33,6 +45,5 @@ module.exports = {
title: 'Space Map', title: 'Space Map',
template: 'index.ejs' template: 'index.ejs'
}), }),
new ExtractTextPlugin("styles.css"),
] ]
}; });

View file

@ -1,35 +0,0 @@
const merge = require('webpack-merge');
const path = require('path');
const common = require('./webpack.common.js');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const extractCSS = ExtractTextPlugin.extract({
fallback: "style-loader",
use: {
loader: "css-loader"
}
});
const configPath = env => {
if (env === true) {
throw "No config file was provided.";
}
return path.resolve(__dirname, `config/${env}`);
};
module.exports = env => merge(common, {
entry: {
main: [configPath(env),
path.resolve(__dirname, 'src/index.jsx')]
},
module: {
rules: [
{ test: /\.css$/, use: extractCSS },
{ test: /\.js(x)?$/, exclude: /node_modules/, loader: "babel-loader?cacheDirectory=true" }
]
},
devtool: "eval-cheap-module-source-map",
devServer: {
contentBase: './dist'
},
});

View file

@ -1,49 +0,0 @@
const webpack = require('webpack');
const path = require('path');
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
const extractCSS = ExtractTextPlugin.extract({
fallback: "style-loader",
use: {
loader: "css-loader",
options: {
sourceMap: true,
minimize: true
}
}
});
const configPath = env => {
if (env === true) {
throw "No config file was provided.";
}
return path.resolve(__dirname, `config/${env}`);
};
module.exports = env => merge(common, {
entry: {
main: [configPath(env),
path.resolve(__dirname, 'src/index.jsx')],
},
module: {
rules: [
{ test: /\.css$/, use: extractCSS },
{ test: /\.js(x)?$/, exclude: /node_modules/, loader: "babel-loader" }
]
},
devtool: "source-map",
plugins: [
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify('production')
}
}),
// new LodashModuleReplacementPlugin(),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.HashedModuleIdsPlugin(),
new webpack.optimize.AggressiveMergingPlugin()
]
});

1632
yarn.lock

File diff suppressed because it is too large Load diff