Added basic control panel

This commit is contained in:
2026-03-11 16:46:06 +01:00
parent 7df210aaf2
commit c4eedfff1e
105 changed files with 21923 additions and 958 deletions

112
src/Audio.ts Normal file
View File

@@ -0,0 +1,112 @@
import { pathExistsSync, readJSONSync, writeFile } from 'fs-extra';
import { Main } from './Main';
import { join } from 'path';
const portAudio = require('naudiodon');
const PREFIX = '[Audio]';
export class AudioChecker {
private _Main: Main;
private configPath: string;
deviceName: string = null;
constructor(Main: Main) {
this._Main = Main;
this.configPath = join(this._Main.dataPath, 'audio.json');
this.load();
}
async waitForDevice() {
if ((this.deviceName ?? '').trim().length == 0) {
console.log(
PREFIX,
'No audio device configured, skipping audio check. Available devices:',
);
portAudio
.getDevices()
.filter((device) => device.maxOutputChannels > 0)
.forEach((device) => {
console.log(` - ${device.name}`);
});
return;
}
console.log(PREFIX, `Waiting for device "${this.deviceName}"...`);
let counter = 0;
return new Promise<void>((resolve) => {
const c = setInterval(() => {
counter++;
const availableDevices = portAudio
.getDevices()
.filter((device) => device.maxOutputChannels > 0)
.map((device) => device.name);
if (!availableDevices.includes(this.deviceName)) {
if (counter == 3) {
console.log(
PREFIX,
'Trouble finding the correct device. Available devices:',
);
availableDevices.forEach((device) => {
console.log(` - ${device}`);
});
}
return;
}
const hostApis = portAudio.getHostAPIs();
const defaultOutputDeviceId =
hostApis.HostAPIs[hostApis.defaultHostAPI].defaultOutput;
const defaultOutputDeviceName = portAudio
.getDevices()
.find(
(device) => device.id === defaultOutputDeviceId,
)?.name;
if (defaultOutputDeviceName !== this.deviceName) {
console.warn(
PREFIX,
`The configured device "${this.deviceName}" is not set as the default output device. Please set it as the default output device to ensure proper functionality. Current default output device: "${defaultOutputDeviceName}".`,
);
this._Main.Twilio.sendError(
'Audio',
`The configured device "${this.deviceName}" is not set as the default output device. Current default output device: "${defaultOutputDeviceName}".`,
);
return;
}
clearInterval(c);
resolve();
console.log(PREFIX, `Device "${this.deviceName}" is ready!`);
}, 1000);
});
}
load() {
console.log(PREFIX, 'Loading audio configuration...');
const exists = pathExistsSync(this.configPath);
if (!exists) return this.save();
const data = readJSONSync(this.configPath);
this.deviceName = data.deviceName;
}
private _save: NodeJS.Timeout;
async save() {
if (this._save) clearTimeout(this._save);
this._save = setTimeout(async () => {
writeFile(
this.configPath,
JSON.stringify(
{ deviceName: this.deviceName ?? null },
null,
4,
),
);
}, 1000);
}
}

View File

