Skip to content

Commit

Permalink
Rewrite websocket to use tracked state instead of context (#10091)
Browse files Browse the repository at this point in the history
* Rewrite websocket to use tracked state instead of context

* Cleanup

* Use component for updating items

* Fix scroll update

* Don't save vite
  • Loading branch information
NickM-27 authored Feb 27, 2024
1 parent f95ce91 commit 21defbe
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 213 deletions.
48 changes: 48 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.1",
"react-tracked": "^1.7.11",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0",
"recoil": "^0.7.7",
Expand Down
7 changes: 2 additions & 5 deletions web/src/api/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { baseUrl } from "./baseUrl";
import useSWR, { SWRConfig } from "swr";
import { SWRConfig } from "swr";
import { WsProvider } from "./ws";
import axios from "axios";
import { ReactNode } from "react";
import { FrigateConfig } from "@/types/frigateConfig";

axios.defaults.baseURL = `${baseUrl}api/`;

Expand Down Expand Up @@ -38,9 +37,7 @@ type WsWithConfigType = {
};

function WsWithConfig({ children }: WsWithConfigType) {
const { data } = useSWR<FrigateConfig>("config");

return data ? <WsProvider config={data}>{children}</WsProvider> : children;
return <WsProvider>{children}</WsProvider>;
}

export function useApiHost() {
Expand Down
187 changes: 71 additions & 116 deletions web/src/api/ws.tsx
Original file line number Diff line number Diff line change
@@ -1,149 +1,104 @@
import { baseUrl } from "./baseUrl";
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from "react";
import { produce, Draft } from "immer";
import { useCallback, useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws";
import { FrigateStats } from "@/types/stats";
import useSWR from "swr";
import { createContainer } from "react-tracked";

type ReducerState = {
[topic: string]: {
lastUpdate: number;
payload: any;
retain: boolean;
};
};

type ReducerAction = {
type Update = {
topic: string;
payload: any;
retain: boolean;
};

const initialState: ReducerState = {
_initial_state: {
lastUpdate: 0,
payload: "",
retain: false,
},
type WsState = {
[topic: string]: any;
};

type WebSocketContextProps = {
state: ReducerState;
readyState: ReadyState;
sendJsonMessage: (message: any) => void;
};
type useValueReturn = [WsState, (update: Update) => void];

export const WS = createContext<WebSocketContextProps>({
state: initialState,
readyState: ReadyState.CLOSED,
sendJsonMessage: () => {},
});

export const useWebSocketContext = (): WebSocketContextProps => {
const context = useContext(WS);
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider"
);
}
return context;
};
function useValue(): useValueReturn {
// basic config
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;

function reducer(state: ReducerState, action: ReducerAction): ReducerState {
switch (action.topic) {
default:
return produce(state, (draftState: Draft<ReducerState>) => {
let parsedPayload = action.payload;
try {
parsedPayload = action.payload && JSON.parse(action.payload);
} catch (e) {}
draftState[action.topic] = {
lastUpdate: Date.now(),
payload: parsedPayload,
retain: action.retain,
};
});
}
}
// main state
const [wsState, setWsState] = useState<WsState>({});

type WsProviderType = {
config: FrigateConfig;
children: ReactNode;
wsUrl?: string;
};
useEffect(() => {
if (!config) {
return;
}

export function WsProvider({
config,
children,
wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`,
}: WsProviderType) {
const [state, dispatch] = useReducer(reducer, initialState);
const cameraStates: WsState = {};

Object.keys(config.cameras).forEach((camera) => {
const { name, record, detect, snapshots, audio } = config.cameras[camera];
cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots.enabled
? "ON"
: "OFF";
cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF";
});

setWsState({ ...wsState, ...cameraStates });
}, [config]);

// ws handler
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
onMessage: (event) => {
dispatch(JSON.parse(event.data));
const data: Update = JSON.parse(event.data);

if (data) {
setWsState({ ...wsState, [data.topic]: data.payload });
}
},
onOpen: () => dispatch({ topic: "", payload: "", retain: false }),
onOpen: () => {},
shouldReconnect: () => true,
});

useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, record, detect, snapshots, audio } = config.cameras[camera];
dispatch({
topic: `${name}/recordings/state`,
payload: record.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/detect/state`,
payload: detect.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/snapshots/state`,
payload: snapshots.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/audio/state`,
payload: audio.enabled ? "ON" : "OFF",
retain: false,
});
});
}, [config]);

return (
<WS.Provider value={{ state, readyState, sendJsonMessage }}>
{children}
</WS.Provider>
const setState = useCallback(
(message: Update) => {
if (readyState === ReadyState.OPEN) {
sendJsonMessage({
topic: message.topic,
payload: message.payload,
retain: message.retain,
});
}
},
[readyState, sendJsonMessage]
);

return [wsState, setState];
}

export const {
Provider: WsProvider,
useTrackedState: useWsState,
useUpdate: useWsUpdate,
} = createContainer(useValue, { defaultState: {}, concurrentMode: true });

export function useWs(watchTopic: string, publishTopic: string) {
const { state, readyState, sendJsonMessage } = useWebSocketContext();
const state = useWsState();
const sendJsonMessage = useWsUpdate();

const value = state[watchTopic] || { payload: null };
const value = { payload: state[watchTopic] || null };

const send = useCallback(
(payload: any, retain = false) => {
if (readyState === ReadyState.OPEN) {
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
}
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
},
[sendJsonMessage, readyState, watchTopic, publishTopic]
[sendJsonMessage, watchTopic, publishTopic]
);

return { value, send };
Expand Down Expand Up @@ -219,21 +174,21 @@ export function useFrigateEvents(): { payload: FrigateEvent } {
const {
value: { payload },
} = useWs("events", "");
return { payload };
return { payload: JSON.parse(payload) };
}

export function useFrigateReviews(): { payload: FrigateReview } {
const {
value: { payload },
} = useWs("reviews", "");
return { payload };
return { payload: JSON.parse(payload) };
}

export function useFrigateStats(): { payload: FrigateStats } {
const {
value: { payload },
} = useWs("stats", "");
return { payload };
return { payload: JSON.parse(payload) };
}

export function useMotionActivity(camera: string): { payload: string } {
Expand Down
Loading

0 comments on commit 21defbe

Please sign in to comment.