Bläddra i källkod

feat(web): detect, clips, snapshots toggles

Paul Armstrong 4 år sedan
förälder
incheckning
b6ba6459fb

+ 0 - 6
web/config/setupTests.js

@@ -15,10 +15,4 @@ Object.defineProperty(window, 'matchMedia', {
 
 window.fetch = () => Promise.resolve();
 
-beforeEach(() => {
-  jest.spyOn(window, 'fetch').mockImplementation(async (url, opts = {}) => {
-    throw new Error(`Unexpected fetch to ${url}, ${JSON.stringify(opts)}`);
-  });
-});
-
 jest.mock('../src/env');

+ 1 - 0
web/public/index.html

@@ -16,6 +16,7 @@
   <body>
     <div id="root" class="z-0"></div>
     <div id="menus" class="z-0"></div>
+    <div id="tooltips" class="z-0"></div>
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <script type="module" src="/dist/index.js"></script>
   </body>

+ 43 - 36
web/src/api/__tests__/index.test.jsx

@@ -1,8 +1,13 @@
 import { h } from 'preact';
+import * as Mqtt from '../mqtt';
 import { ApiProvider, useFetch, useApiHost } from '..';
 import { render, screen } from '@testing-library/preact';
 
 describe('useApiHost', () => {
+  beforeEach(() => {
+    jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
+  });
+
   test('is set from the baseUrl', async () => {
     function Test() {
       const apiHost = useApiHost();
@@ -17,26 +22,34 @@ describe('useApiHost', () => {
   });
 });
 
+function Test() {
+  const { data, status } = useFetch('/api/tacos');
+  return (
+    <div>
+      <span>{data ? data.returnData : ''}</span>
+      <span>{status}</span>
+    </div>
+  );
+}
+
 describe('useFetch', () => {
-  function Test() {
-    const { data, status } = useFetch('/api/tacos');
-    return (
-      <div>
-        <span>{data ? data.returnData : ''}</span>
-        <span>{status}</span>
-      </div>
-    );
-  }
-  test('loads data', async () => {
-    const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(
-      (url) =>
-        new Promise((resolve) => {
-          setTimeout(() => {
-            resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
-          }, 1);
-        })
-    );
+  let fetchSpy;
+
+  beforeEach(() => {
+    jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
+    fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
+      if (url.endsWith('/api/config')) {
+        return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+      }
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
+        }, 1);
+      });
+    });
+  });
 
+  test('loads data', async () => {
     render(
       <ApiProvider>
         <Test />
@@ -55,14 +68,16 @@ describe('useFetch', () => {
   });
 
   test('sets error if response is not okay', async () => {
-    jest.spyOn(window, 'fetch').mockImplementation(
-      (url) =>
-        new Promise((resolve) => {
-          setTimeout(() => {
-            resolve({ ok: false });
-          }, 1);
-        })
-    );
+    jest.spyOn(window, 'fetch').mockImplementation((url) => {
+      if (url.includes('/config')) {
+        return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+      }
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          resolve({ ok: false });
+        }, 1);
+      });
+    });
 
     render(
       <ApiProvider>
@@ -76,15 +91,6 @@ describe('useFetch', () => {
   });
 
   test('does not re-fetch if the query has already been made', async () => {
-    const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(
-      (url) =>
-        new Promise((resolve) => {
-          setTimeout(() => {
-            resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
-          }, 1);
-        })
-    );
-
     const { rerender } = render(
       <ApiProvider>
         <Test key={0} />
@@ -109,6 +115,7 @@ describe('useFetch', () => {
 
     jest.runAllTimers();
 
-    expect(fetchSpy).toHaveBeenCalledTimes(1);
+    // once for /api/config, once for /api/tacos
+    expect(fetchSpy).toHaveBeenCalledTimes(2);
   });
 });

+ 30 - 4
web/src/api/__tests__/mqtt.test.jsx

@@ -45,7 +45,7 @@ describe('MqttProvider', () => {
 
   test('connects to the mqtt server', async () => {
     render(
-      <MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
+      <MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
         <Test />
       </MqttProvider>
     );
@@ -69,7 +69,7 @@ describe('MqttProvider', () => {
     }
 
     const { rerender } = render(
-      <MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
+      <MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
         <Test />
       </MqttProvider>
     );
@@ -78,7 +78,7 @@ describe('MqttProvider', () => {
       data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
     });
     rerender(
-      <MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
+      <MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
         <Test />
       </MqttProvider>
     );
@@ -96,7 +96,7 @@ describe('MqttProvider', () => {
     }
 
     render(
-      <MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
+      <MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
         <Test />
       </MqttProvider>
     );
@@ -106,4 +106,30 @@ describe('MqttProvider', () => {
       JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }) })
     );
   });
+
+  test('prefills the clips/detect/snapshots state from config', async () => {
+    jest.spyOn(Date, 'now').mockReturnValue(123456);
+    const config = {
+      cameras: {
+        front: { name: 'front', detect: { enabled: true }, clips: { enabled: false }, snapshots: { enabled: true } },
+        side: { name: 'side', detect: { enabled: false }, clips: { enabled: false }, snapshots: { enabled: false } },
+      },
+    };
+    render(
+      <MqttProvider config={config} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
+        <Test />
+      </MqttProvider>
+    );
+    await screen.findByTestId('data');
+    expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
+    expect(screen.getByTestId('front/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
+    expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
+    expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
+    expect(screen.getByTestId('side/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
+    expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
+  });
 });
+
+const mockConfig = {
+  cameras: {},
+};

+ 8 - 3
web/src/api/index.jsx

@@ -40,14 +40,19 @@ function reducer(state, { type, payload, meta }) {
   }
 }
 
-export const ApiProvider = ({ children }) => {
+export function ApiProvider({ children }) {
   const [state, dispatch] = useReducer(reducer, initialState);
   return (
     <Api.Provider value={{ state, dispatch }}>
-      <MqttProvider>{children}</MqttProvider>
+      <MqttWithConfig>{children}</MqttWithConfig>
     </Api.Provider>
   );
-};
+}
+
+function MqttWithConfig({ children }) {
+  const { data, status } = useConfig();
+  return status === FetchStatus.LOADED ? <MqttProvider config={data}>{children}</MqttProvider> : children;
+}
 
 function shouldFetch(state, url, fetchId = null) {
   if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {

+ 46 - 4
web/src/api/mqtt.jsx

@@ -31,6 +31,7 @@ function reducer(state, { topic, payload, retain }) {
 }
 
 export function MqttProvider({
+  config,
   children,
   createWebsocket = defaultCreateWebsocket,
   mqttUrl = `${baseUrl.replace(/^https?:/, 'ws:')}/ws`,
@@ -38,6 +39,15 @@ export function MqttProvider({
   const [state, dispatch] = useReducer(reducer, initialState);
   const wsRef = useRef();
 
+  useEffect(() => {
+    Object.keys(config.cameras).forEach((camera) => {
+      const { name, clips, detect, snapshots } = config.cameras[camera];
+      dispatch({ topic: `${name}/clips/state`, payload: clips.enabled ? 'ON' : 'OFF' });
+      dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
+      dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
+    });
+  }, [config]);
+
   useEffect(
     () => {
       const ws = createWebsocket(mqttUrl);
@@ -62,17 +72,49 @@ export function MqttProvider({
   return <Mqtt.Provider value={{ state, ws: wsRef.current }}>{children}</Mqtt.Provider>;
 }
 
-export function useMqtt(topic) {
+export function useMqtt(watchTopic, publishTopic) {
   const { state, ws } = useContext(Mqtt);
 
-  const value = state[topic] || { payload: null };
+  const value = state[watchTopic] || { payload: null };
 
   const send = useCallback(
     (payload) => {
-      ws.send(JSON.stringify({ topic, payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload }));
+      ws.send(
+        JSON.stringify({
+          topic: publishTopic || watchTopic,
+          payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload,
+        })
+      );
     },
-    [ws, topic]
+    [ws, watchTopic, publishTopic]
   );
 
   return { value, send, connected: state.__connected };
 }
+
+export function useDetectState(camera) {
+  const {
+    value: { payload },
+    send,
+    connected,
+  } = useMqtt(`${camera}/detect/state`, `${camera}/detect/set`);
+  return { payload, send, connected };
+}
+
+export function useClipsState(camera) {
+  const {
+    value: { payload },
+    send,
+    connected,
+  } = useMqtt(`${camera}/clips/state`, `${camera}/clips/set`);
+  return { payload, send, connected };
+}
+
+export function useSnapshotsState(camera) {
+  const {
+    value: { payload },
+    send,
+    connected,
+  } = useMqtt(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
+  return { payload, send, connected };
+}

+ 37 - 11
web/src/components/Button.jsx

@@ -1,4 +1,6 @@
-import { h } from 'preact';
+import { h, Fragment } from 'preact';
+import Tooltip from './Tooltip';
+import { useCallback, useRef, useState } from 'preact/hooks';
 
 const ButtonColors = {
   blue: {
@@ -22,6 +24,13 @@ const ButtonColors = {
     text:
       'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
   },
+  gray: {
+    contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
+    outlined:
+      'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
+    text:
+      'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
+  },
   disabled: {
     contained: 'bg-gray-400',
     outlined:
@@ -52,6 +61,9 @@ export default function Button({
   type = 'contained',
   ...attrs
 }) {
+  const [hovered, setHovered] = useState(false);
+  const ref = useRef();
+
   let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
     ButtonColors[disabled ? 'disabled' : color][type]
   } font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
@@ -62,18 +74,32 @@ export default function Button({
     classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
   }
 
+  const handleMousenter = useCallback((event) => {
+    setHovered(true);
+  }, []);
+
+  const handleMouseleave = useCallback((event) => {
+    setHovered(false);
+  }, []);
+
   const Element = href ? 'a' : 'div';
 
   return (
-    <Element
-      role="button"
-      aria-disabled={disabled ? 'true' : 'false'}
-      tabindex="0"
-      className={classes}
-      href={href}
-      {...attrs}
-    >
-      {children}
-    </Element>
+    <Fragment>
+      <Element
+        role="button"
+        aria-disabled={disabled ? 'true' : 'false'}
+        tabindex="0"
+        className={classes}
+        href={href}
+        ref={ref}
+        onmouseenter={handleMousenter}
+        onmouseleave={handleMouseleave}
+        {...attrs}
+      >
+        {children}
+      </Element>
+      {hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} /> : null}
+    </Fragment>
   );
 }

+ 9 - 2
web/src/components/Card.jsx

@@ -9,6 +9,7 @@ export default function Box({
   elevated = true,
   header,
   href,
+  icons = [],
   media = null,
   ...props
 }) {
@@ -26,8 +27,8 @@ export default function Box({
           <div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
         </Element>
       ) : null}
-      {buttons.length || content ? (
-        <div className="pl-4 pb-2">
+      {buttons.length || content || icons.length ? (
+        <div className="px-4 pb-2">
           {content || null}
           {buttons.length ? (
             <div className="flex space-x-4 -ml-2">
@@ -36,6 +37,12 @@ export default function Box({
                   {name}
                 </Button>
               ))}
+              <div class="flex-grow" />
+              {icons.map(({ name, icon: Icon, ...props }) => (
+                <Button aria-label={name} className="rounded-full" key={name} type="text" {...props}>
+                  <Icon className="w-6" />
+                </Button>
+              ))}
             </div>
           ) : null}
         </div>

+ 61 - 0
web/src/components/Tooltip.jsx

@@ -0,0 +1,61 @@
+import { h } from 'preact';
+import { createPortal } from 'preact/compat';
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+const TIP_SPACE = 20;
+
+export default function Tooltip({ relativeTo, text }) {
+  const [position, setPosition] = useState({ top: -Infinity, left: -Infinity });
+  const portalRoot = document.getElementById('tooltips');
+  const ref = useRef();
+
+  useEffect(() => {
+    if (ref && ref.current && relativeTo && relativeTo.current) {
+      const windowWidth = window.innerWidth;
+      const {
+        x: relativeToX,
+        y: relativeToY,
+        width: relativeToWidth,
+        height: relativeToHeight,
+      } = relativeTo.current.getBoundingClientRect();
+      const { width: tipWidth, height: tipHeight } = ref.current.getBoundingClientRect();
+
+      const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
+      const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
+
+      let newTop = top - TIP_SPACE - tipHeight;
+      let newLeft = left - Math.round(tipWidth / 2);
+      // too far right
+      if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
+        newLeft = left - tipWidth - TIP_SPACE;
+        newTop = top - Math.round(tipHeight / 2);
+      }
+      // too far left
+      else if (newLeft < TIP_SPACE + window.scrollX) {
+        newLeft = left + TIP_SPACE;
+        newTop = top - Math.round(tipHeight / 2);
+      }
+      // too close to top
+      else if (newTop <= TIP_SPACE + window.scrollY) {
+        newTop = top + tipHeight + TIP_SPACE;
+      }
+
+      setPosition({ left: newLeft, top: newTop });
+    }
+  }, [relativeTo, ref]);
+
+  const tooltip = (
+    <div
+      role="tooltip"
+      className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-opacity duration-200 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
+        position.top >= 0 ? 'opacity-100' : ''
+      }`}
+      ref={ref}
+      style={position.top >= 0 ? position : null}
+    >
+      {text}
+    </div>
+  );
+
+  return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
+}

+ 115 - 0
web/src/components/__tests__/Toolltip.test.jsx

@@ -0,0 +1,115 @@
+import { h, createRef } from 'preact';
+import Tooltip from '../Tooltip';
+import { render, screen } from '@testing-library/preact';
+
+describe('Tooltip', () => {
+  test('renders in a relative position', async () => {
+    jest
+      .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+      // relativeTo
+      .mockReturnValueOnce({
+        x: 100,
+        y: 100,
+        width: 50,
+        height: 10,
+      })
+      // tooltip
+      .mockReturnValueOnce({ width: 40, height: 15 });
+
+    const ref = createRef();
+    render(
+      <div>
+        <div ref={ref} />
+        <Tooltip relativeTo={ref} text="hello" />
+      </div>
+    );
+
+    const tooltip = await screen.findByRole('tooltip');
+    const style = window.getComputedStyle(tooltip);
+    expect(style.left).toEqual('105px');
+    expect(style.top).toEqual('70px');
+  });
+
+  test('if too far right, renders to the left', async () => {
+    window.innerWidth = 1024;
+    jest
+      .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+      // relativeTo
+      .mockReturnValueOnce({
+        x: 1000,
+        y: 100,
+        width: 24,
+        height: 10,
+      })
+      // tooltip
+      .mockReturnValueOnce({ width: 50, height: 15 });
+
+    const ref = createRef();
+    render(
+      <div>
+        <div ref={ref} />
+        <Tooltip relativeTo={ref} text="hello" />
+      </div>
+    );
+
+    const tooltip = await screen.findByRole('tooltip');
+    const style = window.getComputedStyle(tooltip);
+    expect(style.left).toEqual('942px');
+    expect(style.top).toEqual('97px');
+  });
+
+  test('if too far left, renders to the right', async () => {
+    jest
+      .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+      // relativeTo
+      .mockReturnValueOnce({
+        x: 0,
+        y: 100,
+        width: 24,
+        height: 10,
+      })
+      // tooltip
+      .mockReturnValueOnce({ width: 50, height: 15 });
+
+    const ref = createRef();
+    render(
+      <div>
+        <div ref={ref} />
+        <Tooltip relativeTo={ref} text="hello" />
+      </div>
+    );
+
+    const tooltip = await screen.findByRole('tooltip');
+    const style = window.getComputedStyle(tooltip);
+    expect(style.left).toEqual('32px');
+    expect(style.top).toEqual('97px');
+  });
+
+  test('if too close to top, renders to the bottom', async () => {
+    window.scrollY = 90;
+    jest
+      .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+      // relativeTo
+      .mockReturnValueOnce({
+        x: 100,
+        y: 100,
+        width: 24,
+        height: 10,
+      })
+      // tooltip
+      .mockReturnValueOnce({ width: 50, height: 15 });
+
+    const ref = createRef();
+    render(
+      <div>
+        <div ref={ref} />
+        <Tooltip relativeTo={ref} text="hello" />
+      </div>
+    );
+
+    const tooltip = await screen.findByRole('tooltip');
+    const style = window.getComputedStyle(tooltip);
+    expect(style.left).toEqual('87px');
+    expect(style.top).toEqual('160px');
+  });
+});

+ 13 - 0
web/src/icons/Clip.jsx

@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Clip({ className = '' }) {
+  return (
+    <svg className={`fill-current ${className}`} viewBox="0 0 24 24">
+      <path d="M0 0h24v24H0z" fill="none" />
+      <path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z" />
+    </svg>
+  );
+}
+
+export default memo(Clip);

+ 12 - 0
web/src/icons/Motion.jsx

@@ -0,0 +1,12 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Motion({ className = '' }) {
+  return (
+    <svg className={`fill-current ${className}`} viewBox="0 0 24 24">
+      <path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7" />
+    </svg>
+  );
+}
+
+export default memo(Motion);

+ 14 - 0
web/src/icons/Snapshot.jsx

@@ -0,0 +1,14 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Snapshot({ className = '' }) {
+  return (
+    <svg className={`fill-current ${className}`} viewBox="0 0 24 24">
+      <path d="M0 0h24v24H0z" fill="none" />
+      <circle cx="12" cy="12" r="3.2" />
+      <path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z" />
+    </svg>
+  );
+}
+
+export default memo(Snapshot);

+ 39 - 1
web/src/routes/Cameras.jsx

@@ -2,6 +2,10 @@ import { h } from 'preact';
 import ActivityIndicator from '../components/ActivityIndicator';
 import Card from '../components/Card';
 import CameraImage from '../components/CameraImage';
+import ClipIcon from '../icons/Clip';
+import MotionIcon from '../icons/Motion';
+import SnapshotIcon from '../icons/Snapshot';
+import { useDetectState, useClipsState, useSnapshotsState } from '../api/mqtt';
 import { useConfig, FetchStatus } from '../api';
 import { useMemo } from 'preact/hooks';
 
@@ -20,8 +24,42 @@ export default function Cameras() {
 }
 
 function Camera({ name }) {
+  const { payload: detectValue, send: sendDetect } = useDetectState(name);
+  const { payload: clipValue, send: sendClips } = useClipsState(name);
+  const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
   const href = `/cameras/${name}`;
   const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]);
+  const icons = useMemo(
+    () => [
+      {
+        name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
+        icon: MotionIcon,
+        color: detectValue === 'ON' ? 'blue' : 'gray',
+        onClick: () => {
+          sendDetect(detectValue === 'ON' ? 'OFF' : 'ON');
+        },
+      },
+      {
+        name: `Toggle clips ${clipValue === 'ON' ? 'off' : 'on'}`,
+        icon: ClipIcon,
+        color: clipValue === 'ON' ? 'blue' : 'gray',
+        onClick: () => {
+          sendClips(clipValue === 'ON' ? 'OFF' : 'ON');
+        },
+      },
+      {
+        name: `Toggle snapshots ${snapshotValue === 'ON' ? 'off' : 'on'}`,
+        icon: SnapshotIcon,
+        color: snapshotValue === 'ON' ? 'blue' : 'gray',
+        onClick: () => {
+          sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON');
+        },
+      },
+    ],
+    [detectValue, sendDetect, clipValue, sendClips, snapshotValue, sendSnapshots]
+  );
 
-  return <Card buttons={buttons} href={href} header={name} media={<CameraImage camera={name} stretch />} />;
+  return (
+    <Card buttons={buttons} href={href} header={name} icons={icons} media={<CameraImage camera={name} stretch />} />
+  );
 }

+ 1 - 1
web/src/routes/Debug.jsx

@@ -14,7 +14,7 @@ export default function Debug() {
   const { data: config } = useConfig();
 
   const {
-    value: { stats },
+    value: { payload: stats },
   } = useMqtt('stats');
   const { data: initialStats } = useStats();
 

+ 7 - 0
web/src/routes/StyleGuide.jsx

@@ -25,6 +25,7 @@ export default function StyleGuide() {
         <Button>Default</Button>
         <Button color="red">Danger</Button>
         <Button color="green">Save</Button>
+        <Button color="gray">Gray</Button>
         <Button disabled>Disabled</Button>
       </div>
       <div className="flex space-x-4 mb-4">
@@ -35,6 +36,9 @@ export default function StyleGuide() {
         <Button color="green" type="text">
           Save
         </Button>
+        <Button color="gray" type="text">
+          Gray
+        </Button>
         <Button disabled type="text">
           Disabled
         </Button>
@@ -47,6 +51,9 @@ export default function StyleGuide() {
         <Button color="green" type="outlined">
           Save
         </Button>
+        <Button color="gray" type="outlined">
+          Gray
+        </Button>
         <Button disabled type="outlined">
           Disabled
         </Button>

+ 35 - 2
web/src/routes/__tests__/Cameras.test.jsx

@@ -1,8 +1,9 @@
 import { h } from 'preact';
 import * as Api from '../../api';
-import Cameras from '../Cameras';
 import * as CameraImage from '../../components/CameraImage';
-import { render, screen } from '@testing-library/preact';
+import * as Mqtt from '../../api/mqtt';
+import Cameras from '../Cameras';
+import { fireEvent, render, screen } from '@testing-library/preact';
 
 describe('Cameras Route', () => {
   let useConfigMock;
@@ -19,6 +20,7 @@ describe('Cameras Route', () => {
     }));
     jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
     jest.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
+    jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
   });
 
   test('shows an ActivityIndicator if not yet loaded', async () => {
@@ -38,4 +40,35 @@ describe('Cameras Route', () => {
     expect(screen.queryByText('side')).toBeInTheDocument();
     expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
   });
+
+  test('buttons toggle detect, clips, and snapshots', async () => {
+    const sendDetect = jest.fn();
+    const sendClips = jest.fn();
+    const sendSnapshots = jest.fn();
+    jest.spyOn(Mqtt, 'useDetectState').mockImplementation(() => {
+      return { payload: 'ON', send: sendDetect };
+    });
+    jest.spyOn(Mqtt, 'useClipsState').mockImplementation(() => {
+      return { payload: 'OFF', send: sendClips };
+    });
+    jest.spyOn(Mqtt, 'useSnapshotsState').mockImplementation(() => {
+      return { payload: 'ON', send: sendSnapshots };
+    });
+
+    render(<Cameras />);
+
+    fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]);
+    expect(sendDetect).toHaveBeenCalledWith('OFF');
+    expect(sendDetect).toHaveBeenCalledTimes(1);
+
+    fireEvent.click(screen.getAllByLabelText('Toggle snapshots off')[0]);
+    expect(sendSnapshots).toHaveBeenCalledWith('OFF');
+
+    fireEvent.click(screen.getAllByLabelText('Toggle clips on')[0]);
+    expect(sendClips).toHaveBeenCalledWith('ON');
+
+    expect(sendDetect).toHaveBeenCalledTimes(1);
+    expect(sendSnapshots).toHaveBeenCalledTimes(1);
+    expect(sendClips).toHaveBeenCalledTimes(1);
+  });
 });

+ 28 - 21
web/src/routes/__tests__/Debug.test.jsx

@@ -1,10 +1,11 @@
 import { h } from 'preact';
 import * as Api from '../../api';
+import * as Mqtt from '../../api/mqtt';
 import Debug from '../Debug';
 import { render, screen } from '@testing-library/preact';
 
 describe('Debug Route', () => {
-  let useStatsMock;
+  let useStatsMock, useMqttMock;
 
   beforeEach(() => {
     jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
@@ -16,10 +17,14 @@ describe('Debug Route', () => {
           front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
           side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
         },
+        mqtt: {
+          stats_interva: 60,
+        },
       },
       status: 'loaded',
     }));
-    useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => statsMock);
+    useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => ({ data: statsMock }));
+    useMqttMock = jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: null } }));
   });
 
   test('shows an ActivityIndicator if stats are null', async () => {
@@ -43,29 +48,31 @@ describe('Debug Route', () => {
     expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
   });
 
-  test('updates the stats on a timeout', async () => {
-    jest.spyOn(window, 'setTimeout').mockReturnValue(123);
-    render(<Debug />);
-    expect(useStatsMock).toHaveBeenCalledWith(null, null);
-    jest.advanceTimersByTime(1001);
-    expect(useStatsMock).toHaveBeenCalledWith(null, 123);
-    expect(useStatsMock).toHaveBeenCalledTimes(2);
+  test('updates the stats from  mqtt', async () => {
+    const { rerender } = render(<Debug />);
+    expect(useMqttMock).toHaveBeenCalledWith('stats');
+    useMqttMock.mockReturnValue({
+      value: {
+        payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } },
+      },
+    });
+    rerender(<Debug />);
+
+    expect(screen.queryByText('42.4242')).toBeInTheDocument();
   });
 });
 
 const statsMock = {
-  data: {
+  detection_fps: 0.0,
+  detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
+  front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
+  side: {
+    camera_fps: 6.9,
+    capture_pid: 71,
     detection_fps: 0.0,
-    detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
-    front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
-    side: {
-      camera_fps: 6.9,
-      capture_pid: 71,
-      detection_fps: 0.0,
-      pid: 60,
-      process_fps: 0.0,
-      skipped_fps: 0.0,
-    },
-    service: { uptime: 34812, version: '0.8.1-d376f6b' },
+    pid: 60,
+    process_fps: 0.0,
+    skipped_fps: 0.0,
   },
+  service: { uptime: 34812, version: '0.8.1-d376f6b' },
 };