@@ -1,6 +1,7 @@
import { io, Socket } from 'socket.io-client';
import { Main } from './Main';
import { delay, ServiceState } from './Utils';
import { State, StatusType } from './Status';
const PREFIX = `[CameraRunner]`;
export class CameraRunner {
@@ -43,7 +44,7 @@ export class CameraRunner {
sendCommand(
command: 'reboot' | 'restart',
callback: (response: { succeed: boolean; message?: string }) => void
callback: (response: { succeed: boolean; message?: string }) => void,
) {
if (this.socket == null || !this.socket.connected)
return callback({
@@ -55,7 +56,7 @@ export class CameraRunner {
command,
(response: { succeed: boolean; message?: string }) => {
callback(response);
}
},
);
}
@@ -77,6 +78,18 @@ export class CameraRunner {
this.message = message;
this.error = error;
this.state = state;
this._Main.Status.update(
StatusType.CameraPC,
CameraPCStateColors[this.state],
message,
{
reboot:
state === 'FAILED' ||
state === 'DISCONNECTED' ||
state == 'CONNECTED',
},
);
this.broadcastState();
if (
@@ -95,11 +108,23 @@ export class CameraRunner {
const poll = async () => {
const data: ProcessStatus = await new Promise((resolve) => {
this.socket.emit('getStatus', (response: ProcessStatus) =>
resolve(response)
resolve(response),
);
});
this.processStatus = data;
this._Main.Status.update(
StatusType.CameraProcess,
CameraProcessStateColors[this.processStatus.state],
this.processStatus.message,
{
reboot:
this.processStatus.state === 'PROBLEM' ||
this.processStatus.state === 'STOPPED' ||
this.processStatus.state === 'RUNNING',
},
);
this.broadcastState();
};
@@ -107,13 +132,24 @@ export class CameraRunner {
this.processStatus.state = simpleStatus.state;
this.processStatus.message = simpleStatus.message;
this.processStatus.error = simpleStatus.error;
this._Main.Status.update(
StatusType.CameraProcess,
CameraProcessStateColors[this.processStatus.state],
this.processStatus.message,
{
reboot:
this.processStatus.state === 'PROBLEM' ||
this.processStatus.state === 'STOPPED' ||
this.processStatus.state === 'RUNNING',
},
);
this.broadcastState();
});
clearInterval(this.pollClock);
this.pollClock = setInterval(
() => poll(),
this._Main.Config.cameraRunner.pollInterval
this._Main.Config.cameraRunner.pollInterval,
);
poll();
}
@@ -146,7 +182,7 @@ export class CameraRunner {
if (ip == null || port == null) {
return this.setInfo(
'Connection problem',
'Camera Runner WebSocket IP or port is not configured.'
'Camera Runner WebSocket IP or port is not configured.',
);
}
@@ -165,7 +201,7 @@ export class CameraRunner {
this.setInfo(
'Disconnected',
'Camera Runner was disconnected unexpectedly',
'DISCONNECTED'
'DISCONNECTED',
);
this.reconnect();
});
@@ -210,6 +246,19 @@ interface CameraRunnerStatus {
export type ProcessStatusState = 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
export const CameraPCStateColors: Record<ServiceState, State> = {
CONNECTED: State.Green,
DISCONNECTED: State.Gray,
CONNECTING: State.Yellow,
FAILED: State.Red,
};
export const CameraProcessStateColors: Record<ProcessStatusState, State> = {
RUNNING: State.Green,
STOPPED: State.Gray,
STARTING: State.Yellow,
PROBLEM: State.Red,
};
interface ProcessStatusSimple {
state: ProcessStatusState;
message?: string;

View File

@@ -21,7 +21,7 @@ export class ConfigurationManager {
if (!configExists) {
await writeFile(
configPath,
JSON.stringify(DefaultConfiguration, null, 4)
JSON.stringify(DefaultConfiguration, null, 4),
);
this._Main.Config = DefaultConfiguration;
console.log(PREFIX, 'Written default configuration');
@@ -36,7 +36,7 @@ export class ConfigurationManager {
async validateConfig(config: Config): Promise<Config> {
const normalizedConfig: Config = this.normalizeConfig(
config,
DefaultConfiguration
DefaultConfiguration,
);
const hasChanges =
JSON.stringify(config) !== JSON.stringify(normalizedConfig);
@@ -47,7 +47,7 @@ export class ConfigurationManager {
const configPath = join(this._Main.dataPath, 'config.json');
await writeFile(
configPath,
JSON.stringify(normalizedConfig, null, 4)
JSON.stringify(normalizedConfig, null, 4),
);
console.log(PREFIX, 'Configuration updated and saved');
}
@@ -73,7 +73,7 @@ export class ConfigurationManager {
!Array.isArray(template[0])
) {
return current.map((item) =>
this.normalizeConfig(item, template[0])
this.normalizeConfig(item, template[0]),
);
}
return current;
@@ -89,7 +89,7 @@ export class ConfigurationManager {
if (template.hasOwnProperty(key)) {
result[key] = this.normalizeConfig(
current?.[key],
template[key]
template[key],
);
}
}
@@ -130,6 +130,7 @@ export interface ConfigUnity {
heartbeatInterval: number;
calibrationImageInterval: number;
launchOnStartup: boolean;
}
export interface ConfigCameraRunner {

View File

@@ -22,6 +22,7 @@ export const DefaultConfiguration: Config = {
heartbeatInterval: 1000,
calibrationImageInterval: 2000,
launchOnStartup: true,
},
cameraRunner: {
webSocket: {

113
src/Configuration/Timer.ts Normal file
View File

@@ -0,0 +1,113 @@
import { join } from 'path';
import { Main } from '../Main';
import { pathExists, readJSON, writeFile, writeJSON } from 'fs-extra';
import { StartOrigin } from '../Unity/UnityRunner';
const PREFIX = '[Main]';
export class Timer {
private _Main: Main;
private configPath: string;
private start: {
hour: number;
minute: number;
} = {
hour: null,
minute: null,
};
private end: {
hour: number;
minute: number;
} = {
hour: null,
minute: null,
};
constructor(Main: Main) {
this._Main = Main;
this.configPath = join(this._Main.dataPath, 'timer.json');
}
getState() {
return {
start: this.start,
end: this.end,
};
}
setStart(data: { hour: number; minute: number }) {
this.start = data;
this._Main.WebServer.socket.emit('timer', this.getState());
this.save();
}
setEnd(data: { hour: number; minute: number }) {
this.end = data;
this._Main.WebServer.socket.emit('timer', this.getState());
this.save();
}
startClock() {
setInterval(() => {
const startTime = new Date();
startTime.setHours(this.start.hour, this.start.minute, 0, 0);
const endTime = new Date();
endTime.setHours(this.end.hour, this.end.minute, 0, 0);
const shouldBeStarted =
startTime <= new Date() && endTime > new Date();
if (shouldBeStarted) {
if (
this._Main.UnityRunner.state !== 'RUNNING' &&
this._Main.UnityRunner.state !== 'STARTING'
) {
console.log(PREFIX, 'Starting Unity');
this._Main.UnityRunner.start(StartOrigin.Timer);
} else if (
this._Main.UnityRunner.startOrigin !== StartOrigin.Timer
) {
console.log(PREFIX, 'Already running');
this._Main.UnityRunner.startOrigin = StartOrigin.Timer;
}
} else {
if (
this._Main.UnityRunner.state === 'RUNNING' &&
this._Main.UnityRunner.startOrigin === StartOrigin.Timer
) {
console.log(PREFIX, 'Stopping Unity');
this._Main.UnityRunner.stop();
}
}
}, 5000);
}
async load() {
console.log(PREFIX, 'Loading timer configuration...');
const exists = await pathExists(this.configPath);
if (!exists) {
this.save();
return this.startClock();
}
const data = await readJSON(this.configPath);
this.start = data.start;
this.end = data.end;
this.startClock();
}
private _save: NodeJS.Timeout;
async save() {
if (this._save) clearTimeout(this._save);
this._save = setTimeout(async () => {
writeFile(
this.configPath,
JSON.stringify({ start: this.start, end: this.end }, null, 4),
);
}, 1000);
}
}

View File

@@ -6,20 +6,26 @@ import {
ConfigurationManager,
} from './Configuration/ConfigurationManager';
import { CameraRunner } from './CameraRunner';
import { UnityRunner } from './Unity/UnityRunner';
import { StartOrigin, UnityRunner } from './Unity/UnityRunner';
import { UnityWebSocket } from './Unity/UnityWebSocket';
import { TwilioHandler } from './Twilio';
import { delay } from './Utils';
import * as ping from 'ping';
import { shutdown } from './Shutdown';
import { Timer } from './Configuration/Timer';
import { Status } from './Status';
import { AudioChecker } from './Audio';
const PREFIX = '[Main]';
export class Main {
dataPath = join(homedir(), 'MorphixProductions', 'NTSHControl');
ConfigurationManager = new ConfigurationManager(this);
Timer = new Timer(this);
Status = new Status(this);
WebServer = new WebServer(this);
Twilio = new TwilioHandler(this);
Audio = new AudioChecker(this);
CameraRunner = new CameraRunner(this);
UnityRunner = new UnityRunner(this);
@@ -35,11 +41,23 @@ export class Main {
await this.WebServer.listen();
await this.Twilio.load();
await this.Audio.waitForDevice();
await this.CameraRunner.connect();
setTimeout(() => {
this.UnityRunner.start();
}, this.Config.unity.executable.startUpDelay ?? 0);
await this.Timer.load();
if ((this.Config.unity.launchOnStartup ?? true) === true)
setTimeout(() => {
this.UnityRunner.start(StartOrigin.Startup);
}, this.Config.unity.executable.startUpDelay ?? 0);
else {
this.UnityRunner.setInfo(
'Not set to launch on startup. Waiting for timer or manual start...',
null,
'STOPPED',
);
}
}
async restart() {
@@ -55,11 +73,11 @@ export class Main {
if (!response.succeed) {
console.error(
'Failed to reboot CameraRunner:',
response.message
response.message,
);
this.Twilio.sendError(
'CameraRunner',
`Failed to reboot CameraRunner: ${response.message}`
`Failed to reboot CameraRunner: ${response.message}`,
);
resolve(false);
} else {
@@ -67,7 +85,7 @@ export class Main {
this.Twilio.sendError('CameraRunner', null);
resolve(true);
}
}
},
);
});
if (!succeed) return;
@@ -75,7 +93,7 @@ export class Main {
await delay(5000);
console.log('Starting UnityRunner...');
await this.UnityRunner.start();
await this.UnityRunner.start(this.UnityRunner.startOrigin);
console.log('Restart complete.');
}

156
src/Status.ts Normal file
View File

@@ -0,0 +1,156 @@
import { Main } from './Main';
import { StartOrigin } from './Unity/UnityRunner';
import { delay } from './Utils';
export class Status {
private _Main: Main;
states: Map<StatusType, StatusEntry> = new Map();
constructor(Main: Main) {
this._Main = Main;
this.ensure();
this.startClock();
}
update(
type: StatusType,
state: State,
message: string,
buttons?: { reboot?: boolean; start?: boolean; stop?: boolean },
) {
this.states.set(type, { state, message, buttons });
this._Main.WebServer.socket.emit('status', this.getState());
}
reboot(type: StatusType) {
switch (type) {
case StatusType.CameraPC: {
this._Main.CameraRunner.sendCommand(
'reboot',
(response: { succeed: boolean; message?: string }) => {
console.log('CameraRunner reboot response:', response);
},
);
}
case StatusType.CameraProcess: {
this._Main.CameraRunner.sendCommand(
'restart',
(response: { succeed: boolean; message?: string }) => {
console.log('CameraRunner restart response:', response);
},
);
}
case StatusType.CameraUnityStream: {
this._Main.CameraRunner.sendCommand(
'restart',
(response: { succeed: boolean; message?: string }) => {
console.log('CameraRunner restart response:', response);
},
);
this._Main.UnityRunner.restart(StartOrigin.Manual);
}
case StatusType.UnityBuild: {
this._Main.UnityRunner.restart(StartOrigin.Manual);
}
case StatusType.ReplayFunction: {
console.log(
'ReplayFunction reboot requested. Not implemented.',
);
}
}
}
start(type: StatusType) {
switch (type) {
case StatusType.UnityBuild: {
this._Main.UnityRunner.requestStart(StartOrigin.Manual);
}
}
}
stop(type: StatusType) {
switch (type) {
case StatusType.UnityBuild: {
this._Main.UnityRunner.requestStop();
}
}
}
getState() {
const state = {};
this.states.forEach((value, key) => {
state[key] = value;
});
return state;
}
private startClock() {
let hadInvalidLastRound: boolean = false;
setInterval(() => {
let hasInvalid = false;
this.states.forEach((state, key) => {
if (state.state === State.Red) hasInvalid = true;
});
if (hasInvalid && !hadInvalidLastRound) {
hadInvalidLastRound = true;
console.warn(
'One ore more status entries are invalid, waiting to see if they recover...',
);
} else if (hasInvalid && hadInvalidLastRound) {
console.error(
'One ore more status entries are still invalid after 5 seconds, restarting system.',
);
this._Main.Twilio.sendError(
'Status',
'One ore more status entries are still invalid after 5 seconds, restarting system.',
);
this._Main.restart();
} else {
this._Main.Twilio.sendError('Status', null);
hadInvalidLastRound = false;
}
}, 5000);
}
private ensure() {
for (const type in StatusType) {
if (!this.states.has(StatusType[type]))
this.states.set(StatusType[type], {
state: State.Gray,
message: 'Loading...',
});
}
}
private doLog() {
console.clear();
this.states.forEach((state, key) => {
console.log(`[${key}] ${state.state} - ${state.message}`);
});
}
}
export interface StatusEntry {
state: State;
message: string;
buttons?: { reboot?: boolean; start?: boolean; stop?: boolean };
}
export enum State {
Green = 'GREEN',
Yellow = 'YELLOW',
Red = 'RED',
Gray = 'GRAY',
}
export enum StatusType {
CameraPC = 'CAMERAPC',
CameraProcess = 'CAMERAPROCESS',
CameraUnityStream = 'CAMERAUNITYSTREAM',
UnityBuild = 'UNITYBUILD',
ReplayFunction = 'REPLAYFUNCTION',
}

View File

@@ -44,7 +44,7 @@ export class TwilioHandler {
this.client = require('twilio')(
this._Main.Config.twilio.accountSid,
this._Main.Config.twilio.authToken
this._Main.Config.twilio.authToken,
);
}
@@ -77,14 +77,13 @@ export class TwilioHandler {
console.log(PREFIX, `Sending to Twilio:\n`, errorMessage);
const promises = this._Main.Config.twilio.toNumbers.map(
(toNumber) => this.sendMessage(toNumber, errorMessage)
(toNumber) => this.sendMessage(toNumber, errorMessage),
);
await Promise.all(promises);
}, this._Main.Config.twilio.aggregateTimeout);
}
sendMessage(to: string, message: string) {
return;
return new Promise<boolean>((resolve) => {
this.client.messages
.create({
@@ -104,4 +103,9 @@ export class TwilioHandler {
}
}
type TwilioCategories = 'CameraRunner' | 'UnityRunner' | 'UnityWebSocket';
type TwilioCategories =
| 'CameraRunner'
| 'UnityRunner'
| 'UnityWebSocket'
| 'Status'
| 'Audio';

View File

@@ -1,7 +1,9 @@
import { pathExistsSync } from 'fs-extra';
import { ChildProcess, exec, spawn } from 'child_process';
import { ChildProcess, spawn } from 'child_process';
import { delay } from '../Utils';
import { Main } from '../Main';
import { ProcessStatusState } from '../CameraRunner';
import { StatusType } from '../Status';
const PREFIX = '[UnityRunner]';
@@ -11,6 +13,8 @@ export class UnityRunner {
private _Main: Main;
state: UnityRunnerState;
startOrigin: StartOrigin;
message?: string = 'Awaiting startup delay...';
error?: string;
@@ -34,11 +38,11 @@ export class UnityRunner {
switch (command) {
case 'restart':
return callback(this.requestRestart());
return callback(this.requestRestart(StartOrigin.Manual));
case 'stop':
return callback(this.requestStop());
case 'start':
return callback(this.requestStart());
return callback(this.requestStart(StartOrigin.Manual));
}
}
@@ -50,7 +54,10 @@ export class UnityRunner {
}, 3000);
}
requestRestart(): { succeed: boolean; message?: string } {
requestRestart(startOrigin: StartOrigin): {
succeed: boolean;
message?: string;
} {
if (this.state !== 'RUNNING')
return {
succeed: false,
@@ -58,7 +65,7 @@ export class UnityRunner {
'Cannot restart when process is not running. It is probably restarting already.',
};
this.restart(true);
this.restart(startOrigin, true);
return { succeed: true };
}
@@ -74,14 +81,17 @@ export class UnityRunner {
return { succeed: true };
}
requestStart(): { succeed: boolean; message?: string } {
requestStart(startOrigin: StartOrigin): {
succeed: boolean;
message?: string;
} {
if (this.state !== 'STOPPED')
return {
succeed: false,
message: 'Cannot start when process is already running.',
};
this.start();
this.start(startOrigin);
return { succeed: true };
}
@@ -92,11 +102,21 @@ export class UnityRunner {
setInfo(
message: string,
error: string,
state: UnityRunnerState = 'PROBLEM'
state: UnityRunnerState = 'PROBLEM',
) {
this.message = message;
this.error = error;
this.state = state;
this._Main.Status.update(
StatusType.UnityBuild,
UnityBuildStateColors[state],
message ?? error ?? '',
{
reboot: state === 'PROBLEM' || state === 'RUNNING',
start: state === 'STOPPED',
stop: state === 'RUNNING',
},
);
this.broadcastState();
if (error != null) this._Main.Twilio.sendError('UnityRunner', error);
@@ -104,7 +124,7 @@ export class UnityRunner {
this.output.current.push(
`[${new Date().toLocaleTimeString('nl-NL')}] [System] [${state}] ${
message ?? error
}`
}`,
);
if (state == 'PROBLEM' || state == 'STOPPED')
@@ -123,7 +143,7 @@ export class UnityRunner {
this.setInfo(
'Requested quit through WebSocket...',
null,
'STARTING'
'STARTING',
);
} else {
this.process.kill('SIGTERM');
@@ -161,7 +181,7 @@ export class UnityRunner {
}
restartTriggered: boolean = false;
async restart(instant: boolean = false) {
async restart(origin: StartOrigin, instant: boolean = false) {
if (this.restartTriggered) return;
this.restartTriggered = true;
@@ -170,6 +190,7 @@ export class UnityRunner {
clearInterval(this.statusClock);
this.startTime = -1;
this.startOrigin = null;
this.broadcastState();
if (!instant) await delay(2000);
@@ -191,10 +212,10 @@ export class UnityRunner {
await delay(10000);
}
await this.start();
await this.start(this.startOrigin);
}
async start() {
async start(origin: StartOrigin) {
if (this.output.current.length > 0) {
this.output.last = [...this.output.current];
this.output.current = [];
@@ -211,7 +232,7 @@ export class UnityRunner {
if (path == null || !pathExistsSync(path)) {
this.setInfo(
'Executable problem',
`Executable path is not set or does not exist: ${path}`
`Executable path is not set or does not exist: ${path}`,
);
return;
}
@@ -221,7 +242,7 @@ export class UnityRunner {
this.setInfo(
'Waiting for CameraRunner to connect...',
null,
'STARTING'
'STARTING',
);
var c = setInterval(() => {
if (this._Main.CameraRunner.state !== 'CONNECTED') return;
@@ -236,7 +257,7 @@ export class UnityRunner {
this.setInfo(
'Waiting for CameraRunner process to start...',
null,
'STARTING'
'STARTING',
);
var c = setInterval(() => {
if (
@@ -259,7 +280,7 @@ export class UnityRunner {
this._Main.Config.unity.executable.arguments,
{
stdio: 'pipe',
}
},
);
this.process.on('exit', (code, signal) => {
@@ -267,13 +288,13 @@ export class UnityRunner {
this.setInfo(
'Process exited',
`Process exited with code ${code} and signal ${signal}`,
'PROBLEM'
'PROBLEM',
);
this.restart();
this.restart(this.startOrigin);
});
this.process.on('error', (err) => {
this.setInfo('Process error', err.message);
this.restart();
this.restart(this.startOrigin);
});
this.process.stdout?.on('data', (data) => {
const lines = data
@@ -283,7 +304,7 @@ export class UnityRunner {
.filter((line) => line.length > 0);
lines.forEach((line) => {
const formattedLine = `[${new Date().toLocaleTimeString(
'nl-NL'
'nl-NL',
)}] [${fileName}] ${line}`;
if (LOG_OUTPUT) console.log(PREFIX, formattedLine);
this.output.current.push(formattedLine);
@@ -297,12 +318,13 @@ export class UnityRunner {
.filter((line) => line.length > 0);
lines.forEach((line) => {
const formattedLine = `[${new Date().toLocaleTimeString(
'nl-NL'
'nl-NL',
)}] [${fileName}] [ERROR] ${line}`;
if (LOG_OUTPUT) console.error(PREFIX, formattedLine);
this.output.current.push(formattedLine);
});
});
this.startOrigin = origin;
this.startStatusClock();
setTimeout(() => {
if (
@@ -340,4 +362,24 @@ interface UnityRunnerStatus {
output: { current: string[]; last: string[] };
}
enum State {
Green = 'GREEN',
Yellow = 'YELLOW',
Red = 'RED',
Gray = 'GRAY',
}
export const UnityBuildStateColors: Record<ProcessStatusState, State> = {
RUNNING: State.Green,
STOPPED: State.Gray,
STARTING: State.Yellow,
PROBLEM: State.Red,
};
export type UnityRunnerState = 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
export enum StartOrigin {
Timer = 'TIMER',
Manual = 'MANUAL',
Startup = 'STARTUP',
}

View File

@@ -1,6 +1,7 @@
import { RawData, WebSocket } from 'ws';
import { Main } from '../Main';
import { delay, ServiceState } from '../Utils';
import { State, StatusType } from '../Status';
const PREFIX = '[Unity]';
export class UnityWebSocket {
@@ -29,6 +30,8 @@ export class UnityWebSocket {
constructor(Main: Main) {
this._Main = Main;
this.updateStatus();
}
handle(command: string, ...args: any[]) {
@@ -74,7 +77,7 @@ export class UnityWebSocket {
this.socket.send(
JSON.stringify({
type: 'quit_application',
})
}),
);
}
@@ -87,7 +90,7 @@ export class UnityWebSocket {
type: 'set_slider_value',
sliderIndex,
sliderValue,
})
}),
);
if (this.parameters.sliders[sliderIndex] == undefined) return;
@@ -104,7 +107,7 @@ export class UnityWebSocket {
type: 'set_advanced_slider_value',
sliderIndex,
sliderValue,
})
}),
);
if (this.parameters.advancedSliders[sliderIndex] == undefined) return;
@@ -120,7 +123,7 @@ export class UnityWebSocket {
JSON.stringify({
type: 'set_out_of_service',
showOutOfService: state,
})
}),
);
this.parameters.outOfService = true;
@@ -130,14 +133,40 @@ export class UnityWebSocket {
broadcastState() {
this._Main.WebServer.socket.emit(
'unityWebSocketState',
this.getState()
this.getState(),
);
}
updateStatus() {
if (this.state != 'CONNECTED') {
this._Main.Status.update(
StatusType.CameraUnityStream,
CameraUnityStateColors[this.state],
this.message,
{
reboot: this.state === 'FAILED',
},
);
} else {
const status = !this.parameters.zedReady
? 'Waiting for ZED stream'
: this.message;
this._Main.Status.update(
StatusType.CameraUnityStream,
this.parameters.zedReady ? State.Green : State.Yellow,
status,
{
reboot: this.parameters.zedReady,
},
);
}
}
setInfo(message: string, error: string, state: ServiceState = 'FAILED') {
this.message = message;
this.error = error;
this.state = state;
this.updateStatus();
this.broadcastState();
if (error != null) this._Main.Twilio.sendError('UnityWebSocket', error);
@@ -185,7 +214,7 @@ export class UnityWebSocket {
? 2
: null,
};
}
},
);
this.parameters.advancedSliders =
message.heartbeat.dataAdvancedSliders.map((slider) => {
@@ -199,12 +228,13 @@ export class UnityWebSocket {
};
});
this.updateStatus();
this.broadcastState();
break;
case 'response_camera_frame':
this._Main.WebServer.Calibration.writeCalibrationImage(
message.imageBase64
message.imageBase64,
);
break;
}
@@ -261,7 +291,7 @@ export class UnityWebSocket {
await delay(1000);
this.socket = new WebSocket(
`ws://${this._Main.Config.unity.webSocket.ip}:${this._Main.Config.unity.webSocket.port}`
`ws://${this._Main.Config.unity.webSocket.ip}:${this._Main.Config.unity.webSocket.port}`,
);
this.socket.on('error', (error) => {
@@ -269,7 +299,7 @@ export class UnityWebSocket {
this.setInfo(
'Connection error',
`Could not connect: ${error.message}`,
'FAILED'
'FAILED',
);
this.reconnect();
});
@@ -288,7 +318,7 @@ export class UnityWebSocket {
this.setInfo(
'Disconnected',
'Unity was disconnected unexpectedly',
'FAILED'
'FAILED',
);
this.reconnect();
});
@@ -300,7 +330,10 @@ export class UnityWebSocket {
private calibrationImageClock: NodeJS.Timeout;
startFetchClocks() {
this.socket.send(
JSON.stringify({ type: 'set_heartbeat_auto_send', autoSend: false })
JSON.stringify({
type: 'set_heartbeat_auto_send',
autoSend: false,
}),
);
this.heartbeatClock = setInterval(() => {
if (
@@ -409,6 +442,13 @@ interface UnitySocketMessageHeartbeat extends UnitySocketMessageBase {
showOutOfService?: boolean;
};
}
export const CameraUnityStateColors: Record<ServiceState, State> = {
CONNECTED: State.Green,
DISCONNECTED: State.Gray,
CONNECTING: State.Yellow,
FAILED: State.Red,
};
interface UnitySocketMessageCameraFrame extends UnitySocketMessageBase {
type: 'response_camera_frame';
imageBase64: string;

View File

@@ -0,0 +1,46 @@
import { Router } from 'express';
import { Main } from '../Main';
import { join } from 'path';
import { pathExistsSync, readdirSync, readFile } from 'fs-extra';
export class ControlRouter {
private _Main: Main;
Router: Router;
path: string;
constructor(Main: Main) {
this._Main = Main;
this.Router = Router();
this.path = join(
__filename,
'..',
'..',
'..',
'frontend',
'views',
'control',
);
this.registerRoutes();
}
async registerRoutes() {
this.Router.get('/', 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);
});
}
}

View File

@@ -20,7 +20,7 @@ export class DashboardRouter {
'..',
'frontend',
'views',
'dashboard'
'dashboard',
);
this.registerRoutes();
@@ -28,21 +28,21 @@ export class DashboardRouter {
async registerRoutes() {
this.Router.get(
['/', '/dashboard', '/calibration', '/cameralogs', '/unitylogs'],
['/dashboard', '/calibration', '/cameralogs', '/unitylogs'],
async (req, res) => {
const htmlContent = await readFile(
join(this.path, 'index.html')
join(this.path, 'index.html'),
);
res.setHeader('Content-Type', 'text/html');
res.send(htmlContent);
}
},
);
this.Router.get('/style.css', async (req, res) => {
this.Router.get('/dashboard/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) => {
this.Router.get('/dashboard/script.js', async (req, res) => {
const scriptContent = await readFile(join(this.path, 'script.js'));
res.setHeader('Content-Type', 'application/javascript');
res.send(scriptContent);

View File

@@ -7,11 +7,14 @@ import { DashboardRouter } from './DashboardRouter';
import { join } from 'path';
import { CalibrationRouter } from './CalibrationRouter';
import { delay } from '../Utils';
import { ControlRouter } from './ControlRouter';
import { StatusType } from '../Status';
const PREFIX = '[WebServer]';
export class WebServer {
private _Main: Main;
Control: ControlRouter;
Dashboard: DashboardRouter;
Calibration: CalibrationRouter;
@@ -23,6 +26,7 @@ export class WebServer {
this._Main = Main;
this.Dashboard = new DashboardRouter(this._Main);
this.Calibration = new CalibrationRouter(this._Main);
this.Control = new ControlRouter(this._Main);
this.prepare();
}
@@ -34,26 +38,29 @@ export class WebServer {
this.app.use(
express.static(
join(__filename, '..', '..', '..', 'frontend', 'static')
)
join(__filename, '..', '..', '..', 'frontend', 'static'),
),
);
this.app.use(this.Dashboard.Router);
this.app.use(this.Control.Router);
this.app.use(this.Calibration.Router);
this.socket.on('connection', (socket) => {
socket.emit('status', this._Main.Status.getState());
socket.emit('timer', this._Main.Timer.getState());
socket.emit(
'cameraRunnerState',
this._Main.CameraRunner.getState()
this._Main.CameraRunner.getState(),
);
socket.emit('unityRunnerState', this._Main.UnityRunner.getStatus());
socket.emit(
'unityWebSocketState',
this._Main.UnityWebSocket.getState()
this._Main.UnityWebSocket.getState(),
);
socket.emit(
'supportNumber',
this._Main.Config.support.telephone.replace('+', '')
this._Main.Config.support.telephone.replace('+', ''),
);
socket.on(
@@ -62,7 +69,7 @@ export class WebServer {
callback: (response: {
succeed: boolean;
message?: string;
}) => void
}) => void,
) => {
if (this._Main.CameraRunner.state !== 'CONNECTED')
return callback({
@@ -73,7 +80,7 @@ export class WebServer {
this._Main.restart();
callback({ succeed: true });
}
},
);
socket.on(
@@ -82,21 +89,52 @@ export class WebServer {
callback: (response: {
succeed: boolean;
message?: string;
}) => void
}) => void,
) => {
await delay(1000);
callback({ succeed: true });
this._Main.shutdown();
}
},
);
socket.on('cameraRunner', (command: string, ...args: any[]) =>
this._Main.CameraRunner.handle(command, ...args)
this._Main.CameraRunner.handle(command, ...args),
);
socket.on('unityRunner', (command: string, ...args: any[]) =>
this._Main.UnityRunner.handle(command, ...args)
this._Main.UnityRunner.handle(command, ...args),
);
socket.on('unityWebSocket', (command: string, ...args: any[]) =>
this._Main.UnityWebSocket.handle(command, ...args)
this._Main.UnityWebSocket.handle(command, ...args),
);
socket.on(
'status',
(
action: 'fullreboot' | 'reboot' | 'start' | 'stop',
type: StatusType,
) => {
switch (action) {
case 'fullreboot':
return this._Main.restart();
case 'reboot':
return this._Main.Status.reboot(type);
case 'start':
return this._Main.Status.start(type);
case 'stop':
return this._Main.Status.stop(type);
}
},
);
socket.on(
'setTimerStart',
(data: { hour: number; minute: number }) => {
this._Main.Timer.setStart(data);
},
);
socket.on(
'setTimerEnd',
(data: { hour: number; minute: number }) => {
this._Main.Timer.setEnd(data);
},
);
});
}
@@ -107,7 +145,7 @@ export class WebServer {
this.httpServer.listen(port, () => {
console.log(
PREFIX,
`Listening on port http://127.0.0.1:${port}`
`Listening on port http://127.0.0.1:${port}`,
);
resolve();
});