Building a Native IP Camera Client in React
- Related: Building a Native Elasticsearch Client in React
- Github Repository: Handling HLS Live Streams & Video Recordings
- Github Repository: Camera REST State Management
In the previous example I used React Query to connect my Tauri App to an Web REST API by building an Elasticsearch client app.
Now I am interested in doing something similar - controlling an INSTAR 'smart' network camera through it's CGI interface. Since I already looked into handling my cameras live video I will skip this part for now and just add a screenshot as placeholder.
There are two problems that I need to solve. The first being using React Query to keep my client state up-to-date with the camera backend. Since the CGI Interface is just another REST API I should be able to copy&paste in my Elasticsearch client code with just a few modifications.
The second issue is that I need the React Query Mutation to be able to use the HTTP POST method and update the camera state.
Fetch State
The following is an example of using React Query to fetch the camera state. All wrapped up in some Shadcn/UI:
import React from 'react'
import { useQuery, UseQueryResult } from "@tanstack/react-query"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ReloadIcon } from "@radix-ui/react-icons"
import { iGetWizardStatusApiResponse } from '@/types/iFeatures'
import { login, features } from '@/config.ts'
const cmd='http://'+login.url+':'+login.port+'/param.cgi?cmd='
const auth='&user='+login.user+'&pwd='+login.password
const param=features.wizardstatus
const fetchData = async(param: string): Promise<iGetWizardStatusApiResponse> => {
const response = await fetch(cmd+param+auth)
const cmdQuery = 'cmd="'+param+'";'
if (response.ok) {
const cleanedTextResponse = (await response.text())
.replace(cmdQuery, '{"')
.replace('response="200";', '}')
.replace(/=/g, '":')
.replace(/";/g, '","')
.replace(/\s/g, '')
.replace(/","}/g, '"}')
const jsonData: iGetWizardStatusApiResponse = JSON.parse(cleanedTextResponse)
return jsonData
}
throw new Error('ERROR :: Data fetching failed!')
}
export default function DataRequest(): React.JSX.Element {
const {
isLoading,
isError,
error,
data,
isSuccess
}: UseQueryResult<iGetWizardStatusApiResponse, Error> = useQuery<iGetWizardStatusApiResponse, Error>({
queryKey: ['ptzparams', { param }],
queryFn: () => fetchData(param),
staleTime: 1000 * 5,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 30,
refetchIntervalInBackground: false,
retry: true,
retryOnMount: true,
// retryDelay: 1000 * 5, // default increases exponentially
})
if(isLoading) return (
<Button variant="ghost" disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
)
if(isError) return (
<Badge variant="destructive" className='mb-2'>
{error.message}
</Badge>
)
if(isSuccess) return (
<>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Installation Wizard
</Label>
<Input id="name" value={data.status} className="col-span-3" />
</div>
</>
)
else return <Badge variant="outline" className='mb-2'>Huh?</Badge>
}
Implementing this for every CGI Command gives you a nice UI displaying all your cameras state variables. But so far without being able to modify them:
Trigger Events
Let's start by using a GET Request to let my camera pan between preset positions. This is a simple request, just like above, but I don't have a state to display:
import React from 'react'
import { useQuery, UseQueryResult } from "@tanstack/react-query"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ReloadIcon } from "@radix-ui/react-icons"
import { login } from '@/config.ts'
import { iPostApiResponseCode } from '@/types/iGeneral'
const cmd='http://'+login.url+':'+login.port+'/param.cgi?cmd='
const auth='&user='+login.user+'&pwd='+login.password
const param='getptzpreset'
const act='&act=goto&index='
const value='1'
const fetchData = async(param: string, value: string): Promise<iPostApiResponseCode> => {
console.log(cmd+param+act+value+auth)
const response = await fetch(cmd+param+act+value+auth)
const cmdQuery = 'cmd="'+param+'";'
if (response.ok) {
const cleanedTextResponse = (await response.text())
.replace(cmdQuery, '{')
.replace('response="', '"code":')
.replace('";', '}')
console.log(cleanedTextResponse)
const jsonData: iPostApiResponseCode = JSON.parse(cleanedTextResponse)
console.log(jsonData)
return jsonData
}
throw new Error('ERROR :: Data fetching failed!')
}
export default function GoTo(): React.JSX.Element {
const {
isLoading,
isError,
error,
data: response,
isSuccess
}: UseQueryResult<iPostApiResponseCode, Error> = useQuery<iPostApiResponseCode, Error>({
queryKey: ['ptzpresetparams', { param, value }],
queryFn: () => fetchData(param, value),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
retryOnMount: false,
})
if(isLoading) return (
<Button variant="ghost" disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
)
if(isError) return (
<Badge variant="destructive" className='mb-2'>
{error.message}
</Badge>
)
if(isSuccess) return <p>Response Code: {response?.code}</p>
else return <Badge variant="outline" className='mb-2'>Huh?</Badge>
}
The component simply returns the HTTP Status code - 200
if successful - and I can see my camera move to the hardcoded preset position const value='1'
:
Now I can replace the hardcoded value with an UI element - e.g. a drop down menu from which I can select a position:
export default function GoTo(): React.JSX.Element {
const [position, setPosition] = useState('')
const {
isLoading,
isError,
error
}: UseQueryResult<iPostApiResponseCode, Error> = useQuery<iPostApiResponseCode, Error>({
queryKey: ['ptzpresetparams', { param, position }],
queryFn: () => fetchData(param, position),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
retryOnMount: false,
})
if(isLoading) return (
<Button variant="ghost" disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
)
if(isError) return (
<Badge variant="destructive" className='mb-2'>
{error.message}
</Badge>
)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>Presets</DropdownMenuTrigger>
<DropdownMenuContent className='flex items-center flex-col'>
<DropdownMenuLabel>Preset Positions</DropdownMenuLabel>
<DropdownMenuSeparator />
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('1')}}>Position 1</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('2')}}>Position 2</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('3')}}>Position 3</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('4')}}>Position 4</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('5')}}>Position 5</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('6')}}>Position 6</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('7')}}>Position 7</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => {setPosition('8')}}>Position 8</Button>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
Update State
To update the camera state as in toggling functions on or off we would usually use React Query Mutations to be able to use POST or PUT requests. But since most network cameras use different commands to get
and set
values they usually allow us to simply keep using GET commands for both.
In the following example I used the get and set command for the privacy areas to toggle a screen mask on and off. The get command uses React Query to keep the client side state fresh while I am use a simple fetch to manually update the state when needed:
import React, { useState } from 'react'
import { useQuery } from "@tanstack/react-query"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ReloadIcon } from "@radix-ui/react-icons"
import { iGetImageCoverApiResponse } from '@/types/iMultimedia'
import { iPostApiResponseCode } from '@/types/iGeneral'
import { login, multimedia } from '@/config.ts'
const cmd='http://'+login.url+':'+login.port+'/param.cgi?cmd='
const auth='&user='+login.user+'&pwd='+login.password
const getparam=multimedia.privacy.get
const setparam=multimedia.privacy.set
const type=multimedia.privacy.privacyarea1
const fetchCameraState = async(param: string, type: string): Promise<iGetImageCoverApiResponse> => {
const response = await fetch(cmd+param+type+auth)
const cmdQuery = 'cmd="'+param+'";'
if (response.ok) {
const cleanedTextResponse = (await response.text())
.replace(cmdQuery, '{"')
.replace('response="200";', '}')
.replace(/=/g, '":')
.replace(/";/g, '","')
.replace(/\s/g, '')
.replace(/","}/g, '"}')
const jsonData: iGetImageCoverApiResponse = JSON.parse(cleanedTextResponse)
return jsonData
}
throw new Error('ERROR :: Data fetching failed!')
}
const updateCameraState = async(
param: string, type: string, value: string
): Promise<iPostApiResponseCode> => {
const response = await fetch(cmd+param+type+'&enable='+value+auth)
const cmdQuery = 'cmd="'+param+'";'
if (response.ok) {
const cleanedTextResponse = (await response.text())
.replace(cmdQuery, '{')
.replace('response="', '"code":')
.replace('";', '}')
console.log(cleanedTextResponse)
const jsonData: iPostApiResponseCode = JSON.parse(cleanedTextResponse)
console.log(jsonData)
return jsonData
}
throw new Error('ERROR :: Data fetching failed!')
}
export default function DataRequest(): React.JSX.Element {
const [value, setValue] = useState('')
const {
isLoading,
isError,
error,
data: response
} = useQuery<iGetImageCoverApiResponse, Error>({
queryKey: ['private1params', { getparam, type }],
queryFn: () => fetchCameraState(getparam, type),
staleTime: 1000 * 5,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retry: true,
retryOnMount: true
})
if(isLoading) return (
<Button variant="ghost" disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait
</Button>
)
if(isError) return (
<Badge variant="destructive" className='mb-2'>
{error.message}
</Badge>
)
return (
<>
<div className="flex gap-2">
<Label htmlFor="name" className="text-right">
Enable Privacy Area 1
</Label>
<Input
defaultValue={response?.enable}
onChange={(e) => setValue(e.target.value)}
className="col-span-3"
/>
<Button onClick={
() => {updateCameraState(setparam, type, value)}
}>Submit</Button>
</div>
</>
)
}