diff --git a/cypress/e2e/camera_spec/camera_boundary.cy.ts b/cypress/e2e/camera_spec/camera_boundary.cy.ts new file mode 100644 index 00000000000..3e8f4ebee4e --- /dev/null +++ b/cypress/e2e/camera_spec/camera_boundary.cy.ts @@ -0,0 +1,70 @@ +import { cy, describe, before, beforeEach, it } from "local-cypress"; +const user = { username: "devdistrictadmin", password: "Coronasafe@123" }; +describe("Camera Boundary", () => { + before(() => { + cy.loginByApi(user.username, user.password); + cy.saveLocalStorage(); + }); + beforeEach(() => { + cy.restoreLocalStorage(); + cy.awaitUrl("/assets"); + cy.get("input[id='search']").type("Dev Camera"); + cy.contains("a", "Dev Camera").contains("a", "ICU").click(); + cy.get("button[id='configure-asset']").click(); + }); + + it("Add new boundary", () => { + cy.get("input[name='bed']").type("bed 01"); + cy.get("li[role='option']").contains("Bed 01").click(); + cy.wait(2000); + cy.intercept("**/api/v1/assetbed/**").as("addBoundary"); + cy.get("button[id='add-boundary-preset']").click(); + cy.wait("@addBoundary"); + }); + + it("Update boundary", () => { + cy.get("input[name='bed']").type("bed 01"); + cy.get("li[role='option']").contains("Bed 01").click(); + cy.wait(2000); + cy.get("button[id='update-boundary-preset']").click(); + cy.intercept("**/api/v1/assetbed/**").as("updateBoundary"); + cy.get("button") + .find("svg.care-svg-icon__baseline.care-l-angle-right") + .should("be.visible") + .first() + .click(); + cy.wait("@updateBoundary"); + cy.get("button").contains("Next").click(); + cy.get("button") + .find("svg.care-svg-icon__baseline.care-l-angle-right") + .should("be.visible") + .first() + .click(); + cy.wait("@updateBoundary"); + cy.get("button").contains("Next").click(); + cy.get("button") + .find("svg.care-svg-icon__baseline.care-l-angle-up") + .should("be.visible") + .first() + .click(); + cy.wait("@updateBoundary"); + cy.get("button").contains("Next").click(); + cy.get("button") + .find("svg.care-svg-icon__baseline.care-l-angle-down") + .should("be.visible") + .first() + .click(); + cy.wait("@updateBoundary"); + cy.get("button").contains("Done").click(); + }); + + it("Delete boundary", () => { + cy.get("input[name='bed']").type("bed 01"); + cy.get("li[role='option']").contains("Bed 01").click(); + cy.wait(1000); + cy.intercept("**/api/v1/assetbed/**").as("deleteBoundary"); + cy.get("button[id='delete-boundary-preset']").click(); + cy.get("button").contains("Delete").click(); + cy.wait("@deleteBoundary"); + }); +}); diff --git a/src/Common/constants.tsx b/src/Common/constants.tsx index e8ab4867764..b830996c58c 100644 --- a/src/Common/constants.tsx +++ b/src/Common/constants.tsx @@ -1000,3 +1000,11 @@ export const XLSXAssetImportSchema = { }, }, }; + +export type direction = "left" | "right" | "up" | "down" | null; +export interface BoundaryRange { + max_x: number; + min_x: number; + max_y: number; + min_y: number; +} diff --git a/src/Common/hooks/useFeedPTZ.ts b/src/Common/hooks/useFeedPTZ.ts index 32cc5e6bc25..cb0026c93bd 100644 --- a/src/Common/hooks/useFeedPTZ.ts +++ b/src/Common/hooks/useFeedPTZ.ts @@ -8,6 +8,8 @@ interface PTZPayload { x: number; y: number; zoom: number; + id?: string | null; + camera_state?: PTZState | null; } export interface PTZState { @@ -48,6 +50,9 @@ interface UseMSEMediaPlayerReturnType { getCameraStatus: (options: IOptions) => void; getPresets: (options: IOptions) => void; gotoPreset: (payload: IGotoPresetPayload, options: IOptions) => void; + lockAsset: (options: IOptions) => void; + unlockAsset: (options: IOptions) => void; + requestAccess: (options: IOptions) => void; } interface IOptions { @@ -189,6 +194,57 @@ export const getPTZPayload = ( return { x, y, zoom }; }; +const lockAsset = + (config: IAsset, dispatch: any) => + async (options: IOptions = {}) => { + if (!config.id) return; + const resp = await dispatch( + operateAsset(config.id, { + action: { + type: "lock_asset", + }, + }) + ); + resp && + (resp.status === 200 + ? options?.onSuccess && options.onSuccess(resp.data.result) + : options?.onError && options.onError(resp)); + }; + +const requestAccess = + (config: IAsset, dispatch: any) => + async (options: IOptions = {}) => { + if (!config.id) return; + const resp = await dispatch( + operateAsset(config.id, { + action: { + type: "request_access", + }, + }) + ); + resp && + (resp.status === 200 + ? options?.onSuccess && options.onSuccess(resp.data.result) + : options?.onError && options.onError(resp)); + }; + +const unlockAsset = + (config: IAsset, dispatch: any) => + async (options: IOptions = {}) => { + if (!config.id) return; + const resp = await dispatch( + operateAsset(config.id, { + action: { + type: "unlock_asset", + }, + }) + ); + resp && + (resp.status === 200 + ? options?.onSuccess && options.onSuccess(resp.data.result) + : options?.onError && options.onError(resp)); + }; + export const useFeedPTZ = ({ config, dispatch, @@ -200,5 +256,8 @@ export const useFeedPTZ = ({ getCameraStatus: getCameraStatus(config, dispatch), getPresets: getPresets(config, dispatch), gotoPreset: gotoPreset(config, dispatch), + lockAsset: lockAsset(config, dispatch), + unlockAsset: unlockAsset(config, dispatch), + requestAccess: requestAccess(config, dispatch), }; }; diff --git a/src/Common/hooks/useNotificationSubscribe.ts b/src/Common/hooks/useNotificationSubscribe.ts new file mode 100644 index 00000000000..8355a8e3cae --- /dev/null +++ b/src/Common/hooks/useNotificationSubscribe.ts @@ -0,0 +1,131 @@ +import { useDispatch } from "react-redux"; +import { + getUserPnconfig, + updateUserPnconfig, + getPublicKey, +} from "../../Redux/actions"; +import { useState } from "react"; +import * as Sentry from "@sentry/browser"; +import { useTranslation } from "react-i18next"; +import { Error } from "../../Utils/Notifications.js"; +import useAuthUser from "../../Common/hooks/useAuthUser"; + +export default function useNotificationSubscribe() { + const { username } = useAuthUser(); + const dispatch: any = useDispatch(); + const [isSubscribed, setIsSubscribed] = useState(""); + const [isSubscribing, setIsSubscribing] = useState(false); + const { t } = useTranslation(); + + const intialSubscriptionState = async () => { + try { + const res = await dispatch(getUserPnconfig({ username: username })); + const reg = await navigator.serviceWorker.ready; + const subscription = await reg.pushManager.getSubscription(); + if (!subscription && !res?.data?.pf_endpoint) { + setIsSubscribed("NotSubscribed"); + } else if (subscription?.endpoint === res?.data?.pf_endpoint) { + setIsSubscribed("SubscribedOnThisDevice"); + } else { + setIsSubscribed("SubscribedOnAnotherDevice"); + } + } catch (error) { + Sentry.captureException(error); + } + }; + + const handleSubscribeClick = () => { + const status = isSubscribed; + if (status === "NotSubscribed" || status === "SubscribedOnAnotherDevice") { + subscribe(); + } else { + unsubscribe(); + } + }; + + const unsubscribe = () => { + navigator.serviceWorker.ready + .then(function (reg) { + setIsSubscribing(true); + reg.pushManager + .getSubscription() + .then(function (subscription) { + subscription + ?.unsubscribe() + .then(async function (_successful) { + const data = { + pf_endpoint: "", + pf_p256dh: "", + pf_auth: "", + }; + await dispatch( + updateUserPnconfig(data, { username: username }) + ); + + setIsSubscribed("NotSubscribed"); + setIsSubscribing(false); + }) + .catch(function (_e) { + Error({ + msg: t("unsubscribe_failed"), + }); + }); + }) + .catch(function (_e) { + Error({ msg: t("subscription_error") }); + }); + }) + .catch(function (_e) { + Sentry.captureException(_e); + }); + }; + + async function subscribe() { + setIsSubscribing(true); + const response = await dispatch(getPublicKey()); + const public_key = response.data.public_key; + const sw = await navigator.serviceWorker.ready; + const push = await sw.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: public_key, + }); + const p256dh = btoa( + String.fromCharCode.apply( + null, + new Uint8Array(push.getKey("p256dh") as any) as any + ) + ); + const auth = btoa( + String.fromCharCode.apply( + null, + new Uint8Array(push.getKey("auth") as any) as any + ) + ); + + const data = { + pf_endpoint: push.endpoint, + pf_p256dh: p256dh, + pf_auth: auth, + }; + + const res = await dispatch( + updateUserPnconfig(data, { username: username }) + ); + + if (res.status >= 200 && res.status <= 300) { + setIsSubscribed("SubscribedOnThisDevice"); + } + setIsSubscribing(false); + } + + return { + isSubscribed, + isSubscribing, + setIsSubscribed, + setIsSubscribing, + handleSubscribeClick, + intialSubscriptionState, + subscribe, + unsubscribe, + }; +} diff --git a/src/Components/Assets/AssetType/ONVIFCamera.tsx b/src/Components/Assets/AssetType/ONVIFCamera.tsx index ec11761abf6..dc782736d3a 100644 --- a/src/Components/Assets/AssetType/ONVIFCamera.tsx +++ b/src/Components/Assets/AssetType/ONVIFCamera.tsx @@ -3,19 +3,24 @@ import { AssetData } from "../AssetTypes"; import { useDispatch } from "react-redux"; import { partialUpdateAsset, + partialUpdateAssetBed, createAssetBed, getPermittedFacility, + listAssetBeds, + deleteAssetBed, } from "../../../Redux/actions"; import * as Notification from "../../../Utils/Notifications.js"; import { BedModel } from "../../Facility/models"; import axios from "axios"; import { getCameraConfig } from "../../../Utils/transformUtils"; -import CameraConfigure from "../configure/CameraConfigure"; import Loading from "../../Common/Loading"; import { checkIfValidIP } from "../../../Common/validation"; import TextFormField from "../../Form/FormFields/TextFormField"; import { Submit } from "../../Common/components/ButtonV2"; import { SyntheticEvent } from "react"; +import LiveFeed from "../../Facility/Consultations/LiveFeed"; +import Card from "../../../CAREUI/display/Card"; +import { BoundaryRange } from "../../../Common/constants"; interface ONVIFCameraProps { assetId: string; @@ -42,8 +47,90 @@ const ONVIFCamera = (props: ONVIFCameraProps) => { const [refreshPresetsHash, setRefreshPresetsHash] = useState( Number(new Date()) ); + const [boundaryPreset, setBoundaryPreset] = useState(null); + const [toUpdateBoundary, setToUpdateBoundary] = useState(false); + const [loadingAddBoundaryPreset, setLoadingAddBoundaryPreset] = + useState(false); + const [presets, setPresets] = useState([]); const dispatch = useDispatch(); + const mapZoomToBuffer = (zoom: number): number => { + interface bufferAtZoom { + [key: string]: number; + } + const bufferAtMaxZoom: bufferAtZoom = { + "0.3": 0.5, + "0.4": 0.25, + "0.5": 0.125, + }; + let buffer = 0; + Object.keys(bufferAtMaxZoom).forEach((key: string) => { + if (zoom <= Number(key)) { + buffer = bufferAtMaxZoom[key]; + } + }); + return buffer !== 0 ? buffer : 0.0625; + }; + + const calcBoundary = (presets: any[]): BoundaryRange => { + const INT_MAX = 3; + const boundary: BoundaryRange = { + max_x: -INT_MAX, + min_x: INT_MAX, + max_y: -INT_MAX, + min_y: INT_MAX, + }; + + const edgePresetsZoom: BoundaryRange = { + max_x: 0, + min_x: 0, + max_y: 0, + min_y: 0, + }; + + presets.forEach((preset: any) => { + if (preset?.meta?.position) { + const position = preset.meta.position; + if (position.x > boundary.max_x) { + boundary.max_x = position.x; + edgePresetsZoom.max_x = position.zoom; + } + if (position.x < boundary.min_x) { + boundary.min_x = position.x; + edgePresetsZoom.min_x = position.zoom; + } + if (position.y > boundary.max_y) { + boundary.max_y = position.y; + edgePresetsZoom.max_y = position.zoom; + } + if (position.y < boundary.min_y) { + boundary.min_y = position.y; + edgePresetsZoom.min_y = position.zoom; + } + } + }); + + Object.keys(edgePresetsZoom).forEach((key) => { + const zoom = edgePresetsZoom[key as keyof BoundaryRange]; + const buffer = mapZoomToBuffer(zoom); + + if (key == "max_x" || key == "max_y") { + boundary[key] = boundary[key] + buffer; + } else { + boundary[key as keyof BoundaryRange] = + boundary[key as keyof BoundaryRange] - buffer; + } + }); + if (boundary.max_x <= boundary.min_x || boundary.max_y <= boundary.min_y) { + return { + max_x: INT_MAX, + min_x: -INT_MAX, + max_y: INT_MAX, + min_y: -INT_MAX, + }; + } + return boundary; + }; useEffect(() => { const fetchFacility = async () => { const res = await dispatch(getPermittedFacility(facilityId)); @@ -69,6 +156,37 @@ const ONVIFCamera = (props: ONVIFCameraProps) => { setIsLoading(false); }, [asset]); + const fetchBoundaryBedPreset = async () => { + const res = await dispatch(listAssetBeds({ bed: bed.id })); + if (res && res.status === 200 && res.data) { + let bedAssets: any[] = res.data.results; + + if (bedAssets.length > 0) { + let boundaryPreset = null; + bedAssets = bedAssets.filter((bedAsset: any) => { + if (bedAsset?.asset_object?.meta?.asset_type != "CAMERA") { + return false; + } else if (bedAsset?.meta?.type == "boundary") { + boundaryPreset = bedAsset; + return false; + } else if (bedAsset?.meta?.position) { + return true; + } + return false; + }); + if (boundaryPreset) { + setBoundaryPreset(boundaryPreset); + } else { + setBoundaryPreset(null); + } + setPresets(bedAssets); + } + } else { + setPresets([]); + setBoundaryPreset(null); + } + }; + const handleSubmit = async (e: SyntheticEvent) => { e.preventDefault(); if (checkIfValidIP(cameraAddress)) { @@ -101,8 +219,113 @@ const ONVIFCamera = (props: ONVIFCameraProps) => { } }; - const addPreset = async (e: SyntheticEvent) => { - e.preventDefault(); + const addBoundaryPreset = async () => { + const config = getCameraConfig(asset as AssetData); + try { + setLoadingAddBoundaryPreset(true); + + if (bed?.id) { + const presetData = await axios.get( + `https://${facilityMiddlewareHostname}/status?hostname=${config.hostname}&port=${config.port}&username=${config.username}&password=${config.password}` + ); + const range = calcBoundary(presets); + const meta = { + type: "boundary", + preset_name: `${bed?.name} boundary`, + bed_id: bed?.id, + error: presetData.data.error, + utcTime: presetData.data.utcTime, + range: range, + }; + const res = await Promise.resolve( + dispatch(createAssetBed({ meta: meta }, assetId, bed?.id as string)) + ); + if (res?.status === 201) { + Notification.Success({ + msg: "Boundary Preset Added Successfully", + }); + // setBed({}); + setRefreshPresetsHash(Number(new Date())); + } else { + Notification.Error({ + msg: "Failed to add Boundary Preset", + }); + } + } else { + Notification.Error({ + msg: "Please select a bed to add Boundary Preset", + }); + } + } catch (e) { + Notification.Error({ + msg: "Something went wrong..!", + }); + } + setLoadingAddBoundaryPreset(false); + }; + + const updateBoundaryPreset = async () => { + if (boundaryPreset && bed?.id) { + try { + if ( + !boundaryPreset?.asset_object?.id || + !boundaryPreset?.bed_object?.id + ) { + Notification.Error({ + msg: "Something went wrong..!", + }); + return; + } + const data = { + asset: boundaryPreset.asset_object.id, + bed: boundaryPreset.bed_object.id, + meta: boundaryPreset.meta, + }; + const res = await Promise.resolve( + dispatch(partialUpdateAssetBed(data, boundaryPreset.id as string)) + ); + if (res?.status === 200) { + Notification.Success({ + msg: "Boundary Preset Modified Successfully", + }); + } else { + Notification.Error({ + msg: "Failed to modify Boundary Preset", + }); + } + } catch (e) { + Notification.Error({ + msg: "Something went wrong..!", + }); + } + } + }; + const deleteBoundaryPreset = async () => { + if (boundaryPreset) { + try { + const res = await Promise.resolve( + dispatch(deleteAssetBed(boundaryPreset.id)) + ); + if (res?.status === 204) { + Notification.Success({ + msg: "Boundary Preset Deleted Successfully", + }); + // setBed({}); + setRefreshPresetsHash(Number(new Date())); + } else { + Notification.Error({ + msg: "Failed to delete Boundary Preset", + }); + } + } catch (e) { + Notification.Error({ + msg: "Something went wrong..!", + }); + } + } + }; + + const addPreset = async () => { const config = getCameraConfig(asset as AssetData); const data = { bed_id: bed.id, @@ -126,14 +349,13 @@ const ONVIFCamera = (props: ONVIFCameraProps) => { Notification.Success({ msg: "Preset Added Successfully", }); - setBed({}); - setNewPreset(""); - setRefreshPresetsHash(Number(new Date())); } else { Notification.Error({ msg: "Something went wrong..!", }); } + setNewPreset(""); + setRefreshPresetsHash(Number(new Date())); } catch (e) { Notification.Error({ msg: "Something went wrong..!", @@ -141,9 +363,7 @@ const ONVIFCamera = (props: ONVIFCameraProps) => { } setLoadingAddPreset(false); }; - if (isLoading) return ; - return (
@@ -199,17 +419,31 @@ const ONVIFCamera = (props: ONVIFCameraProps) => {
{assetType === "ONVIF" ? ( - + <> + + + + ) : null}
); diff --git a/src/Components/Assets/configure/CameraBoundaryConfigure.tsx b/src/Components/Assets/configure/CameraBoundaryConfigure.tsx new file mode 100644 index 00000000000..1bd598278f5 --- /dev/null +++ b/src/Components/Assets/configure/CameraBoundaryConfigure.tsx @@ -0,0 +1,265 @@ +import { useState } from "react"; +import { BedModel } from "../../Facility/models"; +import ConfirmDialog from "../../Common/ConfirmDialog"; +import CareIcon from "../../../CAREUI/icons/CareIcon"; +import { direction } from "../../../Common/constants"; +interface CameraBoundaryConfigureProps { + addBoundaryPreset: () => void; + deleteBoundaryPreset: () => void; + boundaryPreset: any; + bed: BedModel; + toUpdateBoundary: boolean; + setToUpdateBoundary: (toUpdate: boolean) => void; + loadingAddBoundaryPreset: boolean; + toAddPreset: boolean; + setDirection: (direction: direction) => void; + isPreview: boolean; + previewBoundary: () => void; +} + +interface UpdateCameraBoundaryConfigureProps { + direction: direction; + setDirection(direction: direction): void; + setToUpdateBoundary: (toUpdate: boolean) => void; + updateBoundaryInfo: Record; + setUpdateBoundaryInfo: (info: Record) => void; +} +export default function CameraBoundaryConfigure( + props: CameraBoundaryConfigureProps +) { + const { + addBoundaryPreset, + deleteBoundaryPreset, + boundaryPreset, + bed, + toUpdateBoundary, + setToUpdateBoundary, + loadingAddBoundaryPreset, + toAddPreset, + setDirection, + isPreview, + previewBoundary, + } = props; + const [toDeleteBoundary, setToDeleteBoundary] = useState(null); + return ( + <> + {toDeleteBoundary && ( + +

+ Boundary preset:{" "} + {toDeleteBoundary.meta.preset_name} +

+

+ Bed: {toDeleteBoundary.bed_object.name} +

+ + } + action="Delete" + variant="danger" + onClose={() => setToDeleteBoundary(null)} + onConfirm={() => { + deleteBoundaryPreset(); + setToDeleteBoundary(null); + }} + /> + )} + + {bed?.id && !boundaryPreset ? ( +
+ +
+ ) : ( + <> + {bed?.id && !toUpdateBoundary && ( +
+
+ +
{`${ + !boundaryPreset + ? bed?.name + : boundaryPreset?.meta?.preset_name + } ${!boundaryPreset ? "boundary" : ""}`}
+
+
+ + + +
+
+ )} + + )} + + ); +} + +export function UpdateCameraBoundaryConfigure( + props: UpdateCameraBoundaryConfigureProps +) { + const { + direction, + setDirection, + setToUpdateBoundary, + updateBoundaryInfo, + setUpdateBoundaryInfo, + } = props; + + const translation: Record = { + left: "Left", + right: "Right", + up: "Top", + down: "Bottom", + }; + + const handlePrevButtonClick = () => { + switch (direction) { + case "left": + setToUpdateBoundary(false); + setDirection(null); + setUpdateBoundaryInfo({ + left: false, + right: false, + up: false, + down: false, + }); + break; + + case "right": + setDirection("left"); + break; + + case "up": + setDirection("right"); + break; + + case "down": + setDirection("up"); + break; + + default: + break; + } + }; + + const showUpdateBoundaryInfo = (dir: string, updated: boolean) => { + if (dir == direction) { + return ( +
+ updating +
+ ); + } + if (updated) { + return ( +
+ updated +
+ ); + } + return ( +
+ not updated +
+ ); + }; + + const handleNextButtonClick = () => { + switch (direction) { + case "left": + setDirection("right"); + break; + case "right": + setDirection("up"); + break; + case "up": + setDirection("down"); + break; + case "down": + setDirection(null); + setToUpdateBoundary(false); + setUpdateBoundaryInfo({ + left: false, + right: false, + up: false, + down: false, + }); + break; + default: + break; + } + }; + + return ( +
+
+ Update boundary +
+
+ {["left", "right", "up", "down"].map((dir) => { + return ( +
+
{translation[dir]}
+
+ {showUpdateBoundaryInfo(dir, updateBoundaryInfo[dir])} +
+
+ ); + })} +
+
+ + +
+
+ ); +} diff --git a/src/Components/Assets/configure/CameraConfigure.tsx b/src/Components/Assets/configure/CameraConfigure.tsx index c3ba434ef3f..743744acf57 100644 --- a/src/Components/Assets/configure/CameraConfigure.tsx +++ b/src/Components/Assets/configure/CameraConfigure.tsx @@ -1,81 +1,55 @@ -import { SyntheticEvent } from "react"; -import { AssetData } from "../AssetTypes"; -import LiveFeed from "../../Facility/Consultations/LiveFeed"; -import { BedSelect } from "../../Common/BedSelect"; -import { BedModel } from "../../Facility/models"; -import { getCameraConfig } from "../../../Utils/transformUtils"; -import { Submit } from "../../Common/components/ButtonV2"; import TextFormField from "../../Form/FormFields/TextFormField"; -import Card from "../../../CAREUI/display/Card"; interface CameraConfigureProps { - asset: AssetData; - addPreset(e: SyntheticEvent): void; - setBed(bed: BedModel): void; - bed: BedModel; + addPreset: () => void; + setToAddPreset: (toAddPreset: boolean) => void; newPreset: string; setNewPreset(preset: string): void; - refreshPresetsHash: number; - facilityMiddlewareHostname: string; isLoading: boolean; } export default function CameraConfigure(props: CameraConfigureProps) { - const { - asset, - addPreset, - setBed, - bed, - isLoading, - newPreset, - setNewPreset, - refreshPresetsHash, - facilityMiddlewareHostname, - } = props; + const { addPreset, newPreset, setNewPreset, isLoading, setToAddPreset } = + props; return ( -
- -
-
-
- - setBed(selected as BedModel)} - selected={bed} - error="" - multiple={false} - location={asset?.location_object?.id} - facility={asset?.location_object?.facility?.id} - /> -
-
- - setNewPreset(e.value)} - error="" - /> -
-
-
- -
-
-
- - +
+ Add preset +
+
+ + setNewPreset(e.value)} + error="" /> - +
+ + +
+
); } diff --git a/src/Components/Facility/Consultations/Feed.tsx b/src/Components/Facility/Consultations/Feed.tsx index caca55102ca..8c4129079a1 100644 --- a/src/Components/Facility/Consultations/Feed.tsx +++ b/src/Components/Facility/Consultations/Feed.tsx @@ -1,5 +1,4 @@ import * as Notification from "../../../Utils/Notifications.js"; - import { CAMERA_STATES, CameraPTZ, @@ -19,6 +18,8 @@ import { partialUpdateAssetBed, } from "../../../Redux/actions"; import { statusType, useAbortableEffect } from "../../../Common/utils"; +import ButtonV2 from "../../Common/components/ButtonV2.js"; +import Spinner from "../../Common/Spinner.js"; import CareIcon from "../../../CAREUI/icons/CareIcon.js"; import { ConsultationModel } from "../models"; @@ -31,16 +32,27 @@ import { useHLSPLayer } from "../../../Common/hooks/useHLSPlayer"; import useKeyboardShortcut from "use-keyboard-shortcut"; import useFullscreen from "../../../Common/hooks/useFullscreen.js"; import { triggerGoal } from "../../Common/Plausible.js"; +import { useMessageListener } from "../../../Common/hooks/useMessageListener.js"; +import useNotificationSubscribe from "../../../Common/hooks/useNotificationSubscribe.js"; import useAuthUser from "../../../Common/hooks/useAuthUser.js"; interface IFeedProps { facilityId: string; consultationId: any; } + +interface cameraOccupier { + username?: string; + firstName?: string; + lastName?: string; + role?: string; + homeFacility?: string; +} const PATIENT_DEFAULT_PRESET = "Patient View".trim().toLowerCase(); export const Feed: React.FC = ({ consultationId, facilityId }) => { const dispatch: any = useDispatch(); + const CAMERA_ACCESS_TIMEOUT = 10 * 60; //seconds const videoWrapper = useRef(null); @@ -55,9 +67,134 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { const [bed, setBed] = useState(); const [precision, setPrecision] = useState(1); const [cameraState, setCameraState] = useState(null); + const [boundaryPreset, setBoundaryPreset] = useState(); const [isFullscreen, setFullscreen] = useFullscreen(); + + // Information about subscription and camera occupier in case asset is not occupied by the current user + const [showSubscriptionInfo, setShowSubscriptionInfo] = useState(false); + const [showCameraOccupierInfo, setShowCameraOccupierInfo] = useState(false); + const [cameraOccupier, setCameraOccupier] = useState({}); + const [timeoutSeconds, setTimeoutSeconds] = useState(CAMERA_ACCESS_TIMEOUT); + const [isRequestingAccess, setIsRequestingAccess] = useState(false); + + const [borderAlert, setBorderAlert] = useState(null); const authUser = useAuthUser(); + // Notification hook to get subscription info + const { isSubscribed, isSubscribing, intialSubscriptionState, subscribe } = + useNotificationSubscribe(); + + useEffect(() => { + intialSubscriptionState(); + }, [dispatch, isSubscribed]); + + // display subscription info + const subscriptionInfo = () => { + return ( +
+ {showSubscriptionInfo && ( +
{ + setShowSubscriptionInfo(false); + }} + > +
+ {isSubscribed != "SubscribedOnThisDevice" + ? "Subscribe to get real time information about camera access" + : "You are subscribed, and will get real time information about camera access"} +
+ {isSubscribed != "SubscribedOnThisDevice" && ( + + {isSubscribing && } + + Subscribe + + )} +
+ )} +
{ + setShowSubscriptionInfo(true); + }} + > + +
+
+ ); + }; + + //display current cameraoccupier info incase the asset is not occupied by the current user + const currentCameraOccupierInfo = () => { + return ( +
{ + setShowCameraOccupierInfo(false); + }} + > + {showCameraOccupierInfo && ( +
+
+ Camera is being used by... +
+ +
+
{`${cameraOccupier.firstName} ${cameraOccupier.lastName}-`}
+
{`${cameraOccupier.role}`}
+
+ {cameraOccupier.homeFacility && ( +
{`${cameraOccupier.homeFacility}`}
+ )} + { + setIsRequestingAccess(true); + requestAccess({ + onSuccess: () => { + Notification.Success({ msg: "Request sent" }); + setIsRequestingAccess(false); + }, + onError: () => { + Notification.Error({ msg: "Request failed" }); + setIsRequestingAccess(false); + }, + }); + }} + ghost + variant="secondary" + size="small" + border + > + {isRequestingAccess && } + Request Access + +
+ )} +
{ + setShowCameraOccupierInfo(true); + }} + > +
+ {cameraOccupier?.firstName?.[0] ? ( + cameraOccupier?.firstName?.[0].toUpperCase() + ) : ( + + )} +
+
+
+ ); + }; + useEffect(() => { const fetchFacility = async () => { const res = await dispatch(getPermittedFacility(facilityId)); @@ -98,7 +235,7 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { async (status: statusType) => { setIsLoading(true); const res = await dispatch(getConsultation(consultationId)); - if (!status.aborted && res.data) { + if (!status.aborted && res?.data) { const consultation = res.data as ConsultationModel; const consultationBedId = consultation.current_bed?.bed_object?.id; if (consultationBedId) { @@ -121,18 +258,23 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { }; if (bedAssets?.data?.results?.length) { - const { camera_access_key } = - bedAssets.data.results[0].asset_object.meta; - const config = camera_access_key.split(":"); - setCameraAsset({ - id: bedAssets.data.results[0].asset_object.id, - accessKey: config[2] || "", - }); - setCameraConfig(bedAssets.data.results[0].meta); - setCameraState({ - ...bedAssets.data.results[0].meta.position, - precision: 1, - }); + bedAssets.data.results = bedAssets.data.results.filter( + (bedAsset: any) => bedAsset.meta.type !== "boundary" + ); + if (bedAssets.data?.results?.length) { + const { camera_access_key } = + bedAssets.data.results[0].asset_object.meta; + const config = camera_access_key.split(":"); + setCameraAsset({ + id: bedAssets.data.results[0].asset_object.id, + accessKey: config[2] || "", + }); + setCameraConfig(bedAssets.data.results[0].meta); + setCameraState({ + ...bedAssets.data.results[0].meta.position, + precision: 1, + }); + } } } @@ -192,6 +334,9 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { getPTZPayload, getPresets, relativeMove, + lockAsset, + unlockAsset, + requestAccess, } = useFeedPTZ({ config: cameraAsset, dispatch, @@ -200,6 +345,18 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { const getBedPresets = async (asset: any) => { if (asset.id && bed) { const bedAssets = await dispatch(listAssetBeds({ asset: asset.id, bed })); + if (bedAssets?.data?.results?.length) { + bedAssets.data.results = bedAssets.data.results.filter( + (bedAsset: any) => { + if (bedAsset.meta.type === "boundary") { + setBoundaryPreset(bedAsset); + return false; + } else { + return true; + } + } + ); + } setBedPresets(bedAssets?.data?.results); } }; @@ -207,8 +364,16 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { useEffect(() => { if (cameraAsset.id) { getPresets({ - onSuccess: (resp) => setPresets(resp), - onError: (_) => { + onSuccess: (resp) => { + setPresets(resp); + setCameraOccupier({}); + }, + onError: (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } Notification.Error({ msg: "Fetching presets failed", }); @@ -218,6 +383,106 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { } }, [cameraAsset, cameraMiddlewareHostname]); + //lock and unlock asset on mount and unmount + useEffect(() => { + if (cameraAsset.id) { + lockAsset({ + onError: async (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } + }, + onSuccess() { + setCameraOccupier({}); + }, + }); + } + + window.addEventListener("beforeunload", () => { + if (cameraAsset.id) { + unlockAsset({}); + } + }); + + return () => { + if (cameraAsset.id) { + unlockAsset({}); + } + window.removeEventListener("beforeunload", () => { + if (cameraAsset.id) { + unlockAsset({}); + } + }); + }; + }, [cameraAsset, cameraMiddlewareHostname]); + + //count down from CAMERA_ACCESS_TIMEOUT when mouse is idle to unlock asset after timeout + useEffect(() => { + const interval = setInterval(() => { + setTimeoutSeconds((prevSeconds) => prevSeconds - 1); + }, 1000); + + const resetTimer = () => { + setTimeoutSeconds(CAMERA_ACCESS_TIMEOUT); + }; + + document.addEventListener("mousemove", resetTimer); + + if (cameraOccupier.username) { + clearInterval(interval); + setTimeoutSeconds(CAMERA_ACCESS_TIMEOUT); + removeEventListener("mousemove", resetTimer); + } + + return () => { + clearInterval(interval); + document.removeEventListener("mousemove", resetTimer); + }; + }, [cameraOccupier]); + + //unlock asset after timeout + useEffect(() => { + if (timeoutSeconds === 0) { + unlockAsset({}); + setTimeoutSeconds(CAMERA_ACCESS_TIMEOUT); + setTimeout(() => { + lockAsset({ + onError: async (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } + }, + onSuccess() { + setCameraOccupier({}); + }, + }); + }, 2000); + } + }, [timeoutSeconds]); + + //Listen to push notifications for- + //1) camera access request + //2) camera access granted + useMessageListener((data) => { + if (data?.status == "success" && data?.asset_id === cameraAsset?.id) { + lockAsset({ + onError: async (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } + }, + onSuccess: () => { + setCameraOccupier({}); + setTimeoutSeconds(CAMERA_ACCESS_TIMEOUT); + }, + }); + } else if (data.status == "request") { + Notification.Warn({ + msg: `${data?.firstName} ${data?.lastName} is requesting access to the camera`, + }); + } + }); + useEffect(() => { let tId: any; if (streamStatus !== StreamStatus.Playing) { @@ -254,8 +519,14 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { onSuccess: () => { setLoading(CAMERA_STATES.IDLE); setCurrentPreset(preset); + setCameraOccupier({}); }, onError: (err: Record) => { + if (err.status === 409) { + setCameraOccupier(err.data as cameraOccupier); + } else { + setCameraOccupier({}); + } setLoading(CAMERA_STATES.IDLE); const responseData = err.data.result; if (responseData.status) { @@ -286,6 +557,13 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { } }, [bedPresets, streamStatus]); + const borderFlash: (dir: any) => void = (dir: any) => { + setBorderAlert(dir); + setTimeout(() => { + setBorderAlert(null); + }, 3000); + }; + const cameraPTZActionCBs: { [key: string]: (option: any, value?: any) => void; } = { @@ -313,6 +591,7 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { updatePreset: (option) => { getCameraStatus({ onSuccess: async (data) => { + setCameraOccupier({}); if (currentPreset?.asset_object?.id && data?.position) { setLoading(option.loadingLabel); const response = await dispatch( @@ -331,18 +610,126 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { if (response && response.status === 200) { Notification.Success({ msg: "Preset Updated" }); getBedPresets(cameraAsset?.id); - getPresets({}); + getPresets({ + onSuccess: () => { + setCameraOccupier({}); + }, + onError: (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } + }, + }); } setLoading(CAMERA_STATES.IDLE); } }, + onError: (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } + }, }); }, other: (option, value) => { setLoading(option.loadingLabel); - relativeMove(getPTZPayload(option.action, precision, value), { - onSuccess: () => setLoading(CAMERA_STATES.IDLE), + let payLoad = getPTZPayload(option.action, precision, value); + if (boundaryPreset?.meta?.range && cameraState) { + const range = boundaryPreset.meta.range; + if (option.action == "up" && cameraState.y + payLoad.y > range.max_y) { + borderFlash("top"); + setLoading(CAMERA_STATES.IDLE); + return; + } else if ( + option.action == "down" && + cameraState.y + payLoad.y < range.min_y + ) { + borderFlash("bottom"); + setLoading(CAMERA_STATES.IDLE); + return; + } else if ( + option.action == "left" && + cameraState.x + payLoad.x < range.min_x + ) { + borderFlash("left"); + setLoading(CAMERA_STATES.IDLE); + return; + } else if ( + option.action == "right" && + cameraState.x + payLoad.x > range.max_x + ) { + borderFlash("right"); + setLoading(CAMERA_STATES.IDLE); + return; + } else if ( + option.action == "zoomOut" && + cameraState.zoom + payLoad.zoom < 0 + ) { + Notification.Error({ msg: "Cannot zoom out" }); + setLoading(CAMERA_STATES.IDLE); + return; + } + } + //insert boundaryPreset.id in payload + if (boundaryPreset?.id) { + payLoad = { + ...payLoad, + id: boundaryPreset.id, + camera_state: cameraState, + }; + } + + relativeMove(payLoad, { + onSuccess: () => { + setLoading(CAMERA_STATES.IDLE); + setCameraOccupier({}); + }, + onError: async (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } + setLoading(CAMERA_STATES.IDLE); + }, }); + if (cameraState) { + let x = cameraState.x; + let y = cameraState.y; + let zoom = cameraState.zoom; + switch (option.action) { + case "left": + x += -0.1 / cameraState.precision; + break; + + case "right": + x += 0.1 / cameraState.precision; + break; + + case "down": + y += -0.1 / cameraState.precision; + break; + + case "up": + y += 0.1 / cameraState.precision; + break; + + case "zoomIn": + zoom += 0.1 / cameraState.precision; + break; + case "zoomOut": + zoom += -0.1 / cameraState.precision; + break; + default: + break; + } + + setCameraState({ ...cameraState, x: x, y: y, zoom: zoom }); + } }, }; @@ -379,6 +766,7 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { absoluteMove(preset.meta.position, { onSuccess: () => { setLoading(CAMERA_STATES.IDLE); + setCameraOccupier({}); setCurrentPreset(preset); console.log( "onSuccess: Set Preset to " + preset?.meta?.preset_name @@ -390,7 +778,12 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { result: "success", }); }, - onError: () => { + onError: async (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } setLoading(CAMERA_STATES.IDLE); setCurrentPreset(preset); console.log( @@ -404,7 +797,18 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { }); }, }); - getCameraStatus({}); + getCameraStatus({ + onSuccess: () => { + setCameraOccupier({}); + }, + onError: (resp) => { + if (resp.status === 409) { + setCameraOccupier(resp.data as cameraOccupier); + } else { + setCameraOccupier({}); + } + }, + }); }} className={classNames( "block border border-gray-500 px-4 py-2", @@ -418,173 +822,153 @@ export const Feed: React.FC = ({ consultationId, facilityId }) => { ))} +
+ {cameraOccupier?.username && currentCameraOccupierInfo()} + {subscriptionInfo()} +
-
- {isIOS ? ( - { - setStreamStatus(StreamStatus.Loading); - }} - onError={(e: any, _: any, hlsInstance: any) => { - if (e === "hlsError") { - const recovered = hlsInstance.recoverMediaError(); - console.log(recovered); - } - }} - onEnded={() => { - setStreamStatus(StreamStatus.Stop); - }} - /> - ) : ( -