From bc5a9c977767fe3bc91d2b81812727332ddae5d0 Mon Sep 17 00:00:00 2001 From: Khavin Shankar Date: Tue, 22 Oct 2024 12:59:16 +0530 Subject: [PATCH 1/2] added support for enabling boundaries for assetbed cameras --- src/Components/Assets/AssetTypes.tsx | 2 + src/Components/CameraFeed/CameraFeed.tsx | 15 +- src/Components/CameraFeed/ConfigureCamera.tsx | 442 ++++++++++++++++-- src/Components/CameraFeed/FeedControls.tsx | 27 +- src/Components/CameraFeed/useOperateCamera.ts | 30 +- .../ConsultationFeedTab.tsx | 4 +- src/Locale/en.json | 11 + 7 files changed, 476 insertions(+), 55 deletions(-) diff --git a/src/Components/Assets/AssetTypes.tsx b/src/Components/Assets/AssetTypes.tsx index 2f8e086a813..389f73239e9 100644 --- a/src/Components/Assets/AssetTypes.tsx +++ b/src/Components/Assets/AssetTypes.tsx @@ -150,6 +150,7 @@ export interface AssetTransaction { modified_date: string; } +export type BoundaryKeys = "x0" | "y0" | "x1" | "y1"; export interface AssetBedModel { id: string; asset_object: AssetData; @@ -159,6 +160,7 @@ export interface AssetBedModel { meta: Record; asset?: string; bed?: string; + boundary: Record | null; } export type AssetBedBody = Partial; diff --git a/src/Components/CameraFeed/CameraFeed.tsx b/src/Components/CameraFeed/CameraFeed.tsx index 3c9b25b6fc9..a7670b1b4e1 100644 --- a/src/Components/CameraFeed/CameraFeed.tsx +++ b/src/Components/CameraFeed/CameraFeed.tsx @@ -6,13 +6,14 @@ import { classNames, isIOS } from "../../Utils/utils"; import FeedAlert, { FeedAlertState, StreamStatus } from "./FeedAlert"; import FeedNetworkSignal from "./FeedNetworkSignal"; import NoFeedAvailable from "./NoFeedAvailable"; -import FeedControls from "./FeedControls"; +import FeedControls, { FeedControlsProps } from "./FeedControls"; import FeedWatermark from "./FeedWatermark"; import useFullscreen from "../../Common/hooks/useFullscreen"; import useBreakpoints from "../../Common/hooks/useBreakpoints"; import { GetPresetsResponse } from "./routes"; import VideoPlayer from "./videoPlayer"; import MonitorAssetPopover from "../Common/MonitorAssetPopover"; +import * as Notification from "../../Utils/Notifications.js"; interface Props { children?: React.ReactNode; @@ -28,6 +29,7 @@ interface Props { shortcutsDisabled?: boolean; onMove?: () => void; operate: ReturnType["operate"]; + additionalControls?: FeedControlsProps["additionalControls"]; } export default function CameraFeed(props: Props) { @@ -133,15 +135,24 @@ export default function CameraFeed(props: Props) { onReset={resetStream} onMove={async (data) => { setState("moving"); - const { res } = await props.operate({ type: "relative_move", data }); + const { res, error } = await props.operate({ + type: "relative_move", + data, + }); props.onMove?.(); setTimeout(() => { setState((state) => (state === "moving" ? undefined : state)); }, 4000); + if (res?.status === 400 && error) { + Notification.Error({ + msg: error.detail, + }); + } if (res?.status === 500) { setState("host_unreachable"); } }} + additionalControls={props.additionalControls} /> ); diff --git a/src/Components/CameraFeed/ConfigureCamera.tsx b/src/Components/CameraFeed/ConfigureCamera.tsx index 052f93a9b05..cb731e83c89 100644 --- a/src/Components/CameraFeed/ConfigureCamera.tsx +++ b/src/Components/CameraFeed/ConfigureCamera.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; -import { AssetData } from "../Assets/AssetTypes"; +import { AssetData, BoundaryKeys } from "../Assets/AssetTypes"; import { getCameraConfig, makeAccessKey } from "../../Utils/transformUtils"; import TextFormField from "../Form/FormFields/TextFormField"; import ButtonV2, { Cancel, Submit } from "../Common/components/ButtonV2"; import useAuthUser from "../../Common/hooks/useAuthUser"; -import CareIcon from "../../CAREUI/icons/CareIcon"; +import CareIcon, { IconName } from "../../CAREUI/icons/CareIcon"; import useOperateCamera from "./useOperateCamera"; import CameraFeed from "./CameraFeed"; import { useTranslation } from "react-i18next"; @@ -29,6 +29,8 @@ import ConfirmDialog from "../Common/ConfirmDialog"; import { FieldLabel } from "../Form/FormFields/FormField"; import { checkIfValidIP } from "../../Common/validation"; import CheckBoxFormField from "../Form/FormFields/CheckBoxFormField"; +import FeedButton from "./FeedButton"; +import * as Notification from "../../Utils/Notifications.js"; interface Props { asset: AssetData; @@ -37,6 +39,19 @@ interface Props { type OnvifPreset = { name: string; value: number }; +const boundaryKeyMap: Record< + BoundaryKeys, + { + icon: IconName; + shortcut: string[][]; + } +> = { + x0: { icon: "l-border-left", shortcut: [["Option", "Shift", "ArrowLeft"]] }, + x1: { icon: "l-border-right", shortcut: [["Option", "Shift", "ArrowRight"]] }, + y0: { icon: "l-border-bottom", shortcut: [["Option", "Shift", "ArrowDown"]] }, + y1: { icon: "l-border-top", shortcut: [["Option", "Shift", "ArrowUp"]] }, +}; + export default function ConfigureCamera(props: Props) { const { t } = useTranslation(); const authUser = useAuthUser(); @@ -59,6 +74,16 @@ export default function ConfigureCamera(props: Props) { const [presetName, setPresetName] = useState(""); const [showUnlinkConfirmation, setShowUnlinkConfirmation] = useState(false); + const [isSettingBoundary, setIsSettingBoundary] = useState(false); + const [boundary, setBoundary] = useState< + Record + >({ + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + }); + const assetBedsQuery = useQuery(routes.listAssetBeds, { query: { asset: props.asset.id, limit: 50 }, }); @@ -97,11 +122,29 @@ export default function ConfigureCamera(props: Props) { prefetch: !!selectedAssetBed?.id, }); + const validateBoundary = () => { + if ( + (["x0", "x1", "y0", "y1"] as BoundaryKeys[]).some( + (direction) => + boundary[direction] === undefined || + boundary[direction]! < -1 || + boundary[direction]! > 1, + ) + ) { + return false; + } + return true; + }; + useEffect(() => setMeta(props.asset.meta), [props.asset]); const accessKeyAttributes = getCameraConfig(meta); - const { operate, key } = useOperateCamera(props.asset.id); + const { operate, key } = useOperateCamera(props.asset.id, { + relative_move: { + asset_bed_id: isSettingBoundary ? undefined : selectedAssetBed?.id, + }, + }); if (!["DistrictAdmin", "StateAdmin"].includes(authUser.user_type)) { return ( @@ -113,7 +156,7 @@ export default function ConfigureCamera(props: Props) { return (
-
+
{ @@ -246,6 +289,95 @@ export default function ConfigureCamera(props: Props) { ); } }} + additionalControls={({ inlineView }) => + isSettingBoundary && ( +
+
+ {(["x0", "x1", "y0", "y1"] as BoundaryKeys[]).map( + (direction) => ( + { + const { data } = await operate({ + type: "get_status", + }); + const position = (data as GetStatusResponse).result + ?.position; + if (!position) { + return; + } + if ( + direction === "x0" && + boundary?.x1 && + position.x >= boundary.x1 + ) { + Notification.Warn({ + msg: "Left boundary value cannot be greater than right boundary value", + }); + return; + } + if ( + direction === "x1" && + boundary?.x0 && + position.x <= boundary.x0 + ) { + Notification.Warn({ + msg: "Right boundary value cannot be lesser than left boundary value", + }); + return; + } + if ( + direction === "y0" && + boundary?.y1 && + position.y >= boundary.y1 + ) { + Notification.Warn({ + msg: "Bottom boundary value cannot be greater than top boundary value", + }); + return; + } + if ( + direction === "y1" && + boundary?.y0 && + position.y <= boundary.y0 + ) { + Notification.Warn({ + msg: "Top boundary value cannot be lesser than bottom boundary value", + }); + return; + } + setBoundary({ + ...boundary, + [direction]: ["x0", "x1"].includes(direction) + ? position.x + : position.y, + }); + }} + > + + + ), + )} +
+
+ ) + } >
    + {isSettingBoundary ? ( +
  • + +
    + + {t("boundary")} + +
    + { + setIsSettingBoundary(false); + setBoundary({ + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + }); + }} + > + + {t("cancel")} + + { + e.preventDefault(); + if (!validateBoundary()) { + Error({ + msg: t("invalid_boundary_values"), + }); + return; + } + const { res } = await request( + routes.partialUpdateAssetBed, + { + pathParams: { + external_id: selectedAssetBed.id, + }, + body: { + asset: selectedAssetBed.asset_object.id, + bed: selectedAssetBed.bed_object.id, + boundary: boundary as Record< + BoundaryKeys, + number + >, + }, + }, + ); + if (res?.ok) { + Success({ msg: "Boundary updated" }); + setIsSettingBoundary(false); + setBoundary({ + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + }); + assetBedsQuery.refetch(); // TODO: optimize this later + } else { + Error({ + msg: t("boundary_update_error"), + }); + } + }} + > + + {t("update")} + +
    +
    +
    + {(["x0", "x1", "y0", "y1"] as BoundaryKeys[]).map( + (direction) => ( + <> + + )[direction] ?? -1 + } + max={ + ( + { + x0: boundary.x1 ?? 1, + y0: boundary.y1 ?? 1, + } as Record + )[direction] ?? 1 + } + label={ + + } + value={ + boundary[direction]?.toString() ?? "" + } + className="mt-1" + onChange={(e) => + setBoundary((prev) => ({ + ...prev, + [direction]: Number(e.value), + })) + } + errorClassName="hidden" + /> + + ), + )} +
    +

    + {t("boundary_manual_edit_warning")} +

    +
  • + + ) : selectedAssetBed.boundary ? ( +
  • +
    +
    + + {t("boundary")} + +
    + { + const boundary = selectedAssetBed.boundary; + setIsSettingBoundary(true); + setBoundary({ + x0: boundary?.x0, + x1: boundary?.x1, + y0: boundary?.y0, + y1: boundary?.y1, + }); + }} + > + + {t("update")} + + { + const { res } = await request( + routes.partialUpdateAssetBed, + { + pathParams: { + external_id: selectedAssetBed.id, + }, + body: { + asset: selectedAssetBed.asset_object.id, + bed: selectedAssetBed.bed_object.id, + boundary: null, + }, + }, + ); + if (res?.ok) { + Success({ msg: "Boundary removed" }); + assetBedsQuery.refetch(); // TODO: optimize this later + } else { + Error({ + msg: t("boundary_update_error"), + }); + } + }} + > + + {t("remove")} + +
    +
    +
    + + {t("boundary")} + +
    + + {t("camera_preset__boundary_x0")}:{" "} + {selectedAssetBed.boundary?.x0} + + + {t("camera_preset__boundary_x1")}:{" "} + {selectedAssetBed.boundary?.x1} + + + {t("camera_preset__boundary_y0")}:{" "} + {selectedAssetBed.boundary?.y0} + + + {t("camera_preset__boundary_y1")}:{" "} + {selectedAssetBed.boundary?.y1} + +
    +
    +
    +
  • + ) : ( +
  • setIsSettingBoundary(true)} + > + + {t("add_boundary")} +
  • + )}
  • { @@ -549,8 +908,50 @@ export default function ConfigureCamera(props: Props) { />
-
- {preset.name} +
+
+ {preset.name} +
+ + operate({ + type: "absolute_move", + data: preset.position!, + }) + } + > + + {t("view")} + + { + setEditPreset({ preset: preset.id }); + }} + > + + {t("update")} + +
+
+
+ + {t("position")} + +
+ X: {preset.position?.x} + Y: {preset.position?.y} + Zoom: {preset.position?.zoom} +
+
+ -
- - operate({ - type: "absolute_move", - data: preset.position!, - }) - } - > - - {t("view")} - - { - setEditPreset({ preset: preset.id }); - }} - > - - {t("update")} - -
))} diff --git a/src/Components/CameraFeed/FeedControls.tsx b/src/Components/CameraFeed/FeedControls.tsx index 36d3aa96cd2..0060af50725 100644 --- a/src/Components/CameraFeed/FeedControls.tsx +++ b/src/Components/CameraFeed/FeedControls.tsx @@ -43,16 +43,23 @@ const payload = (action: number, precision: number) => { return { x, y, zoom }; }; -interface Props { +export interface FeedControlsProps { shortcutsDisabled?: boolean; onMove: (payload: PTZPayload) => void; isFullscreen: boolean; setFullscreen: (state: boolean) => void; onReset: () => void; inlineView: boolean; + additionalControls?: ( + props: Omit, + ) => React.ReactNode; } -export default function FeedControls({ shortcutsDisabled, ...props }: Props) { +export default function FeedControls({ + shortcutsDisabled, + additionalControls, + ...props +}: FeedControlsProps) { const [precision, setPrecision] = useState(1); const togglePrecision = () => setPrecision((p) => (p === 16 ? 1 : p << 1)); @@ -229,18 +236,22 @@ export default function FeedControls({ shortcutsDisabled, ...props }: Props) { {controls.fullscreen}
+ {additionalControls?.(props)}
); } return ( -
-
{controls.zoom}
-
{controls.position}
-
- {controls.reset} - {controls.fullscreen} +
+
+
{controls.zoom}
+
{controls.position}
+
+ {controls.reset} + {controls.fullscreen} +
+ {additionalControls?.(props)}
); } diff --git a/src/Components/CameraFeed/useOperateCamera.ts b/src/Components/CameraFeed/useOperateCamera.ts index 0e65fb0130c..5455a4efd6f 100644 --- a/src/Components/CameraFeed/useOperateCamera.ts +++ b/src/Components/CameraFeed/useOperateCamera.ts @@ -8,36 +8,44 @@ export interface PTZPayload { zoom: number; } -interface GetStatusOperation { +interface BaseOperation { + type: string; + options?: Record; +} + +interface GetStatusOperation extends BaseOperation { type: "get_status"; } -interface GetPresetsOperation { +interface GetPresetsOperation extends BaseOperation { type: "get_presets"; } -interface GoToPresetOperation { +interface GoToPresetOperation extends BaseOperation { type: "goto_preset"; data: { preset: number; }; } -interface AbsoluteMoveOperation { +interface AbsoluteMoveOperation extends BaseOperation { type: "absolute_move"; data: PTZPayload; } -interface RelativeMoveOperation { +interface RelativeMoveOperation extends BaseOperation { type: "relative_move"; data: PTZPayload; + options?: { + asset_bed_id?: string; + }; } -interface GetStreamToken { +interface GetStreamToken extends BaseOperation { type: "get_stream_token"; } -interface ResetFeedOperation { +interface ResetFeedOperation extends BaseOperation { type: "reset"; } @@ -54,7 +62,10 @@ export type OperationAction = * This hook is used to control the PTZ of a camera asset and retrieve other related information. * @param id The external id of the camera asset */ -export default function useOperateCamera(id: string) { +export default function useOperateCamera( + id: string, + options?: Partial>>, +) { const [key, setKey] = useState(0); return { @@ -68,6 +79,7 @@ export default function useOperateCamera(id: string) { body: { action: { type: "get_status", + options: options?.get_status, }, }, silent: true, @@ -76,7 +88,7 @@ export default function useOperateCamera(id: string) { return request(FeedRoutes.operateAsset, { pathParams: { id }, - body: { action }, + body: { action: { ...action, options: options?.[action.type] } }, silent: true, }); }, diff --git a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx index 91922c99205..ecf60861d14 100644 --- a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx +++ b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx @@ -52,7 +52,9 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { const asset = preset?.asset_bed.asset_object; - const { key, operate } = useOperateCamera(asset?.id ?? ""); + const { key, operate } = useOperateCamera(asset?.id ?? "", { + relative_move: { asset_bed_id: preset?.asset_bed.id }, + }); const presetsQuery = useQuery(FeedRoutes.listBedPresets, { pathParams: { bed_id: bed?.id ?? "" }, diff --git a/src/Locale/en.json b/src/Locale/en.json index 691f44a1e03..ede2a58f901 100644 --- a/src/Locale/en.json +++ b/src/Locale/en.json @@ -259,6 +259,7 @@ "add_attachments": "Add Attachments", "add_beds": "Add Bed(s)", "add_beds_to_configure_presets": "Add beds to this location to configure presets for them.", + "add_boundary": "Add boundary", "add_details_of_patient": "Add Details of Patient", "add_location": "Add Location", "add_new_user": "Add New User", @@ -354,11 +355,18 @@ "systolic_less_than_diastolic": "Systolic must be greater than diastolic." }, "board_view": "Board View", + "boundary": "Boundary", + "boundary_manual_edit_warning": "We strongly recommend NOT to set boundary values manually. Use the navigations within camera feed to set the boundary values.", + "boundary_update_error": "Error updating boundary values", "bradycardia": "Bradycardia", "breathlessness_level": "Breathlessness level", "camera": "Camera", "camera_bed_link_success": "Camera linked to bed successfully.", "camera_permission_denied": "Camera Permission denied", + "camera_preset__boundary_x0": "Left Boundary", + "camera_preset__boundary_x1": "Right Boundary", + "camera_preset__boundary_y0": "Bottom Boundary", + "camera_preset__boundary_y1": "Top Boundary", "camera_was_linked_to_bed": "This camera was linked to this bed", "cancel": "Cancel", "capture": "Capture", @@ -718,6 +726,7 @@ "insurer_name_required": "Insurer Name is required", "international_mobile": "International Mobile", "invalid_asset_id_msg": "Oops! The asset ID you entered does not appear to be valid.", + "invalid_boundary_values": "Please ensure that all boundary values are set and are within the range of -1 to 1", "invalid_email": "Please Enter a Valid Email Address", "invalid_ip_address": "Invalid IP Address", "invalid_link_msg": "It appears that the password reset link you have used is either invalid or expired. Please request a new password reset link.", @@ -1102,6 +1111,7 @@ "serviced_on": "Serviced on", "session_expired": "Session Expired", "session_expired_msg": "It appears that your session has expired. This could be due to inactivity. Please login again to continue.", + "set_as": "Set as", "set_average_weekly_working_hours_for": "Set Average weekly working hours for", "set_your_local_language": "Set your local language", "settings_and_filters": "Settings and Filters", @@ -1182,6 +1192,7 @@ "unlink_asset_bed_and_presets": "Delete linked presets and unlink bed", "unlink_asset_bed_caution": "This action will also delete all presets that are associated to this camera and bed.", "unlink_camera_and_bed": "Unlink this bed from this camera", + "unset": "Unset", "unsubscribe": "Unsubscribe", "unsubscribe_failed": "Unsubscribe failed.", "unsupported_browser": "Unsupported Browser", From 178068d2c40b3fcaed4878673363d296f983b20c Mon Sep 17 00:00:00 2001 From: Khavin Shankar Date: Wed, 23 Oct 2024 07:26:28 +0530 Subject: [PATCH 2/2] fixed build failure --- src/Components/CameraFeed/CameraFeed.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/CameraFeed/CameraFeed.tsx b/src/Components/CameraFeed/CameraFeed.tsx index 17d313fcbc0..fbc11a22318 100644 --- a/src/Components/CameraFeed/CameraFeed.tsx +++ b/src/Components/CameraFeed/CameraFeed.tsx @@ -12,7 +12,6 @@ import useFullscreen from "../../Common/hooks/useFullscreen"; import useBreakpoints from "../../Common/hooks/useBreakpoints"; import { GetPresetsResponse } from "./routes"; import VideoPlayer from "./videoPlayer"; -import MonitorAssetPopover from "../Common/MonitorAssetPopover"; import * as Notification from "../../Utils/Notifications.js"; import AssetInfoPopover from "../Common/AssetInfoPopover";