Skip to content

Commit

Permalink
Merge pull request #47 from gemini-hlsw/alarms-louder
Browse files Browse the repository at this point in the history
Add sound and more noticeable elements to active guide alarms
  • Loading branch information
hugo-vrijswijk authored Jul 26, 2024
2 parents 7db3286 + fe0acec commit e493cb2
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 32 deletions.
Binary file added src/assets/sounds/alarm.mp3
Binary file not shown.
Binary file added src/assets/sounds/alarm.webm
Binary file not shown.
60 changes: 60 additions & 0 deletions src/components/Layout/AlarmAudio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useMemo } from 'react';
import { useAlarmValue } from '../atoms/alarm';
import alarmSoundMp3 from '@assets/sounds/alarm.mp3';
import alarmSoundWebm from '@assets/sounds/alarm.webm';

export function AlarmAudio() {
const alarm = useAlarmValue();

const alarmAudio = useMemo(() => {
const audio = selectPlayableAlarm();
audio.loop = true;

// Be nicer to developers' ears
if (import.meta.env.DEV) audio.volume = 0.3;

return audio;
}, []);

useEffect(() => {
const checkAlarm = () => {
if (alarm)
alarmAudio
.play()
.then(() => clearInterval(AudioRetryInterval))
.catch((err) => {
console.log('waiting for user interaction to play first notification', err);
});
else {
clearInterval(AudioRetryInterval);
alarmAudio.pause();
}
};

// Retry playing the alarm every 500ms until it plays, and start immediately
const AudioRetryInterval = setInterval(checkAlarm, 500);
checkAlarm();

// Stop the alarm when the component is unmounted
return () => {
clearInterval(AudioRetryInterval);
alarmAudio.pause();
alarmAudio.remove;
};
}, [alarm, alarmAudio]);

return <></>;
}

/**
* use mp3 if possible, otherwise use webm
*/
function selectPlayableAlarm(): HTMLAudioElement {
const mp3 = new Audio(alarmSoundMp3);
const webm = new Audio(alarmSoundWebm);

if (mp3.canPlayType('audio/mpeg')) return mp3;
else if (webm.canPlayType('audio/webm')) return webm;
// If neither can play, at least we'll try mp3
else return mp3;
}
2 changes: 2 additions & 0 deletions src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Outlet } from 'react-router-dom';
import Navbar from './Navbar/Navbar';
import './Layout.scss';
import { AlarmAudio } from './AlarmAudio';

