Skip to content

Commit

Permalink
feat(availability): implement availability feature
Browse files Browse the repository at this point in the history
  • Loading branch information
nurikk committed Nov 28, 2021
1 parent 275939f commit 3d21699
Show file tree
Hide file tree
Showing 11 changed files with 134 additions and 110 deletions.
24 changes: 18 additions & 6 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ReconnectingWebSocket from "reconnecting-websocket";
import store, { Extension, LogMessage } from "./store";
import store, { Extension, LogMessage, OnlineOrOffline } from "./store";
import { BridgeConfig, BridgeInfo, TouchLinkDevice, Device, DeviceState, BridgeState, Group } from './types';
import { sanitizeGraph, isSecurePage, randomString, stringifyWithPreservingUndefinedAsNull } from "./utils";
import { Notyf } from "notyf";
Expand All @@ -10,6 +10,7 @@ const TOKEN_LOCAL_STORAGE_ITEM_NAME = "z2m-token";
const AUTH_FLAG_LOCAL_STORAGE_ITEM_NAME = "z2m-auth";
const UNAUTHORIZED_ERROR_CODE = 4401;

const AVALIABILITY_FEATURE_TOPIC_ENDING = "/availability";
const notyf = new Notyf();

interface Message {
Expand Down Expand Up @@ -115,6 +116,11 @@ class Api {
this.socket.addEventListener("message", this.onMessage);
this.socket.addEventListener("close", this.onClose);
}
private processDeviceStateMessage = (data: Message): void => {
let { deviceStates } = store.getState();
deviceStates = { ...deviceStates, ...{ [data.topic]: { ...deviceStates[data.topic], ...(data.payload as DeviceState) } } };
store.setState({ deviceStates });
}
private procsessBridgeMessage = (data: Message): void => {
switch (data.topic) {
case "bridge/config":
Expand Down Expand Up @@ -221,6 +227,13 @@ class Api {
}
}

private processAvailabilityMessage = (data: Message): void => {
let { avalilability } = store.getState();
const friendlyName = data.topic.split(AVALIABILITY_FEATURE_TOPIC_ENDING, 1)[0];
avalilability = { ...avalilability, ...{ [friendlyName]: data.payload as OnlineOrOffline}};
store.setState({ avalilability });
}

private resolvePromises(message: ResponseWithStatus): void {
const { transaction, status } = message;
if (transaction !== undefined && this.requests.has(transaction)) {
Expand Down Expand Up @@ -253,13 +266,12 @@ class Api {
notyf.error(e.message);
notyf.error(event.data);
}

if (data.topic.startsWith("bridge/")) {
if (data.topic.endsWith(AVALIABILITY_FEATURE_TOPIC_ENDING)) {
this.processAvailabilityMessage(data);
} else if (data.topic.startsWith("bridge/")) {
this.procsessBridgeMessage(data);
} else {
let { deviceStates } = store.getState();
deviceStates = { ...deviceStates, ...{ [data.topic]: { ...deviceStates[data.topic], ...(data.payload as DeviceState) } } };
store.setState({ deviceStates });
this.processDeviceStateMessage(data);
}
}
}
Expand Down
30 changes: 21 additions & 9 deletions src/components/device-page/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ import DeviceControlGroup from "../device-control/DeviceControlGroup";
import cx from "classnames";
import style from "./style.css";
import { connect } from "unistore/react";
import { GlobalState } from "../../store";
import { GlobalState, OnlineOrOffline } from "../../store";
import get from 'lodash/get';
import DeviceImage from "../device-image";
import { ModelLink, VendorLink } from "../vendor-links/verndor-links";
import PowerSource from "../power-source";
import { LastSeen } from "../LastSeen";
import { WithTranslation, withTranslation } from "react-i18next";
import { DisplayValue } from "../display-value/DisplayValue";
import { Avaliability } from "../zigbee";


type DeviceInfoProps = {
device: Device;
}
type PropsFromStore = Pick<GlobalState, 'deviceStates' | 'bridgeInfo'>;
type PropsFromStore = Pick<GlobalState, 'deviceStates' | 'bridgeInfo' | 'avalilability'>;

// [Flower sensor](http://modkam.ru/?p=1700)
const markdownLinkRegex = /\[(.*?)\]\((.*?)\)/;
Expand All @@ -31,7 +32,18 @@ const displayProps = [
},
{
translationKey: 'last_seen',
render: (device: Device, state: DeviceState, bridgeInfo: BridgeInfo) => <dd className="col-12 col-md-7"><LastSeen lastSeenType={bridgeInfo.config.advanced.last_seen} state={state}/></dd>,
render: (device: Device, state: DeviceState, bridgeInfo: BridgeInfo) => <dd className="col-12 col-md-7"><LastSeen lastSeenType={bridgeInfo.config.advanced.last_seen} state={state} /></dd>,
},
{
translationKey: 'avaliability',
render: (device: Device, state: DeviceState, bridgeInfo: BridgeInfo, availability: OnlineOrOffline) => {
const availabilityFeatureEnabled = !!bridgeInfo.config.availability;
return <dd className="col-12 col-md-7">
<Avaliability
avaliability={availability}
enabled={availabilityFeatureEnabled} />
</dd>
},
},
{
key: 'type',
Expand All @@ -55,13 +67,13 @@ const displayProps = [
if (result) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [all, title, link] = result;
content = <a target="_blank" rel="noopener noreferrer"href={link}>{title}</a>
content = <a target="_blank" rel="noopener noreferrer" href={link}>{title}</a>
}
return <dd className="col-12 col-md-7">{content}</dd>
},
},
{
render: (device: Device) => <dd className="col-12 col-md-7" ><p className={cx('mb-0', 'font-weight-bold', { 'text-danger': !device.supported, 'text-success': device.supported })}><DisplayValue name="supported" value={device.supported}/></p></dd>,
render: (device: Device) => <dd className="col-12 col-md-7" ><p className={cx('mb-0', 'font-weight-bold', { 'text-danger': !device.supported, 'text-success': device.supported })}><DisplayValue name="supported" value={device.supported} /></p></dd>,
translationKey: 'support_status'
},
{
Expand Down Expand Up @@ -108,8 +120,8 @@ const displayProps = [
];
// eslint-disable-next-line react/prefer-stateless-function
export class DeviceInfo extends Component<DeviceInfoProps & PropsFromStore & WithTranslation<"zigbee">, unknown> {
render(): JSX.Element{
const { device, deviceStates, bridgeInfo, t } = this.props;
render(): JSX.Element {
const { device, deviceStates, bridgeInfo, avalilability, t } = this.props;

const deviceState: DeviceState = deviceStates[device.friendly_name] ?? {} as DeviceState;
return (
Expand All @@ -123,7 +135,7 @@ export class DeviceInfo extends Component<DeviceInfoProps & PropsFromStore & Wit
<Fragment key={prop.translationKey}>
<dt className="col-12 col-md-5">{t(prop.translationKey)}</dt>
{prop.render ?
prop.render(device, deviceState, bridgeInfo) : <dd className="col-12 col-md-7">{get(device, prop.key)}</dd>}
prop.render(device, deviceState, bridgeInfo, avalilability[device.friendly_name] ?? 'offline') : <dd className="col-12 col-md-7">{get(device, prop.key)}</dd>}

</Fragment>
))
Expand All @@ -138,7 +150,7 @@ export class DeviceInfo extends Component<DeviceInfoProps & PropsFromStore & Wit
}
}

const mappedProps = ["deviceStates", "bridgeInfo"];
const mappedProps = ["deviceStates", "bridgeInfo", "avalilability"];

const ConnectedDeviceInfoPage = withTranslation("zigbee")(connect<DeviceInfoProps, unknown, GlobalState, PropsFromStore>(mappedProps)(DeviceInfo));
export default ConnectedDeviceInfoPage;
7 changes: 4 additions & 3 deletions src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const processHighlights = ({ networkGraph, links, selectedNode, node, link, link
linkLabel.style("opacity", 1);
}
}
type PropsFromStore = Pick<GlobalState, 'networkGraph' | 'networkGraphIsLoading' | 'deviceStates' | 'devices'>;
type PropsFromStore = Pick<GlobalState, 'networkGraph' | 'networkGraphIsLoading' | 'deviceStates' | 'devices' | 'avalilability'>;
export class MapComponent extends Component<PropsFromStore & MapApi & WithTranslation<"map">, MapState> {
ref = createRef<HTMLDivElement>();
svgRef = createRef<SVGSVGElement>();
Expand Down Expand Up @@ -196,7 +196,7 @@ export class MapComponent extends Component<PropsFromStore & MapApi & WithTransl
const { width, height, visibleLinks } = this.state;


const { networkGraph, deviceStates, devices } = this.props;
const { networkGraph, deviceStates, devices, avalilability } = this.props;
const links = networkGraph.links.filter(l => intersection(visibleLinks, l.relationships).length > 0);
return (
<svg ref={this.svgRef} viewBox={`0 0 ${width} ${height}`}>
Expand All @@ -208,6 +208,7 @@ export class MapComponent extends Component<PropsFromStore & MapApi & WithTransl
simulation={this.simulation}
deviceStates={deviceStates}
devices={devices}
avalilability={avalilability}
/>
</g>
</svg >
Expand Down Expand Up @@ -293,6 +294,6 @@ export class MapComponent extends Component<PropsFromStore & MapApi & WithTransl
}


const mappedProps = ["networkGraph", "networkGraphIsLoading", "deviceStates", "devices"];
const mappedProps = ["networkGraph", "networkGraphIsLoading", "deviceStates", "devices", "avalilability"];
const ConnectedMap = withTranslation("map")(connect<unknown, MapState, GlobalState, unknown>(mappedProps, actions)(MapComponent));
export default ConnectedMap;
2 changes: 1 addition & 1 deletion src/components/map/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
}

.offline {
opacity: 0.5;
opacity: 0.5 !important;
stroke: gray;
}

Expand Down
18 changes: 7 additions & 11 deletions src/components/map/nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { select } from "d3-selection";
import { drag } from "d3-drag";
import { CSSTransition } from 'react-transition-group'; // ES6
import isEqual from "lodash/isEqual";
import { OnlineOrOffline, WithAvaliability, WithDevices, WithDeviceStates } from "../../store";



Expand All @@ -35,14 +36,10 @@ interface NodeProps extends MouseEventsResponderNode {
node: NodeI;
deviceState: DeviceState;
device: Device;
avalilability: OnlineOrOffline;
}

const offlineTimeout = 3600 * 2;

export const isOnline = (device: Device): boolean => {

return true; // TODO: implement avalilability feature
};
type NodeState = {
hasBeenUpdated: boolean;
}
Expand Down Expand Up @@ -91,10 +88,10 @@ class Node extends Component<NodeProps, NodeState> {

render() {
const { hasBeenUpdated } = this.state;
const { node, deviceState, device } = this.props;
const { node, deviceState, device, avalilability } = this.props;
const { onMouseOver, onMouseOut, onDblClick } = this;
const deviceType = node.type as string;
const cn = cx(style.node, style[deviceType]); //{ [style.offline]: !isOnline(node.device, time) }
const cn = cx(style.node, style[deviceType], { [style.offline]: avalilability === "offline" })
return (<g className={cn}
ref={this.ref as RefObject<SVGImageElement>}
onMouseOver={onMouseOver}
Expand Down Expand Up @@ -133,12 +130,10 @@ class Node extends Component<NodeProps, NodeState> {
}
}

interface NodesProps extends MouseEventsResponderNode {
interface NodesProps extends MouseEventsResponderNode, WithAvaliability, WithDevices, WithDeviceStates {
root: SVGElement;
nodes: NodeI[];
deviceStates: Record<FriendlyName, DeviceState>;
simulation: Simulation<NodeI, LinkI>;
devices: Record<IEEEEAddress, Device>;
}

type NodesState = {
Expand Down Expand Up @@ -186,7 +181,7 @@ export default class Nodes extends Component<NodesProps, NodesState> {


render() {
const { nodes, onMouseOut, onMouseOver, deviceStates, devices } = this.props;
const { nodes, onMouseOut, onMouseOver, deviceStates, devices, avalilability } = this.props;
return (
<g className={style.nodes}>
{nodes.map((node: NodeI) => (
Expand All @@ -197,6 +192,7 @@ export default class Nodes extends Component<NodesProps, NodesState> {
node={node}
deviceState={deviceStates[node.friendlyName as FriendlyName]}
device={devices[node.ieeeAddr]}
avalilability={avalilability[node.friendlyName as FriendlyName]}
/>
))}
</g>
Expand Down
48 changes: 41 additions & 7 deletions src/components/zigbee/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { Component, Fragment, ReactNode } from "react";
import { Device, DeviceState, LastSeenType } from "../../types";
import { Notyf } from "notyf";
import { connect } from "unistore/react";
import { GlobalState } from "../../store";
import { GlobalState, OnlineOrOffline } from "../../store";
import actions from "../../actions/actions";
import style from "./style.css";
import Spinner from "../spinner";
Expand All @@ -18,22 +18,42 @@ import PowerSource from "../power-source";
import DeviceControlGroup from "../device-control/DeviceControlGroup";
import { Table } from "../grid/ReactTableCom";
import { CellProps, Column } from "react-table";
import cx from "classnames";
export interface ZigbeeTableData {
id: string;
device: Device;
state: DeviceState;
avalilabilityState: OnlineOrOffline;
}


type PropsFromStore = Pick<GlobalState, 'devices' | 'deviceStates' | 'bridgeInfo'>;
type PropsFromStore = Pick<GlobalState, 'devices' | 'deviceStates' | 'bridgeInfo' | 'avalilability'>;
type ZigbeeTableProps = PropsFromStore & WithTranslation<"zigbee">;

type DevicesTableProps = {
data: ZigbeeTableData[];
lastSeenType: LastSeenType;
availabilityFeatureEnabled: boolean;
}
type AvaliabilityStateProps = {
avaliability: OnlineOrOffline;
enabled?: boolean;
}
export function Avaliability(props: AvaliabilityStateProps) {
const { t } = useTranslation(["zigbee"]);
const { avaliability, enabled = true } = props;
if (enabled) {
return <span className={cx({
"text-danger animation-blinking": avaliability === "offline",
'text-success': avaliability === "online"
})}>{t(avaliability)}</span>
} else {
return <a target="_blank" rel="noopener noreferrer"
href="https://www.zigbee2mqtt.io/guide/configuration/device-availability.html#availability-advanced-configuration">N/A</a>
}
}
function DevicesTable(props: DevicesTableProps) {
const { data, lastSeenType } = props;
const { data, lastSeenType, availabilityFeatureEnabled } = props;
const { t } = useTranslation(["zigbee", "common"]);

const columns: Column<ZigbeeTableData>[] = [
Expand Down Expand Up @@ -91,6 +111,13 @@ function DevicesTable(props: DevicesTableProps) {
Cell: ({ row: { original: { state } } }) => <LastSeen state={state} lastSeenType={lastSeenType} />,

}] : []),
...(availabilityFeatureEnabled ? [{
id: 'avaliability',
Header: t('avaliability') as string,
accessor: ({ avalilabilityState }) => avalilabilityState,
Cell: ({ row: { original: { avalilabilityState } } }) => <Avaliability avaliability={avalilabilityState} />,
}] : []),

{
id: 'power',
Header: t('power') as string,
Expand All @@ -116,7 +143,9 @@ function DevicesTable(props: DevicesTableProps) {
</div>);
}
export function ZigbeeTable(props: ZigbeeTableProps) {
const { devices, deviceStates, bridgeInfo } = props;
const { devices, deviceStates, bridgeInfo, avalilability } = props;
const availabilityFeatureEnabled = !!bridgeInfo.config.availability;

const getDevicesToRender = (): ZigbeeTableData[] => {
return Object.values(devices)
.filter(device => device.type !== "Coordinator")
Expand All @@ -125,22 +154,27 @@ export function ZigbeeTable(props: ZigbeeTableProps) {
return {
id: device.friendly_name,
device,
state
state,
avalilabilityState: avalilability[device.friendly_name] ?? "offline"
} as ZigbeeTableData;
});
}
const data = React.useMemo(() => getDevicesToRender(), [devices, deviceStates]);


if (Object.keys(data).length) {
return <DevicesTable data={data} lastSeenType={bridgeInfo.config.advanced.last_seen} />
return <DevicesTable
data={data}
lastSeenType={bridgeInfo.config.advanced.last_seen}
availabilityFeatureEnabled={availabilityFeatureEnabled}
/>
} else {
return (<div className="h-100 d-flex justify-content-center align-items-center">
<Spinner />
</div>);
}
}

const mappedProps = ["devices", "deviceStates", "bridgeInfo"];
const mappedProps = ["devices", "deviceStates", "bridgeInfo", "avalilability"];
const ConnectedZigbeePage = withTranslation(["zigbee", "common"])(connect<unknown, unknown, PropsFromStore, unknown>(mappedProps, actions)(ZigbeeTable));
export default ConnectedZigbeePage;
5 changes: 4 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,10 @@
"zigbee_manufacturer": "Zigbee Manufacturer",
"zigbee_model": "Zigbee Model",
"device": "Device",
"channel": "Channel"
"channel": "Channel",
"avaliability": "Avaliability",
"offline": "Offline",
"online": "Online"
},
"scene": {
"scene_id": "Scene ID",
Expand Down
3 changes: 2 additions & 1 deletion src/initialState.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
"logs": [],
"extensions": [],
"theme": "light",
"missingTranslations": {}
"missingTranslations": {},
"avalilability": {}
}

0 comments on commit 3d21699

Please sign in to comment.