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() }