Initial commit
This commit is contained in:
211
src/CameraRunner.ts
Normal file
211
src/CameraRunner.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Main } from './Main';
|
||||
import { delay, ServiceState } from './Utils';
|
||||
|
||||
const PREFIX = `[CameraRunner]`;
|
||||
export class CameraRunner {
|
||||
private _Main: Main;
|
||||
|
||||
socket: Socket;
|
||||
|
||||
state: ServiceState = 'DISCONNECTED';
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
processStatus: ProcessStatus = {
|
||||
state: 'STOPPED',
|
||||
|
||||
startTime: -1,
|
||||
output: { current: [], last: [] },
|
||||
};
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
}
|
||||
|
||||
handle(command: string, ...args: any[]) {
|
||||
switch (command) {
|
||||
case 'restart':
|
||||
case 'reboot':
|
||||
const callback: (response: {
|
||||
succeed: boolean;
|
||||
message?: string;
|
||||
}) => void = args[0];
|
||||
if (typeof callback !== 'function') return;
|
||||
|
||||
this.sendCommand(command, callback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sendCommand(
|
||||
command: string,
|
||||
callback: (response: { succeed: boolean; message?: string }) => void
|
||||
) {
|
||||
if (this.socket == null || !this.socket.connected)
|
||||
return callback({
|
||||
succeed: false,
|
||||
message: 'Not connected to CameraRunner',
|
||||
});
|
||||
|
||||
this.socket.emit(
|
||||
command,
|
||||
(response: { succeed: boolean; message?: string }) => {
|
||||
callback(response);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
broadcastState() {
|
||||
this._Main.WebServer.socket.emit('cameraRunnerState', this.getState());
|
||||
}
|
||||
|
||||
getState(): CameraRunnerStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
error: this.error,
|
||||
|
||||
processStatus: this.processStatus,
|
||||
};
|
||||
}
|
||||
|
||||
setInfo(message: string, error: string, state: ServiceState = 'FAILED') {
|
||||
this.message = message;
|
||||
this.error = error;
|
||||
this.state = state;
|
||||
this.broadcastState();
|
||||
|
||||
if (state == 'FAILED' || state == 'DISCONNECTED')
|
||||
console.warn(PREFIX, message ?? error);
|
||||
else console.log(PREFIX, message ?? error);
|
||||
}
|
||||
|
||||
private pollClock: NodeJS.Timeout;
|
||||
startPollClock() {
|
||||
const poll = async () => {
|
||||
const data: ProcessStatus = await new Promise((resolve) => {
|
||||
this.socket.emit('getStatus', (response: ProcessStatus) =>
|
||||
resolve(response)
|
||||
);
|
||||
});
|
||||
|
||||
this.processStatus = data;
|
||||
this.broadcastState();
|
||||
};
|
||||
|
||||
this.socket.on('simpleStatus', (simpleStatus: ProcessStatusSimple) => {
|
||||
this.processStatus.state = simpleStatus.state;
|
||||
this.processStatus.message = simpleStatus.message;
|
||||
this.processStatus.error = simpleStatus.error;
|
||||
this.broadcastState();
|
||||
});
|
||||
|
||||
clearInterval(this.pollClock);
|
||||
this.pollClock = setInterval(
|
||||
() => poll(),
|
||||
this._Main.Config.cameraRunner.pollInterval
|
||||
);
|
||||
poll();
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
clearInterval(this.pollClock);
|
||||
if (this.socket != null) {
|
||||
this.socket.disconnect();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
await delay(2000);
|
||||
|
||||
this.message = 'Reconnecting in 10 seconds...';
|
||||
this.broadcastState();
|
||||
|
||||
await delay(5000);
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.setInfo('Connecting...', null, 'CONNECTING');
|
||||
|
||||
await delay(1000);
|
||||
|
||||
const ip = this._Main.Config.cameraRunner.webSocket.ip;
|
||||
const port = this._Main.Config.cameraRunner.webSocket.port;
|
||||
|
||||
if (ip == null || port == null) {
|
||||
return this.setInfo(
|
||||
'Connection problem',
|
||||
'Camera Runner WebSocket IP or port is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
this.socket = io(`ws://${ip}:${port}`, {
|
||||
reconnectionAttempts: 0,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.setInfo('Connected', null, 'CONNECTED');
|
||||
this.startPollClock();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.setInfo(
|
||||
'Disconnected',
|
||||
'Camera Runner was disconnected unexpectedly',
|
||||
'DISCONNECTED'
|
||||
);
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('reconnect', () => {
|
||||
this.setInfo('Connected (reconnection)', null, 'CONNECTED');
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (err) => {
|
||||
this.setInfo('Connection problem', `Connection error: ${err}`);
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.setInfo('Connection problem', `Socket error: ${err}`);
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('reconnect_attempt', () => {
|
||||
this.setInfo('Reconnecting', null, 'CONNECTING');
|
||||
});
|
||||
|
||||
this.socket.on('reconnect_failed', () => {
|
||||
this.setInfo('Connection problem', 'Reconnection failed');
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.setInfo('Disconnected', null, 'DISCONNECTED');
|
||||
this.reconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface CameraRunnerStatus {
|
||||
state: ServiceState;
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
processStatus: ProcessStatus;
|
||||
}
|
||||
|
||||
export type ProcessStatusState = 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
|
||||
|
||||
interface ProcessStatusSimple {
|
||||
state: ProcessStatusState;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
interface ProcessStatus extends ProcessStatusSimple {
|
||||
startTime: number;
|
||||
output: { current: string[]; last: string[] };
|
||||
}
|
||||
134
src/Configuration/ConfigurationManager.ts
Normal file
134
src/Configuration/ConfigurationManager.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ensureDir, pathExists, readJSON, writeFile } from 'fs-extra';
|
||||
import { Main } from '../Main';
|
||||
import { join } from 'path';
|
||||
import { DefaultConfiguration } from './DefaultConfiguration';
|
||||
|
||||
const PREFIX = '[ConfigurationManager]';
|
||||
export class ConfigurationManager {
|
||||
private _Main: Main;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
}
|
||||
|
||||
async load() {
|
||||
console.log(PREFIX, 'Loading');
|
||||
|
||||
await ensureDir(join(this._Main.dataPath));
|
||||
|
||||
let configPath = join(this._Main.dataPath, 'config.json');
|
||||
let configExists = await pathExists(configPath);
|
||||
if (!configExists) {
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(DefaultConfiguration, null, 4)
|
||||
);
|
||||
this._Main.Config = DefaultConfiguration;
|
||||
console.log(PREFIX, 'Written default configuration');
|
||||
} else {
|
||||
var config: Config = await readJSON(configPath);
|
||||
|
||||
this._Main.Config = await this.validateConfig(config);
|
||||
console.log(PREFIX, 'Loaded configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async validateConfig(config: Config): Promise<Config> {
|
||||
const normalizedConfig: Config = this.normalizeConfig(
|
||||
config,
|
||||
DefaultConfiguration
|
||||
);
|
||||
const hasChanges =
|
||||
JSON.stringify(config) !== JSON.stringify(normalizedConfig);
|
||||
|
||||
if (hasChanges) {
|
||||
this._Main.Config = normalizedConfig;
|
||||
|
||||
const configPath = join(this._Main.dataPath, 'config.json');
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(normalizedConfig, null, 4)
|
||||
);
|
||||
console.log(PREFIX, 'Configuration updated and saved');
|
||||
}
|
||||
|
||||
return normalizedConfig;
|
||||
}
|
||||
|
||||
private normalizeConfig(current: any, template: any): any {
|
||||
if (template === null || template === undefined) {
|
||||
return template;
|
||||
}
|
||||
|
||||
if (typeof template !== 'object' || Array.isArray(template)) {
|
||||
if (
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
typeof current === typeof template
|
||||
) {
|
||||
if (Array.isArray(template) && Array.isArray(current)) {
|
||||
if (
|
||||
template.length > 0 &&
|
||||
typeof template[0] === 'object' &&
|
||||
!Array.isArray(template[0])
|
||||
) {
|
||||
return current.map((item) =>
|
||||
this.normalizeConfig(item, template[0])
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
|
||||
for (const key in template) {
|
||||
if (template.hasOwnProperty(key)) {
|
||||
result[key] = this.normalizeConfig(
|
||||
current?.[key],
|
||||
template[key]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
webServer: ConfigWebServer;
|
||||
unity: ConfigUnity;
|
||||
cameraRunner: ConfigCameraRunner;
|
||||
}
|
||||
|
||||
export interface ConfigWebServer {
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ConfigUnity {
|
||||
executable: {
|
||||
path: string;
|
||||
arguments: string[];
|
||||
startUpDelay?: number;
|
||||
};
|
||||
|
||||
webSocket: {
|
||||
ip: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
heartbeatInterval: number;
|
||||
calibrationImageInterval: number;
|
||||
}
|
||||
|
||||
export interface ConfigCameraRunner {
|
||||
webSocket: {
|
||||
ip: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
pollInterval: number;
|
||||
}
|
||||
30
src/Configuration/DefaultConfiguration.ts
Normal file
30
src/Configuration/DefaultConfiguration.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Config } from './ConfigurationManager';
|
||||
|
||||
export const DefaultConfiguration: Config = {
|
||||
webServer: {
|
||||
port: 6300,
|
||||
},
|
||||
unity: {
|
||||
executable: {
|
||||
path: '',
|
||||
arguments: [],
|
||||
startUpDelay: 5000,
|
||||
},
|
||||
|
||||
webSocket: {
|
||||
ip: '127.0.0.1',
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
heartbeatInterval: 1000,
|
||||
calibrationImageInterval: 2000,
|
||||
},
|
||||
cameraRunner: {
|
||||
webSocket: {
|
||||
ip: '127.0.0.1',
|
||||
port: 6301,
|
||||
},
|
||||
|
||||
pollInterval: 5000,
|
||||
},
|
||||
};
|
||||
4
src/Entry.ts
Normal file
4
src/Entry.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Main } from './Main';
|
||||
|
||||
const _Main = new Main();
|
||||
_Main.start();
|
||||
34
src/Main.ts
Normal file
34
src/Main.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { join } from 'path';
|
||||
import { WebServer } from './WebServer/WebServer';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
Config,
|
||||
ConfigurationManager,
|
||||
} from './Configuration/ConfigurationManager';
|
||||
import { CameraRunner } from './CameraRunner';
|
||||
import { UnityRunner } from './Unity/UnityRunner';
|
||||
import { UnityWebSocket } from './Unity/UnityWebSocket';
|
||||
|
||||
export class Main {
|
||||
dataPath = join(homedir(), 'MorphixProductions', 'NTSHControl');
|
||||
|
||||
ConfigurationManager = new ConfigurationManager(this);
|
||||
WebServer = new WebServer(this);
|
||||
|
||||
CameraRunner = new CameraRunner(this);
|
||||
UnityRunner = new UnityRunner(this);
|
||||
UnityWebSocket = new UnityWebSocket(this);
|
||||
|
||||
Config: Config;
|
||||
|
||||
async start() {
|
||||
await this.ConfigurationManager.load();
|
||||
await this.WebServer.listen();
|
||||
|
||||
await this.CameraRunner.connect();
|
||||
|
||||
setTimeout(() => {
|
||||
this.UnityRunner.start();
|
||||
}, this.Config.unity.executable.startUpDelay ?? 0);
|
||||
}
|
||||
}
|
||||
274
src/Unity/UnityRunner.ts
Normal file
274
src/Unity/UnityRunner.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import { delay } from '../Utils';
|
||||
import { Main } from '../Main';
|
||||
|
||||
const PREFIX = '[UnityRunner]';
|
||||
|
||||
const LOG_OUTPUT = !process.argv.includes('--no-output-log');
|
||||
|
||||
export class UnityRunner {
|
||||
private _Main: Main;
|
||||
|
||||
state: UnityRunnerState;
|
||||
message?: string = 'Awaiting startup delay...';
|
||||
error?: string;
|
||||
|
||||
startTime: number = null;
|
||||
output: { current: string[]; last: string[] } = { current: [], last: [] };
|
||||
|
||||
process: ChildProcess;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
}
|
||||
|
||||
handle(command: string, ...args: any[]) {
|
||||
switch (command) {
|
||||
case 'restart':
|
||||
const callback: (response: {
|
||||
succeed: boolean;
|
||||
message?: string;
|
||||
}) => void = args[0];
|
||||
if (typeof callback !== 'function') return;
|
||||
|
||||
callback(this.requestRestart());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private statusClock: NodeJS.Timeout;
|
||||
startStatusClock() {
|
||||
clearInterval(this.statusClock);
|
||||
this.statusClock = setInterval(() => {
|
||||
this.broadcastState();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
requestRestart(): { succeed: boolean; message?: string } {
|
||||
if (this.state !== 'RUNNING')
|
||||
return {
|
||||
succeed: false,
|
||||
message:
|
||||
'Cannot restart when process is not running. It is probably restarting already.',
|
||||
};
|
||||
|
||||
this.restart(true);
|
||||
return { succeed: true };
|
||||
}
|
||||
|
||||
broadcastState() {
|
||||
this._Main.WebServer.socket.emit('unityRunnerState', this.getStatus());
|
||||
}
|
||||
|
||||
setInfo(
|
||||
message: string,
|
||||
error: string,
|
||||
state: UnityRunnerState = 'PROBLEM'
|
||||
) {
|
||||
this.message = message;
|
||||
this.error = error;
|
||||
this.state = state;
|
||||
this.broadcastState();
|
||||
|
||||
this.output.current.push(
|
||||
`[${new Date().toLocaleTimeString('nl-NL')}] [System] [${state}] ${
|
||||
message ?? error
|
||||
}`
|
||||
);
|
||||
|
||||
if (state == 'PROBLEM' || state == 'STOPPED')
|
||||
console.warn(PREFIX, message ?? error);
|
||||
else console.log(PREFIX, message ?? error);
|
||||
}
|
||||
|
||||
private restartTriggered: boolean = false;
|
||||
async restart(instant: boolean = false) {
|
||||
if (this.restartTriggered) return;
|
||||
clearInterval(this.statusClock);
|
||||
|
||||
this._Main.WebServer.Calibration.hasCalibrationImage = false;
|
||||
|
||||
this._Main.UnityWebSocket.disconnect();
|
||||
|
||||
this.restartTriggered = true;
|
||||
this.startTime = -1;
|
||||
this.broadcastState();
|
||||
|
||||
await delay(2000);
|
||||
|
||||
if (this.output.current.length > 0) {
|
||||
this.output.last = [...this.output.current];
|
||||
this.output.current = [];
|
||||
}
|
||||
|
||||
if (instant)
|
||||
this.setInfo('Process will restart shortly...', null, 'STOPPED');
|
||||
|
||||
if (this.process != null) {
|
||||
this.process.kill('SIGTERM');
|
||||
|
||||
await delay(3000);
|
||||
|
||||
if (!this.process.killed && this.process.exitCode === null) {
|
||||
this.process.kill('SIGKILL');
|
||||
console.log(PREFIX, 'Sent SIGKILL to process.');
|
||||
}
|
||||
}
|
||||
this.startTime = -1;
|
||||
|
||||
if (!instant) {
|
||||
this.message = 'Reconnecting in 10 seconds...';
|
||||
this.broadcastState();
|
||||
|
||||
await delay(10000);
|
||||
}
|
||||
await this.start();
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.output.current.length > 0) {
|
||||
this.output.last = [...this.output.current];
|
||||
this.output.current = [];
|
||||
}
|
||||
this.startTime = Date.now();
|
||||
this.restartTriggered = false;
|
||||
this.broadcastState();
|
||||
|
||||
this._Main.WebServer.Calibration.hasCalibrationImage = false;
|
||||
|
||||
clearInterval(this.statusClock);
|
||||
|
||||
const path = this._Main.Config.unity.executable.path;
|
||||
if (path == null || !pathExistsSync(path)) {
|
||||
this.setInfo(
|
||||
'Executable problem',
|
||||
`Executable path is not set or does not exist: ${path}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._Main.CameraRunner.state !== 'CONNECTED')
|
||||
await new Promise<void>((resolve) => {
|
||||
this.setInfo(
|
||||
'Waiting for CameraRunner to connect...',
|
||||
null,
|
||||
'STARTING'
|
||||
);
|
||||
var c = setInterval(() => {
|
||||
if (this._Main.CameraRunner.state !== 'CONNECTED') return;
|
||||
|
||||
clearInterval(c);
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
if (this._Main.CameraRunner.processStatus?.state !== 'RUNNING')
|
||||
await new Promise<void>((resolve) => {
|
||||
this.setInfo(
|
||||
'Waiting for CameraRunner process to start...',
|
||||
null,
|
||||
'STARTING'
|
||||
);
|
||||
var c = setInterval(() => {
|
||||
if (
|
||||
this._Main.CameraRunner.processStatus?.state !==
|
||||
'RUNNING'
|
||||
)
|
||||
return;
|
||||
|
||||
clearInterval(c);
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
const fileName = path.split(/(\/|\\)/).pop();
|
||||
|
||||
this.setInfo(`Starting executable: ${fileName}`, null, 'STARTING');
|
||||
|
||||
this.process = spawn(
|
||||
path,
|
||||
this._Main.Config.unity.executable.arguments,
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
|
||||
this.process.on('exit', (code, signal) => {
|
||||
if (this.restartTriggered) return;
|
||||
this.setInfo(
|
||||
'Process exited',
|
||||
`Process exited with code ${code} and signal ${signal}`,
|
||||
'STOPPED'
|
||||
);
|
||||
this.restart();
|
||||
});
|
||||
this.process.on('error', (err) => {
|
||||
this.setInfo('Process error', err.message);
|
||||
this.restart();
|
||||
});
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
lines.forEach((line) => {
|
||||
const formattedLine = `[${new Date().toLocaleTimeString(
|
||||
'nl-NL'
|
||||
)}] [${fileName}] ${line}`;
|
||||
if (LOG_OUTPUT) console.log(PREFIX, formattedLine);
|
||||
this.output.current.push(formattedLine);
|
||||
});
|
||||
});
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
lines.forEach((line) => {
|
||||
const formattedLine = `[${new Date().toLocaleTimeString(
|
||||
'nl-NL'
|
||||
)}] [${fileName}] [ERROR] ${line}`;
|
||||
if (LOG_OUTPUT) console.error(PREFIX, formattedLine);
|
||||
this.output.current.push(formattedLine);
|
||||
});
|
||||
});
|
||||
this.startStatusClock();
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.process == null ||
|
||||
this.process?.killed ||
|
||||
this.process?.exitCode != null ||
|
||||
this.restartTriggered
|
||||
)
|
||||
return;
|
||||
|
||||
this.setInfo('Running', '', 'RUNNING');
|
||||
this._Main.UnityWebSocket.connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
getStatus(): UnityRunnerStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
error: this.error,
|
||||
|
||||
startTime: this.startTime,
|
||||
output: this.output,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface UnityRunnerStatus {
|
||||
state: 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
startTime: number;
|
||||
output: { current: string[]; last: string[] };
|
||||
}
|
||||
|
||||
export type UnityRunnerState = 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
|
||||
286
src/Unity/UnityWebSocket.ts
Normal file
286
src/Unity/UnityWebSocket.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { RawData, WebSocket } from 'ws';
|
||||
import { Main } from '../Main';
|
||||
import { delay, ServiceState } from '../Utils';
|
||||
|
||||
const PREFIX = '[Unity]';
|
||||
export class UnityWebSocket {
|
||||
private _Main: Main;
|
||||
|
||||
state: ServiceState = 'DISCONNECTED';
|
||||
message?: string = 'Waiting for process...';
|
||||
error?: string;
|
||||
|
||||
parameters: UnityParameters = {
|
||||
timelineWatching: false,
|
||||
timelineStanding: false,
|
||||
timelineProgress: 0,
|
||||
zedPath: '',
|
||||
zedReady: false,
|
||||
zedFPS: '-',
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
socket: WebSocket;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
}
|
||||
|
||||
handle(command: string, ...args: any[]) {
|
||||
switch (command) {
|
||||
case 'parameterValue':
|
||||
const sliderIndex: number = args[0];
|
||||
const percentage: number = args[1];
|
||||
|
||||
if (this.parameters.parameters[sliderIndex] == undefined)
|
||||
return;
|
||||
this.parameters.parameters[sliderIndex].outputValue =
|
||||
percentage;
|
||||
|
||||
if (
|
||||
this.socket == null ||
|
||||
this.socket.readyState !== WebSocket.OPEN
|
||||
)
|
||||
return;
|
||||
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'set_slider_value',
|
||||
sliderIndex: sliderIndex,
|
||||
sliderValue: percentage,
|
||||
})
|
||||
);
|
||||
|
||||
this.broadcastState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
broadcastState() {
|
||||
this._Main.WebServer.socket.emit(
|
||||
'unityWebSocketState',
|
||||
this.getState()
|
||||
);
|
||||
}
|
||||
|
||||
setInfo(message: string, error: string, state: ServiceState = 'FAILED') {
|
||||
this.message = message;
|
||||
this.error = error;
|
||||
this.state = state;
|
||||
this.broadcastState();
|
||||
|
||||
if (state == 'FAILED' || state == 'DISCONNECTED')
|
||||
console.warn(PREFIX, message ?? error);
|
||||
else console.log(PREFIX, message ?? error);
|
||||
}
|
||||
|
||||
stopFetchClocks() {
|
||||
clearInterval(this.heartbeatClock);
|
||||
clearInterval(this.calibrationImageClock);
|
||||
}
|
||||
|
||||
handleSocketMessage(data: RawData) {
|
||||
let message: UnitySocketMessage;
|
||||
try {
|
||||
message = JSON.parse(data.toString());
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'heartbeat_data':
|
||||
this.parameters.timelineWatching =
|
||||
message.heartbeat.dataTimeline.isWatching;
|
||||
this.parameters.timelineStanding =
|
||||
message.heartbeat.dataTimeline.isStanding;
|
||||
this.parameters.timelineProgress =
|
||||
message.heartbeat.dataTimeline.timelineProgress;
|
||||
this.parameters.zedPath = `${message.heartbeat.zedCamera.streamInputIP}:${message.heartbeat.zedCamera.streamInputPort}`;
|
||||
this.parameters.zedReady =
|
||||
message.heartbeat.zedCamera.isZedReady;
|
||||
this.parameters.zedFPS = message.heartbeat.zedCamera.cameraFPS;
|
||||
this.parameters.parameters = message.heartbeat.dataSliders;
|
||||
this.broadcastState();
|
||||
break;
|
||||
|
||||
case 'response_camera_frame':
|
||||
this._Main.WebServer.Calibration.writeCalibrationImage(
|
||||
message.imageBase64
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
disconnected: boolean = false;
|
||||
async disconnect() {
|
||||
this.restartRequested = true;
|
||||
this.disconnected = true;
|
||||
|
||||
if (this.socket != null) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
this.stopFetchClocks();
|
||||
|
||||
this.setInfo('Waiting for process...', null, 'DISCONNECTED');
|
||||
}
|
||||
|
||||
private restartRequested = false;
|
||||
async reconnect() {
|
||||
if (this.restartRequested) return;
|
||||
if (this.disconnected) return;
|
||||
this.restartRequested = true;
|
||||
|
||||
this.stopFetchClocks();
|
||||
|
||||
if (this.socket != null) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
await delay(2000);
|
||||
if (this.disconnected) return;
|
||||
|
||||
this.message = `Reconnecting in 10 seconds...`;
|
||||
this.broadcastState();
|
||||
|
||||
await delay(10000);
|
||||
if (this.disconnected) return;
|
||||
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.restartRequested = false;
|
||||
this.disconnected = false;
|
||||
|
||||
this.stopFetchClocks();
|
||||
|
||||
this.setInfo('Connecting...', null, 'CONNECTING');
|
||||
|
||||
await delay(1000);
|
||||
|
||||
this.socket = new WebSocket(
|
||||
`ws://${this._Main.Config.unity.webSocket.ip}:${this._Main.Config.unity.webSocket.port}`
|
||||
);
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
if (this.restartRequested) return;
|
||||
this.setInfo(
|
||||
'Connection error',
|
||||
`Could not connect: ${error.message}`,
|
||||
'FAILED'
|
||||
);
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('open', () => {
|
||||
this.startFetchClocks();
|
||||
|
||||
this.setInfo('Connected', null, 'CONNECTED');
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
if (this.restartRequested) return;
|
||||
this.setInfo(
|
||||
'Disconnected',
|
||||
'Unity was disconnected unexpectedly',
|
||||
'FAILED'
|
||||
);
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.socket.on('message', (data) => this.handleSocketMessage(data));
|
||||
}
|
||||
|
||||
private heartbeatClock: NodeJS.Timeout;
|
||||
private calibrationImageClock: NodeJS.Timeout;
|
||||
startFetchClocks() {
|
||||
this.socket.send(
|
||||
JSON.stringify({ type: 'set_heartbeat_auto_send', autoSend: false })
|
||||
);
|
||||
this.heartbeatClock = setInterval(() => {
|
||||
if (
|
||||
this.socket == null ||
|
||||
this.socket.readyState !== WebSocket.OPEN
|
||||
)
|
||||
return;
|
||||
|
||||
this.socket.send(JSON.stringify({ type: 'request_heartbeat' }));
|
||||
}, this._Main.Config.unity.heartbeatInterval);
|
||||
|
||||
this.calibrationImageClock = setInterval(() => {
|
||||
if (
|
||||
this.socket == null ||
|
||||
this.socket.readyState !== WebSocket.OPEN
|
||||
)
|
||||
return;
|
||||
|
||||
this.socket.send(JSON.stringify({ type: 'request_camera_frame' }));
|
||||
}, this._Main.Config.unity.calibrationImageInterval);
|
||||
}
|
||||
|
||||
getState(): UnityWebSocketStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
error: this.error,
|
||||
|
||||
parameters: this.parameters,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface UnityWebSocketStatus {
|
||||
state: ServiceState;
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
parameters: UnityParameters;
|
||||
}
|
||||
|
||||
interface UnityParameters {
|
||||
timelineWatching: boolean;
|
||||
timelineStanding: boolean;
|
||||
timelineProgress: number;
|
||||
zedPath: string;
|
||||
zedReady: boolean;
|
||||
zedFPS: string;
|
||||
parameters: UnitySocketMessageHeartbeat['heartbeat']['dataSliders'];
|
||||
}
|
||||
|
||||
type UnitySocketMessage =
|
||||
| UnitySocketMessageHeartbeat
|
||||
| UnitySocketMessageCameraFrame;
|
||||
|
||||
interface UnitySocketMessageBase {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
}
|
||||
interface UnitySocketMessageHeartbeat extends UnitySocketMessageBase {
|
||||
type: 'heartbeat_data';
|
||||
heartbeat: {
|
||||
dataSliders: {
|
||||
sliderIndex: number;
|
||||
sliderName: string;
|
||||
outputValue: number;
|
||||
}[];
|
||||
dataTimeline: {
|
||||
isStanding: boolean;
|
||||
isWatching: boolean;
|
||||
timelineProgress: number;
|
||||
};
|
||||
zedCamera: {
|
||||
cameraFPS: string;
|
||||
isZedReady: boolean;
|
||||
streamInputIP: string;
|
||||
streamInputPort: number;
|
||||
zedGrabError: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
interface UnitySocketMessageCameraFrame extends UnitySocketMessageBase {
|
||||
type: 'response_camera_frame';
|
||||
imageBase64: string;
|
||||
}
|
||||
9
src/Utils.ts
Normal file
9
src/Utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type ServiceState =
|
||||
| 'CONNECTING'
|
||||
| 'CONNECTED'
|
||||
| 'DISCONNECTED'
|
||||
| 'FAILED';
|
||||
|
||||
export function delay(duration: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, duration));
|
||||
}
|
||||
41
src/WebServer/CalibrationRouter.ts
Normal file
41
src/WebServer/CalibrationRouter.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router } from 'express';
|
||||
import { Main } from '../Main';
|
||||
import { join } from 'path';
|
||||
import { pathExistsSync, readdirSync, readFile, writeFile } from 'fs-extra';
|
||||
|
||||
export class CalibrationRouter {
|
||||
private _Main: Main;
|
||||
Router: Router;
|
||||
|
||||
calibrationImagePath: string;
|
||||
hasCalibrationImage: boolean = false;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
this.Router = Router();
|
||||
|
||||
this.calibrationImagePath = join(
|
||||
this._Main.dataPath,
|
||||
'calibrationImage.png'
|
||||
);
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
async registerRoutes() {
|
||||
this.Router.get('/calibrationImage', (req, res) => {
|
||||
if (!this.hasCalibrationImage)
|
||||
return res.redirect('/img/noCalibrationImage.png');
|
||||
|
||||
res.sendFile(this.calibrationImagePath);
|
||||
});
|
||||
}
|
||||
|
||||
async writeCalibrationImage(base64: string) {
|
||||
await writeFile(
|
||||
this.calibrationImagePath,
|
||||
Buffer.from(base64, 'base64')
|
||||
);
|
||||
this.hasCalibrationImage = true;
|
||||
}
|
||||
}
|
||||
51
src/WebServer/DashboardRouter.ts
Normal file
51
src/WebServer/DashboardRouter.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express';
|
||||
import { Main } from '../Main';
|
||||
import { join } from 'path';
|
||||
import { pathExistsSync, readdirSync, readFile } from 'fs-extra';
|
||||
|
||||
export class DashboardRouter {
|
||||
private _Main: Main;
|
||||
Router: Router;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
this.Router = Router();
|
||||
|
||||
this.path = join(
|
||||
__filename,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'frontend',
|
||||
'views',
|
||||
'dashboard'
|
||||
);
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
async registerRoutes() {
|
||||
this.Router.get(
|
||||
['/', '/dashboard', '/calibration', '/cameralogs', '/unitylogs'],
|
||||
async (req, res) => {
|
||||
const htmlContent = await readFile(
|
||||
join(this.path, 'index.html')
|
||||
);
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(htmlContent);
|
||||
}
|
||||
);
|
||||
this.Router.get('/style.css', async (req, res) => {
|
||||
const styleContent = await readFile(join(this.path, 'style.css'));
|
||||
res.setHeader('Content-Type', 'text/css');
|
||||
res.send(styleContent);
|
||||
});
|
||||
this.Router.get('/script.js', async (req, res) => {
|
||||
const scriptContent = await readFile(join(this.path, 'script.js'));
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.send(scriptContent);
|
||||
});
|
||||
}
|
||||
}
|
||||
78
src/WebServer/WebServer.ts
Normal file
78
src/WebServer/WebServer.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Application } from 'express';
|
||||
import * as express from 'express';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { createServer, Server as HTTPServer } from 'http';
|
||||
import { Main } from '../Main';
|
||||
import { DashboardRouter } from './DashboardRouter';
|
||||
import { join } from 'path';
|
||||
import { CalibrationRouter } from './CalibrationRouter';
|
||||
|
||||
const PREFIX = '[WebServer]';
|
||||
export class WebServer {
|
||||
private _Main: Main;
|
||||
|
||||
Dashboard: DashboardRouter;
|
||||
Calibration: CalibrationRouter;
|
||||
|
||||
httpServer: HTTPServer;
|
||||
app: Application;
|
||||
socket: SocketIOServer;
|
||||
|
||||
constructor(Main: Main) {
|
||||
this._Main = Main;
|
||||
this.Dashboard = new DashboardRouter(this._Main);
|
||||
this.Calibration = new CalibrationRouter(this._Main);
|
||||
|
||||
this.prepare();
|
||||
}
|
||||
|
||||
private prepare() {
|
||||
this.app = express();
|
||||
this.httpServer = createServer(this.app);
|
||||
this.socket = new SocketIOServer(this.httpServer);
|
||||
|
||||
this.app.use(
|
||||
express.static(
|
||||
join(__filename, '..', '..', '..', 'frontend', 'static')
|
||||
)
|
||||
);
|
||||
|
||||
this.app.use(this.Dashboard.Router);
|
||||
this.app.use(this.Calibration.Router);
|
||||
|
||||
this.socket.on('connection', (socket) => {
|
||||
socket.emit(
|
||||
'cameraRunnerState',
|
||||
this._Main.CameraRunner.getState()
|
||||
);
|
||||
socket.emit('unityRunnerState', this._Main.UnityRunner.getStatus());
|
||||
socket.emit(
|
||||
'unityWebSocketState',
|
||||
this._Main.UnityWebSocket.getState()
|
||||
);
|
||||
|
||||
socket.on('cameraRunner', (command: string, ...args: any[]) =>
|
||||
this._Main.CameraRunner.handle(command, ...args)
|
||||
);
|
||||
socket.on('unityRunner', (command: string, ...args: any[]) =>
|
||||
this._Main.UnityRunner.handle(command, ...args)
|
||||
);
|
||||
socket.on('unityWebSocket', (command: string, ...args: any[]) =>
|
||||
this._Main.UnityWebSocket.handle(command, ...args)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
listen() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const port = this._Main.Config.webServer.port;
|
||||
this.httpServer.listen(port, () => {
|
||||
console.log(
|
||||
PREFIX,
|
||||
`Listening on port http://127.0.0.1:${port}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user