export default function Layout() {
return (
<div className="layout">
<AlarmAudio />
<Navbar />
<div className="body">
<Outlet />
Expand Down
6 changes: 5 additions & 1 deletion src/components/Layout/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import './Navbar.scss';
import { useOdbTokenValue } from '@/components/atoms/odb';
import { useIsLoggedIn, useSignout, useUser } from '@/components/atoms/auth';
import { useConfiguration } from '@gql/configs/Configuration';
import { useAlarmValue } from '@/components/atoms/alarm';
import clsx from 'clsx';

export default function Navbar() {
const configuration = useConfiguration().data?.configuration;
Expand All @@ -18,6 +20,8 @@ export default function Navbar() {
// Will be removed in the future
const odbToken = useOdbTokenValue();

const alarm = useAlarmValue();

const themeIcon: string = theme === 'dark' ? 'pi pi-moon' : 'pi pi-sun';

function userSession() {
Expand Down Expand Up @@ -47,7 +51,7 @@ export default function Navbar() {
];

return (
<nav className="top-bar">
<nav className={clsx('top-bar', alarm && 'animate-error-bg')}>
<div className="left">
<Link to="/">
<Button icon="pi pi-map" iconPos="left" className="p-button-text nav-btn main-title">
Expand Down
6 changes: 3 additions & 3 deletions src/components/Panels/Guider/Alarms/Alarm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { clsx } from 'clsx';
import { Checkbox, CheckboxChangeEvent } from 'primereact/checkbox';
import { InputNumber, InputNumberValueChangeEvent } from 'primereact/inputnumber';
import { useId } from 'react';
import { evaluateAlarm } from './Alarms';

export function Alarm({
wfs,
Expand Down Expand Up @@ -34,13 +35,12 @@ export function Alarm({
}

const disabledOrNoData = disabled || !guideQuality || !alarm;
const hasAlarm = enabled && ((guideQuality?.flux ?? 0) < (limit ?? 0) || !guideQuality?.centroidDetected);
const hasAlarm = evaluateAlarm(alarm, guideQuality);

return (
<div className={clsx('alarm', hasAlarm && 'has-alarm')}>
<div className={clsx('alarm', hasAlarm && 'has-alarm animate-error-bg')}>
<div className="title-bar">
<Title title={wfs} />
<div className="alarm-indicator animate-ping" />
</div>
<div className="body">
<label htmlFor={`flux-${id}`} className="label">
Expand Down
31 changes: 28 additions & 3 deletions src/components/Panels/Guider/Alarms/Alarms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { GET_GUIDE_ALARMS, UPDATE_GUIDE_ALARM } from '@gql/configs/GuideAlarm';
import { renderWithContext } from '@gql/render';
import { GUIDE_QUALITY_SUBSCRIPTION } from '@gql/server/GuideQuality';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { Alarms } from './Alarms';
import { Alarms, evaluateAlarm } from './Alarms';
import { guideAlarmAtom } from '@/components/atoms/alarm';

describe(Alarms.name, () => {
let store: ReturnType<typeof renderWithContext>['store'];
beforeEach(async () => {
renderWithContext(<Alarms />, { mocks });
store = renderWithContext(<Alarms />, { mocks }).store;

// Wait for the alarms to be loaded
await waitFor(async () => !(await screen.findAllByLabelText<HTMLInputElement>('Limit'))[0].disabled);
Expand All @@ -19,7 +21,7 @@ describe(Alarms.name, () => {
expect(screen.queryByText('OIWFS')).not.toBeNull();
});

it('calls updatAlarm when limit is changed', async () => {
it('calls updateAlarm when limit is changed', async () => {
const limitInput = screen.getAllByLabelText('Limit')[0];

fireEvent.change(limitInput, { target: { value: '900' } });
Expand All @@ -28,6 +30,29 @@ describe(Alarms.name, () => {
await waitFor(async () =>
expect((await screen.findAllByLabelText<HTMLInputElement>('Limit'))[0].value).equals('900'),
);
expect(store.get(guideAlarmAtom)).true;
});
});

describe(evaluateAlarm.name, () => {
it('should be false if no alarm is set', () => {
expect(evaluateAlarm(undefined, { centroidDetected: false, flux: 900 })).false;
});

it('should be false if no guide quality is set', () => {
expect(evaluateAlarm({ enabled: true, limit: 900, wfs: 'OIWFS' }, undefined)).false;
});

it('should be false if the flux is above the limit', () => {
expect(evaluateAlarm({ enabled: true, limit: 900, wfs: 'OIWFS' }, { centroidDetected: true, flux: 900 })).false;
});

it('should be true if no centroid is detected', () => {
expect(evaluateAlarm({ enabled: true, limit: 900, wfs: 'OIWFS' }, { centroidDetected: false, flux: 900 })).true;
});

it('should be true if flux is below the limit', () => {
expect(evaluateAlarm({ enabled: true, limit: 900, wfs: 'OIWFS' }, { centroidDetected: true, flux: 899 })).true;
});
});

Expand Down
23 changes: 22 additions & 1 deletion src/components/Panels/Guider/Alarms/Alarms.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { UpdateGuideAlarmMutationVariables } from '@gql/configs/gen/graphql';
import { GuideAlarm, UpdateGuideAlarmMutationVariables } from '@gql/configs/gen/graphql';
import { useGuideAlarms, useUpdateGuideAlarm } from '@gql/configs/GuideAlarm';
import { useGuideQualities } from '@gql/server/GuideQuality';
import { Title } from '@Shared/Title/Title';
import { Alarm } from './Alarm';
import { useCanEdit } from '@/components/atoms/auth';
import { GuideQuality } from '@gql/server/gen/graphql';
import { useEffect } from 'react';
import { useSetGuideAlarm } from '@/components/atoms/alarm';

export function Alarms() {
const canEdit = useCanEdit();
const toggleGuideAlarm = useSetGuideAlarm();

const { data, loading: subscriptionLoading } = useGuideQualities();
const guideQualities = data?.guidersQualityValues;
Expand All @@ -16,6 +20,17 @@ export function Alarms() {

const [updateAlarm] = useUpdateGuideAlarm();

useEffect(() => {
const hasAlarm =
!!alarms &&
!!guideQualities &&
(evaluateAlarm(alarms.OIWFS, guideQualities.oiwfs) ||
evaluateAlarm(alarms.PWFS1, guideQualities.pwfs1) ||
evaluateAlarm(alarms.PWFS2, guideQualities.pwfs2));

toggleGuideAlarm(hasAlarm);
}, [alarms, guideQualities]);

function onUpdateAlarm(variables: UpdateGuideAlarmMutationVariables) {
updateAlarm({
variables,
Expand Down Expand Up @@ -56,3 +71,9 @@ export function Alarms() {
</div>
);
}

export function evaluateAlarm(alarm: GuideAlarm | undefined, guideQuality: GuideQuality | undefined): boolean {
if (!alarm || !guideQuality) return false;

return alarm.enabled && (guideQuality.flux < alarm.limit || !guideQuality.centroidDetected);
}
29 changes: 15 additions & 14 deletions src/components/Panels/Guider/Guider.scss
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,6 @@
grid-column-start: 2;
background-color: transparent;
}
> .alarm-indicator {
margin-left: auto;
margin-right: 10px;
}
}

> .body {
Expand All @@ -301,19 +297,9 @@
}
}
}
.alarm-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: transparent;
}

&.has-alarm {
border: 1px solid var(--red-500);

.alarm-indicator {
background-color: var(--red-500);
}
}
}
}
Expand All @@ -332,3 +318,18 @@
}
}
}

.animate-error-bg {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;

@keyframes pulse {
0%,
100% {
background-color: inherit;
opacity: 1;
}
50% {
background-color: var(--red-600);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export default function WavefrontSensor({ canEdit, wfs }: { canEdit: boolean; wf
const [freq, setFreq] = useState(100);
const [observeState, setObserveState] = useState(false);
let observeButton;
const startObserve = useOiwfsObserve();
const stopObserve = useOiwfsStopObserve();
if (wfs === 'OIWFS') {
const startObserve = useOiwfsObserve();
const stopObserve = useOiwfsStopObserve();
if (observeState) {
observeButton = (
<Button
Expand Down
17 changes: 17 additions & 0 deletions src/components/atoms/alarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithReset, RESET } from 'jotai/utils';

// More types can be added as needed
export type AlarmType = 'Guide';

const alarmAtom = atomWithReset<AlarmType | null>(null);
export const useAlarm = () => useAtom(alarmAtom);
export const useAlarmValue = () => useAtomValue(alarmAtom);

export const guideAlarmAtom = atom(
(get) => get(alarmAtom) === 'Guide',
(_get, set, value: boolean) => set(alarmAtom, value ? 'Guide' : RESET),
);
export const useGuideAlarm = () => useAtom(guideAlarmAtom);
export const useSetGuideAlarm = () => useSetAtom(guideAlarmAtom);
export const useGuideAlarmValue = () => useAtomValue(guideAlarmAtom);
9 changes: 6 additions & 3 deletions src/gql/render.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { render, RenderOptions } from '@testing-library/react';
import { Provider, WritableAtom } from 'jotai';
import { createStore, Provider, WritableAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import { PropsWithChildren, ReactElement } from 'react';
import { GET_SLEW_FLAGS } from './configs/SlewFlags';
Expand All @@ -26,8 +26,9 @@ export function renderWithContext<T extends AtomTuples>(
createOptions: CreateOptions<T> = {},
options?: RenderOptions,
) {
return render(
<Provider>
const store = createStore();
const renderResult = render(
<Provider store={store}>
<HydrateAtoms initialValues={createOptions.initialValues ?? ([] as InferAtomTuples<T>)}>
<MockedProvider mocks={[...mocks, ...(createOptions.mocks ?? [])]} addTypename={false}>
{ui}
Expand All @@ -36,6 +37,8 @@ export function renderWithContext<T extends AtomTuples>(
</Provider>,
options,
);

return { ...renderResult, store };
}

const mocks: MockedResponse[] = [
Expand Down
13 changes: 8 additions & 5 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,14 @@ export default defineConfig(({ mode }) => ({
},
plugins: [
react({
plugins: [
// https://jotai.org/docs/tools/swc
['@swc-jotai/react-refresh', {}],
['@swc-jotai/debug-label', {}],
],
plugins:
mode !== 'production'
? [
// https://jotai.org/docs/tools/swc
['@swc-jotai/react-refresh', {}],
['@swc-jotai/debug-label', {}],
]
: [],
}),
mkcert({ hosts: ['localhost', 'local.lucuma.xyz', 'navigate.lucuma.xyz'] }),
],
Expand Down

0 comments on commit e493cb2

Please sign in to comment.