wise-eyes-core
Version:
Web server to monitor the status of owlcms
453 lines (399 loc) • 13.2 kB
text/typescript
import type Express from 'express';
import type {
AthleteState,
} from './athlete';
import type {
BreakType,
CeremonyType,
FopState,
Mode,
OwlcmsLiftType,
PlatformState,
RecordKind,
} from './platform';
import {
klona,
} from 'klona/json';
import cors from 'cors';
import equal from 'deep-equal';
import express from 'express';
import expressWs from 'express-ws';
import Platform from './platform';
import WebSocket from 'ws';
type BooleanString =
| 'false'
| 'true';
interface DecisionBody {
d1?: BooleanString;
d2?: BooleanString;
d3?: BooleanString;
down?: BooleanString;
decisionEventType:
| 'DOWN_SIGNAL'
| 'FULL_DECISION'
| 'JURY_DECISION'
| 'RESET'
| 'START_DELIBERATION';
fop: string;
fopState: FopState;
juryDecision: JuryDecision;
juryReversal: BooleanString;
mode: Mode;
recordKind: RecordKind;
waitForAnnouncer: BooleanString;
}
type JuryDecision =
| 'BAD_LIFT'
| 'GOOD_LIFT';
type PlatformCallback = (platform: Platform) => void;
interface Request<Body> extends Express.Request {
body: Body;
}
interface TimerBody {
athleteMillisRemaining?: number
athleteTimerEventType?:
| 'SetTime'
| 'StartTime'
| 'StopTime';
break: BooleanString;
breakMillisRemaining?: number;
breakTimerEventType?:
| 'BreakPaused'
| 'BreakSet'
| 'BreakStarted';
breakType?: BreakType;
ceremonyType?: CeremonyType;
fopName: string;
fopState: FopState;
indefiniteBreak: BooleanString;
mode: Mode;
}
interface UpdateBody {
attemptNumber: string;
breakType?: BreakType;
ceremonyType?: CeremonyType;
fop: string;
fopState: FopState;
groupAthletes: string;
groupDescription: string;
groupInfo: string;
groupName: string;
leaders: string;
liftingOrderAthletes: string;
liftTypeKey: OwlcmsLiftType;
liftType: string;
mode: Mode;
recordKind: RecordKind;
records: string;
startNumber: string;
translationMap: string;
}
export default function createApp({
debug = false,
}: {
debug?: boolean;
} = {}): Express.Application {
const app = express();
const appWs = expressWs(app);
const socketMaps = {
liftingOrder: new Map<string, Set<WebSocket>>(),
status: new Map<string, Set<WebSocket>>(),
};
function broadcastLiftingOrder(
platformName: string,
liftingOrder: AthleteState[]
): void {
socketMaps.liftingOrder.get(platformName)?.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
client.send(JSON.stringify(liftingOrder));
});
}
function broadcastStatus(state: PlatformState): void {
socketMaps.status.get(state.name)?.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
client.send(JSON.stringify(state));
});
}
function withPlatformForClient(
request: express.Request,
callback: PlatformCallback
): void {
const platform = Platform.getPlatform(request.params.platform, {
noPersist: true,
});
callback(platform);
}
function withPlatformForServer(
platformName: string,
callback: PlatformCallback
): void {
const platform = Platform.getPlatform(platformName);
const prevState = klona(platform.getState());
const prevLiftingOrder = klona(
platform.getLiftingOrder()
.map((athlete) => athlete.getState())
);
callback(platform);
const currentState = platform.getState();
if (!equal(prevState, currentState)) {
broadcastStatus(currentState);
}
const currentLiftingOrder = platform.getLiftingOrder()
.map((athlete) => athlete.getState());
if (!equal(prevLiftingOrder, currentLiftingOrder)) {
broadcastLiftingOrder(currentState.name, currentLiftingOrder);
}
}
app.use(cors());
app.use(express.urlencoded({
extended: true,
}));
app.post('/decision', (request: Request<DecisionBody>, response) => {
response.end();
if (debug) {
console.log('/decision');
console.log(request.body);
}
const {
d1,
d2,
d3,
decisionEventType: eventType,
down,
fop,
fopState,
juryDecision,
juryReversal,
mode,
recordKind,
waitForAnnouncer,
} = request.body;
withPlatformForServer(fop, (platform) => {
let handled = true;
switch (eventType) {
case 'DOWN_SIGNAL':
platform.setDownSignal(down === 'true');
break;
case 'FULL_DECISION':
platform.setDecisions({
centerReferee: !d2
? null
: d2 === 'true'
? 'good'
: 'bad',
leftReferee: !d1
? null
: d1 === 'true'
? 'good'
: 'bad',
rightReferee: !d3
? null
: d3 === 'true'
? 'good'
: 'bad',
});
break;
case 'JURY_DECISION':
if (waitForAnnouncer === 'true') {
return;
}
platform.setJuryDecision({
decision: juryDecision === 'GOOD_LIFT'
? 'good'
: 'bad',
reversal: juryReversal === 'true',
});
break;
case 'RESET':
platform.resetDecisions();
break;
case 'START_DELIBERATION':
// Do nothing. This will be handled in `/update`
handled = false;
break;
default:
handled = false;
if (debug) {
console.log(`!! UNHANDLED DECISION EVENT decisionEventType=${eventType}`);
}
}
if (handled) {
platform.setFopState(fopState);
platform.setMode(mode);
platform.setRecordKind(recordKind);
}
});
});
app.post('/timer', (request: Request<TimerBody>, response) => {
response.end();
if (debug) {
console.log('/timer');
console.log(request.body);
}
const {
athleteMillisRemaining,
athleteTimerEventType,
breakTimerEventType,
breakMillisRemaining,
breakType,
ceremonyType,
fopName,
fopState,
indefiniteBreak,
mode,
} = request.body;
withPlatformForServer(fopName, (platform) => {
platform.setBreakType(breakType || null);
platform.setCeremonyType(ceremonyType || null);
platform.setFopState(fopState);
platform.setMode(mode);
if (breakTimerEventType) {
// When a break ends, we will not receive `indefiniteBreak`, but we
// can consider the end of a break to always considered indefinite since
// we're waiting for the next part of the competition to start with no
// running clock.
const isIndefinite = !indefiniteBreak || indefiniteBreak === 'true'
platform.getBreakClock().update({
isStopped: breakTimerEventType !== 'BreakStarted',
milliseconds: isIndefinite
? Number.POSITIVE_INFINITY
: breakMillisRemaining as number,
});
}
if (athleteTimerEventType) {
platform.getAthleteClock().update({
isStopped: athleteTimerEventType !== 'StartTime',
milliseconds: athleteMillisRemaining as number,
});
}
});
});
app.post('/update', (request: Request<UpdateBody>, response) => {
response.end();
if (debug) {
console.log('/update');
(({
/* eslint-disable @typescript-eslint/no-unused-vars */
groupAthletes,
leaders,
liftingOrderAthletes,
translationMap,
/* eslint-enable @typescript-eslint/no-unused-vars */
...params
}) => {
console.log(
Object.fromEntries(
Object.entries(params)
.sort((a, b) => a[0].localeCompare(b[0]))
)
);
})(request.body);
}
const {
breakType,
ceremonyType,
fop,
fopState,
groupDescription,
groupInfo,
groupName,
leaders,
liftingOrderAthletes,
liftType,
liftTypeKey,
mode,
recordKind,
records,
startNumber,
} = request.body;
withPlatformForServer(fop, (platform) => {
platform.setBreakType(breakType || null);
platform.setCeremonyType(ceremonyType || null);
platform.setFopState(fopState);
platform.setLeaders(leaders ? JSON.parse(leaders) : []);
platform.setLiftType({
key: liftTypeKey,
name: liftType,
});
platform.setMode(mode);
platform.setRecordKind(recordKind);
platform.setRecords(records ? JSON.parse(records) : null);
platform.setSession({
description: groupDescription,
info: groupInfo,
name: groupName,
});
// TODO: determine when this is undefined
platform.updateAthletes(JSON.parse(liftingOrderAthletes));
platform.setCurrentAthlete(parseInt(startNumber));
});
});
app.get('/', (_request, response) => {
response.json({
platforms: Platform.getPlatforms(),
});
});
app.get('/platform/:platform/athlete-clock', (request, response) => {
withPlatformForClient(request, (platform) => {
response.json([platform.getAthleteClock().getState()]);
});
});
app.get('/platform/:platform/break-clock', (request, response) => {
withPlatformForClient(request, (platform) => {
response.json([platform.getBreakClock().getState()]);
});
});
app.get('/platform/:platform/current-athlete', (request, response) => {
withPlatformForClient(request, (platform) => {
response.json([{
athlete: platform.getCurrentAthlete()?.getState(),
clock: platform.getAthleteClock()?.getState(),
}]);
});
});
app.get('/platform/:platform/lifting-order', (request, response) => {
withPlatformForClient(request, (platform) => {
response.json(
platform.getLiftingOrder()
.map((athlete) => athlete.getState())
);
});
});
app.get('/platform/:platform/status', (request, response) => {
withPlatformForClient(request, (platform) => {
response.json([platform.getState()]);
});
});
appWs.app.ws('/ws/platform/:platform/lifting-order', (client, request) => {
const platformName = request.params.platform;
let clients = socketMaps.liftingOrder.get(platformName);
if (!clients) {
clients = new Set();
socketMaps.liftingOrder.set(platformName, clients);
}
clients.add(client);
withPlatformForClient(request, (platform) => {
client.send(JSON.stringify(
platform.getLiftingOrder()
.map((athlete) => athlete.getState())
));
});
});
appWs.app.ws('/ws/platform/:platform/status', (client, request) => {
const platformName = request.params.platform;
let clients = socketMaps.status.get(platformName);
if (!clients) {
clients = new Set();
socketMaps.status.set(platformName, clients);
}
clients.add(client);
withPlatformForClient(request, (platform) => {
client.send(JSON.stringify(platform.getState()));
});
});
return app;
}