You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
389 lines
10 KiB
JavaScript
389 lines
10 KiB
JavaScript
import dgram from 'node:dgram'
|
|
import {existsSync, readFileSync} from 'node:fs'
|
|
import http from 'node:http'
|
|
import net from 'node:net'
|
|
import {resolve} from 'node:path'
|
|
import {fileURLToPath} from 'node:url'
|
|
|
|
loadDotEnv()
|
|
|
|
const DEFAULT_ADDRESS = 0x01
|
|
const DEFAULT_SPEED = 0x20
|
|
|
|
const CONTROL_HOST = process.env.CONTROL_HOST || '0.0.0.0'
|
|
const CONTROL_PORT = Number(process.env.CONTROL_PORT || 8090)
|
|
const CONTROL_TRANSPORT = process.env.CONTROL_TRANSPORT || 'log'
|
|
const CONTROL_TCP_HOST = process.env.CONTROL_TCP_HOST || ''
|
|
const CONTROL_TCP_PORT = Number(process.env.CONTROL_TCP_PORT || 0)
|
|
const CONTROL_UDP_HOST = process.env.CONTROL_UDP_HOST || ''
|
|
const CONTROL_UDP_PORT = Number(process.env.CONTROL_UDP_PORT || 0)
|
|
const CONTROL_ADDRESS = parseByte(process.env.CONTROL_ADDRESS, DEFAULT_ADDRESS)
|
|
const CONTROL_SPEED = parseByte(process.env.CONTROL_SPEED, DEFAULT_SPEED)
|
|
|
|
function loadDotEnv() {
|
|
const envPath = resolve(process.cwd(), '.env')
|
|
|
|
if (!existsSync(envPath)) {
|
|
return
|
|
}
|
|
|
|
const lines = readFileSync(envPath, 'utf8').split(/\r?\n/)
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
const separatorIndex = trimmed.indexOf('=')
|
|
if (separatorIndex === -1) {
|
|
continue
|
|
}
|
|
|
|
const key = trimmed.slice(0, separatorIndex).trim()
|
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1)
|
|
}
|
|
|
|
if (!process.env[key]) {
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseByte(value, fallback) {
|
|
if (!value) {
|
|
return fallback
|
|
}
|
|
|
|
const normalized = String(value).trim().toLowerCase()
|
|
const parsed = normalized.startsWith('0x')
|
|
? Number.parseInt(normalized.slice(2), 16)
|
|
: Number.parseInt(normalized, 10)
|
|
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xff) {
|
|
return fallback
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
function clampPelcoSpeed(value) {
|
|
return Math.max(0x00, Math.min(0x3f, value))
|
|
}
|
|
|
|
function createPelcoDCommand(command1, command2, data1 = 0x00, data2 = 0x00) {
|
|
const address = CONTROL_ADDRESS
|
|
const checksum = (address + command1 + command2 + data1 + data2) & 0xff
|
|
return Buffer.from([
|
|
0xff,
|
|
address,
|
|
command1,
|
|
command2,
|
|
data1,
|
|
data2,
|
|
checksum,
|
|
])
|
|
}
|
|
|
|
function createPresetCommand(operation, presetNo) {
|
|
const preset = parseByte(presetNo, 0x00)
|
|
|
|
if (preset < 0x01 || preset > 0xff) {
|
|
throw new Error(`Invalid preset number: ${presetNo}`)
|
|
}
|
|
|
|
const command2ByOperation = {
|
|
set: 0x03,
|
|
clear: 0x05,
|
|
call: 0x07,
|
|
}
|
|
const command2 = command2ByOperation[operation]
|
|
|
|
if (command2 === undefined) {
|
|
throw new Error(`Invalid preset operation: ${operation}`)
|
|
}
|
|
|
|
return createPelcoDCommand(0x00, command2, 0x00, preset)
|
|
}
|
|
|
|
export const CONTROL_COMMANDS = Object.freeze({
|
|
PTZ_UP: 'PTZ_UP',
|
|
PTZ_DOWN: 'PTZ_DOWN',
|
|
PTZ_LEFT: 'PTZ_LEFT',
|
|
PTZ_RIGHT: 'PTZ_RIGHT',
|
|
PTZ_STOP: 'PTZ_STOP',
|
|
ZOOM_TELE: 'ZOOM_TELE',
|
|
ZOOM_WIDE: 'ZOOM_WIDE',
|
|
FOCUS_FAR: 'FOCUS_FAR',
|
|
FOCUS_NEAR: 'FOCUS_NEAR',
|
|
IRIS_OPEN: 'IRIS_OPEN',
|
|
IRIS_CLOSE: 'IRIS_CLOSE',
|
|
AUX_2_ON: 'AUX_2_ON',
|
|
AUX_2_OFF: 'AUX_2_OFF',
|
|
PRESET_1_CALL: 'PRESET_1_CALL',
|
|
LASER_ON: 'LASER_ON',
|
|
LASER_AUTO: 'LASER_AUTO',
|
|
LASER_OFF: 'LASER_OFF',
|
|
AUTO_FOCUS_ON: 'AUTO_FOCUS_ON',
|
|
AUTO_FOCUS_OFF: 'AUTO_FOCUS_OFF',
|
|
DEFOG_ON: 'DEFOG_ON',
|
|
DEFOG_OFF: 'DEFOG_OFF',
|
|
DEFROST_ON: 'DEFROST_ON',
|
|
DEFROST_OFF: 'DEFROST_OFF',
|
|
REBOOT: 'REBOOT',
|
|
})
|
|
|
|
export function buildControlCommand(action) {
|
|
const speed = clampPelcoSpeed(CONTROL_SPEED)
|
|
|
|
switch (action) {
|
|
case CONTROL_COMMANDS.PTZ_UP:
|
|
return createPelcoDCommand(0x00, 0x08, 0x00, speed)
|
|
case CONTROL_COMMANDS.PTZ_DOWN:
|
|
return createPelcoDCommand(0x00, 0x10, 0x00, speed)
|
|
case CONTROL_COMMANDS.PTZ_LEFT:
|
|
return createPelcoDCommand(0x00, 0x04, speed, 0x00)
|
|
case CONTROL_COMMANDS.PTZ_RIGHT:
|
|
return createPelcoDCommand(0x00, 0x02, speed, 0x00)
|
|
case CONTROL_COMMANDS.PTZ_STOP:
|
|
return createPelcoDCommand(0x00, 0x00, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.ZOOM_TELE:
|
|
return createPelcoDCommand(0x00, 0x20, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.ZOOM_WIDE:
|
|
return createPelcoDCommand(0x00, 0x40, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.FOCUS_FAR:
|
|
return createPelcoDCommand(0x00, 0x80, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.FOCUS_NEAR:
|
|
return createPelcoDCommand(0x01, 0x00, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.IRIS_OPEN:
|
|
return createPelcoDCommand(0x02, 0x00, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.IRIS_CLOSE:
|
|
return createPelcoDCommand(0x04, 0x00, 0x00, 0x00)
|
|
case CONTROL_COMMANDS.AUX_2_ON:
|
|
return createPelcoDCommand(0x00, 0x09, 0x00, 0x02)
|
|
case CONTROL_COMMANDS.AUX_2_OFF:
|
|
return createPelcoDCommand(0x00, 0x0b, 0x00, 0x02)
|
|
case CONTROL_COMMANDS.PRESET_1_CALL:
|
|
return createPresetCommand('call', 1)
|
|
case CONTROL_COMMANDS.LASER_ON:
|
|
return createPresetCommand('call', 224)
|
|
case CONTROL_COMMANDS.LASER_AUTO:
|
|
return createPresetCommand('call', 225)
|
|
case CONTROL_COMMANDS.LASER_OFF:
|
|
return createPresetCommand('call', 226)
|
|
case CONTROL_COMMANDS.AUTO_FOCUS_ON:
|
|
return createPresetCommand('call', 135)
|
|
case CONTROL_COMMANDS.AUTO_FOCUS_OFF:
|
|
return createPresetCommand('call', 136)
|
|
case CONTROL_COMMANDS.DEFOG_ON:
|
|
return createPresetCommand('call', 252)
|
|
case CONTROL_COMMANDS.DEFOG_OFF:
|
|
return createPresetCommand('call', 253)
|
|
case CONTROL_COMMANDS.DEFROST_ON:
|
|
return createPresetCommand('call', 250)
|
|
case CONTROL_COMMANDS.DEFROST_OFF:
|
|
return createPresetCommand('call', 251)
|
|
case CONTROL_COMMANDS.REBOOT:
|
|
return createPresetCommand('call', 220)
|
|
default:
|
|
throw new Error(`Unsupported control action: ${action}`)
|
|
}
|
|
}
|
|
|
|
function writeTcp(command) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!CONTROL_TCP_HOST || !CONTROL_TCP_PORT) {
|
|
reject(new Error('Missing CONTROL_TCP_HOST or CONTROL_TCP_PORT'))
|
|
return
|
|
}
|
|
|
|
const socket = net.createConnection({
|
|
host: CONTROL_TCP_HOST,
|
|
port: CONTROL_TCP_PORT,
|
|
})
|
|
|
|
socket.setTimeout(3000)
|
|
socket.on('connect', () => {
|
|
socket.end(command)
|
|
})
|
|
socket.on('close', resolve)
|
|
socket.on('timeout', () => {
|
|
socket.destroy(new Error('Control TCP connection timed out'))
|
|
})
|
|
socket.on('error', reject)
|
|
})
|
|
}
|
|
|
|
function writeUdp(command) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!CONTROL_UDP_HOST || !CONTROL_UDP_PORT) {
|
|
reject(new Error('Missing CONTROL_UDP_HOST or CONTROL_UDP_PORT'))
|
|
return
|
|
}
|
|
|
|
const socket = dgram.createSocket('udp4')
|
|
socket.send(command, CONTROL_UDP_PORT, CONTROL_UDP_HOST, (error) => {
|
|
socket.close()
|
|
|
|
if (error) {
|
|
reject(error)
|
|
return
|
|
}
|
|
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function sendControlAction(action) {
|
|
const command = buildControlCommand(action)
|
|
const hex = command.toString('hex').match(/../g).join(' ').toUpperCase()
|
|
|
|
if (CONTROL_TRANSPORT === 'tcp') {
|
|
await writeTcp(command)
|
|
} else if (CONTROL_TRANSPORT === 'udp') {
|
|
await writeUdp(command)
|
|
} else {
|
|
console.log(`[control] dry-run ${action}: ${hex}`)
|
|
}
|
|
|
|
return {
|
|
action,
|
|
hex,
|
|
transport: CONTROL_TRANSPORT,
|
|
}
|
|
}
|
|
|
|
export function getControlStatus() {
|
|
return {
|
|
host: CONTROL_HOST,
|
|
port: CONTROL_PORT,
|
|
transport: CONTROL_TRANSPORT,
|
|
address: CONTROL_ADDRESS,
|
|
speed: clampPelcoSpeed(CONTROL_SPEED),
|
|
tcpConfigured: Boolean(CONTROL_TCP_HOST && CONTROL_TCP_PORT),
|
|
udpConfigured: Boolean(CONTROL_UDP_HOST && CONTROL_UDP_PORT),
|
|
}
|
|
}
|
|
|
|
function writeJson(response, statusCode, payload) {
|
|
response.writeHead(statusCode, {
|
|
'access-control-allow-origin': '*',
|
|
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
|
'access-control-allow-headers': 'content-type',
|
|
'content-type': 'application/json; charset=utf-8',
|
|
})
|
|
response.end(JSON.stringify(payload))
|
|
}
|
|
|
|
function readRequestJson(request) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = ''
|
|
|
|
request.setEncoding('utf8')
|
|
request.on('data', (chunk) => {
|
|
body += chunk
|
|
|
|
if (body.length > 1024 * 1024) {
|
|
request.destroy(new Error('Request body is too large'))
|
|
}
|
|
})
|
|
request.on('end', () => {
|
|
if (!body.trim()) {
|
|
resolve({})
|
|
return
|
|
}
|
|
|
|
try {
|
|
resolve(JSON.parse(body))
|
|
} catch {
|
|
reject(new Error('Invalid JSON body'))
|
|
}
|
|
})
|
|
request.on('error', reject)
|
|
})
|
|
}
|
|
|
|
async function handleControlRequest(request, response) {
|
|
try {
|
|
const payload = await readRequestJson(request)
|
|
const action = payload.action
|
|
|
|
if (!action || typeof action !== 'string') {
|
|
writeJson(response, 400, {
|
|
ok: false,
|
|
message: 'Missing action',
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await sendControlAction(action)
|
|
writeJson(response, 200, {
|
|
ok: true,
|
|
...result,
|
|
})
|
|
} catch (error) {
|
|
writeJson(response, 400, {
|
|
ok: false,
|
|
message: error.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
export function startControlServer() {
|
|
const server = http.createServer((request, response) => {
|
|
if (request.method === 'OPTIONS') {
|
|
writeJson(response, 204, {})
|
|
return
|
|
}
|
|
|
|
if (request.method === 'GET' && request.url === '/health') {
|
|
writeJson(response, 200, {
|
|
ok: true,
|
|
control: getControlStatus(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (request.method === 'GET' && request.url === '/commands') {
|
|
writeJson(response, 200, {
|
|
ok: true,
|
|
commands: Object.values(CONTROL_COMMANDS),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (request.method === 'POST' && request.url === '/control') {
|
|
handleControlRequest(request, response)
|
|
return
|
|
}
|
|
|
|
writeJson(response, 404, {
|
|
ok: false,
|
|
message: 'Not Found',
|
|
})
|
|
})
|
|
|
|
server.on('error', (error) => {
|
|
console.error(`[control] failed to listen on ${CONTROL_HOST}:${CONTROL_PORT}: ${error.message}`)
|
|
process.exitCode = 1
|
|
})
|
|
|
|
server.listen(CONTROL_PORT, CONTROL_HOST, () => {
|
|
console.log(`[control] http://${CONTROL_HOST}:${CONTROL_PORT}`)
|
|
console.log(`[control] transport=${CONTROL_TRANSPORT}`)
|
|
})
|
|
|
|
return server
|
|
}
|
|
|
|
const currentFile = fileURLToPath(import.meta.url)
|
|
|
|
if (process.argv[1] === currentFile) {
|
|
startControlServer()
|
|
}
|