Skip to content

Commit

Permalink
Publish 1.0.1 Version
Browse files Browse the repository at this point in the history
  • Loading branch information
pandadtdyy authored Jun 13, 2024
2 parents a113dbf + 5b358ec commit 936baf3
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 70 deletions.
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

A tool for CN XCPC contests

- 全平台远程代码打印
- 支持封榜期间发放鼓励气球的小票机
- 代码打印和小票机打印(全平台支持)
- 支持封榜期间发放鼓励气球
- 支持连接 DOMjudge 与 Hydro 系统,同时亦可独立运行
- 选手机赛时数据监控与屏幕监控

TODO Features:

- [ ] 更好的选手机座位绑定
- [ ] 支持多平台参赛系统
- [ ] Windows 小票机支持
- [ ] 优化 UI 顺畅度
- [ ] 使用 WebSocket 返回指令执行情况
- [ ] 支持全考场监视
Expand Down Expand Up @@ -51,7 +50,7 @@ const serverSchema = Schema.intersect([
Schema.const('hydro'),
] as const).required(), // 赛事系统类型
server: Schema.string().role('url').required(), // 赛事系统地址
contestId: Schema.string(), // 赛事ID,如无则自动获取(Domjudge),hydro 请使用 domainId/contestId 作为ID
contestId: Schema.string(), // 赛事ID,如无则自动获取(DOMjudge),hydro 请使用 domainId/contestId 作为ID
token: Schema.string(), // 赛事系统 Token 如无可使用用户名密码登录
username: Schema.string(), // 赛事系统用户名
password: Schema.string(), // 赛事系统密码
Expand All @@ -72,7 +71,7 @@ const serverSchema = Schema.intersect([
`print [file] [original] [language] [username] [teamname] [teamid] [location]` 为打印命令,其中 `file` 为代码文件路径,`original` 为原文件名,`language` 为语言,`username` 为用户名,`teamname` 为队伍名,`teamid` 为队伍ID,`location` 为选手位置。

#### Balloon
服务支持 `Fetch Mode` 下的气球推送,支持 `Domjudge``Hydro` 系统,支持 `Domjudge``Hydro` 系统的 `Balloon` 推送,同时若赛事在封榜后仍然推送气球,则支持自定义鼓励气球数,高于设定值则不推送,为所有队伍打造优质赛场体验。
服务支持 `Fetch Mode` 下的气球推送,支持 `DOMjudge``Hydro` 系统,支持 `DOMjudge``Hydro` 系统的 `Balloon` 推送,同时若赛事在封榜后仍然推送气球,则支持自定义鼓励气球数,高于设定值则不推送,为所有队伍打造优质赛场体验。

#### Monitor
服务支持监控选手机情况和监控服务器桌面,如您需要选手机监控,可通过设置 Systemd 定时执行任务等多种方式定时执行 `monitor` 命令,如需监控服务器桌面,请在选手机上提前运行 `vlc-camera``vlc-desktop` 服务, CAICPC 镜像已经内置了这三两个服务,您只需在选手机上运行即可,如您为自己的镜像,可从 `https://github.com/hydro-dev/xcpc-tools/blob/main/scripts/monitor` 下载 `monitor` 服务。
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"packages/*"
],
"scripts": {
"start:client": "node -r ./register.js packages/server/index.ts --client --debug",
"start:server": "node -r ./register.js packages/server/index.ts --debug",
"dev:client": "node -r ./register.js packages/server/index.ts --client --debug",
"dev:server": "node -r ./register.js packages/server/index.ts --debug",
"lint": "eslint packages --ext js,ts,tsx,jsx",
"build": "yarn build:ui:prod && node -r ./register.js build.ts",
"build": "yarn build:ui && node -r ./register.js build.ts",
"build:server": "node -r ./register.js build.ts",
"build:pkg": "yarn build:ui:prod && node -r ./register.js build.ts && pkg dist/xcpc-tools.js --targets linux,macos,win --out-path dist/pkg"
"build:pkg": "yarn build:ui && node -r ./register.js build.ts && pkg dist/xcpc-tools.js --targets linux,macos,win --out-path dist/pkg"
},
"devDependencies": {
"@expo-google-fonts/noto-color-emoji": "^0.2.3",
Expand Down
36 changes: 4 additions & 32 deletions packages/server/client/balloon.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
/* eslint-disable no-await-in-loop */
import path from 'path';
import EscPosEncoder from '@freedom_sky/esc-pos-encoder';
import superagent from 'superagent';
import { config } from '../config';
import {
convertToChinese,
fs, Logger, sleep,
checkReceiptStatus, convertToChinese, Logger, receiptPrint, sleep,
} from '../utils';

const encoder = new EscPosEncoder();
Expand Down Expand Up @@ -72,30 +70,6 @@ const logger = new Logger('balloon');
let timer = null;
let printer = null;

async function getReceiptStatus(receipt) {
const lp = receipt.split('/').pop();
const oldPrinter = printer;
printer = {
printer: receipt,
info: fs.readFileSync(`/sys/class/usbmisc/${lp}/device/ieee1284_id`, 'utf8').trim(),
};
if (!oldPrinter || oldPrinter.info === printer.info) return;
logger.info('Printer changed:', printer.printer, printer.info);
const usbDevices = fs.readdirSync('/dev/usb');
for (const f of usbDevices) {
if (f.startsWith('lp')) {
const lpid = fs.readFileSync(`/sys/class/usbmisc/${f}/device/ieee1284_id`, 'utf8').trim();
if (lpid === oldPrinter.info) {
logger.info('Printer found:', f, ':', lpid);
oldPrinter.printer = `/dev/usb/${f}`;
printer = oldPrinter;
break;
}
}
}
if (oldPrinter.info !== printer.info) throw Error('Printer not found, please check the printer connection.');
}

async function printBalloon(doc, lang) {
const bReceipt = receiptText(
doc.balloonid,
Expand All @@ -107,10 +81,8 @@ async function printBalloon(doc, lang) {
doc.total ? Object.keys(doc.total).map((k) => `- ${k}: ${doc.total[k].color}`).join('\n') : 'N/A',
lang,
);
if (printer) {
await getReceiptStatus(printer.printer);
fs.writeFileSync(path.resolve(printer.printer), bReceipt);
}
printer = await checkReceiptStatus(printer);
await receiptPrint(printer, bReceipt);
}

async function fetchTask(c) {
Expand All @@ -137,7 +109,7 @@ async function fetchTask(c) {
}

export async function apply() {
await getReceiptStatus(config.balloon);
printer = config.balloon;
if (config.token && config.server && config.balloon) await fetchTask(config);
else logger.error('Config not found, please check the config.yaml');
}
17 changes: 5 additions & 12 deletions packages/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';
import Schema from 'schemastery';
import { version } from './package.json';
import {
checkReceiptPrinter,
fs, getPrinters, Logger, yaml,
} from './utils';

Expand Down Expand Up @@ -31,24 +32,15 @@ password:
`;
let printers = [];
if (isClient) {
printers = await getPrinters().then((r) => r.map((p) => p.printer)).catch(() => []);
printers = await getPrinters(true).catch(() => []);
logger.info(printers.length, 'printers found:', printers.join(', '));
if (process.platform === 'linux') {
const usbDevices = fs.readdirSync('/dev/usb');
for (const f of usbDevices) {
if (f.startsWith('lp')) {
const lpid = fs.readFileSync(`/sys/class/usbmisc/${f}/device/ieee1284_id`, 'utf8').trim();
logger.info(`USB Printer ${f} found: ${lpid}`);
logger.info(`If you want to use this printer for balloon print, please set balloon: /dev/usb/${f} in config.yaml.`);
}
}
} else logger.info('If you want to use balloon client, please run this on linux.');
await checkReceiptPrinter(printers);
}
const clientConfigDefault = yaml.dump({
server: '',
balloon: '',
balloonLang: 'zh',
printers,
printers: printers.map((p) => p.printer),
token: '',
});
fs.writeFileSync(configPath, isClient ? clientConfigDefault : serverConfigDefault);
Expand Down Expand Up @@ -81,6 +73,7 @@ const serverSchema = Schema.intersect([
token: Schema.string(),
username: Schema.string(),
password: Schema.string(),
freezeEncourage: Schema.number().default(0),
}).description('Fetcher Config'),
Schema.object({
type: Schema.const('server').required(),
Expand Down
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/xcpc-tools",
"version": "1.0.0",
"version": "1.0.1",
"description": "A tools for XCPC contests",
"main": "index.ts",
"repository": "https://github.com/Hydro-dev/xcpc-tools",
Expand Down
102 changes: 94 additions & 8 deletions packages/server/service/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Context, Service } from 'cordis';
import superagent from 'superagent';
import { config } from '../config';
import { Logger } from '../utils';
import { Logger, mongoId } from '../utils';

const logger = new Logger('fetcher');
const fetch = (url: string, type: 'get' | 'post' = 'get') => superagent[type](new URL(url, config.server).toString())
Expand All @@ -11,10 +11,10 @@ export interface IBasicFetcher {
contest: Record<string, any>
cron(): Promise<void>
contestInfo(): Promise<boolean>
getToken?(username: string, password: string): Promise<void>
teamInfo?(): Promise<void>
balloonInfo?(all: boolean): Promise<void>
setBalloonDone?(bid: string): Promise<void>
getToken(username: string, password: string): Promise<void>
teamInfo(): Promise<void>
balloonInfo(all: boolean): Promise<void>
setBalloonDone(bid: string): Promise<void>
}
class BasicFetcher extends Service implements IBasicFetcher {
contest: any;
Expand Down Expand Up @@ -60,7 +60,7 @@ class BasicFetcher extends Service implements IBasicFetcher {
}
}

class DomJudgeFetcher extends BasicFetcher {
class DOMjudgeFetcher extends BasicFetcher {
async contestInfo() {
let contest;
if (!config.contestId) {
Expand Down Expand Up @@ -141,10 +141,96 @@ class DomJudgeFetcher extends BasicFetcher {
}
}

class HydroFetcher extends BasicFetcher {
async contestInfo() {
const ids = config.contestId.split('/');
const [domainId, contestId] = ids.length === 2 ? ids : ['system', config.contestId];
const { body } = await fetch(`/d/${domainId}/contest/${contestId}`);
if (!body || !body.tdoc) {
logger.error('Contest not found');
return false;
}
const contest = body.tdoc;
contest.freeze_time = contest.lockAt;
const old = this?.contest?._id;
this.contest = {
info: contest, id: contest._id, name: contest.title, domainId,
};
logger.info(`Connected to ${contest.name}(id=${contest.id})`);
return old === this.contest.id;
}

async getToken(username, password) {
const res = await fetch('/login', 'post').send({ uname: username, password, rememberme: 'on' })
.redirects(0).ok((i) => i.status === 302);
if (!res) throw new Error('Failed to get token');
config.token = `Bearer ${res.header['set-cookie'][0].split(';')[0].split('=')[1]}`;
}

async teamInfo() {
const { body } = await fetch(`/d/${this.contest.domainId}/contest/${this.contest.id}/user`);
if (!body || !body.length) return;
const teams = body.tsdocs.filter((t) => body.udict[t.uid]).map((t) => (body.udict[t.uid]));
for (const team of teams) {
await this.ctx.db.teams.update({ id: team._id }, { $set: team }, { upsert: true });
}
logger.debug(`Found ${teams.length} teams`);
}

async balloonInfo(all) {
if (all) logger.info('Sync all balloons...');
const { body } = await fetch(`/d/${this.contest.domainId}/contest/${this.contest.id}/balloon?todo=${all ? 'false' : 'true'}`);
if (!body || !body.length) return;
const balloons = body;
for (const balloon of balloons) {
const teamTotal = await this.ctx.db.balloon.find({ teamid: balloon.teamid, time: { $lt: (balloon.time * 1000).toFixed(0) } });
const encourage = teamTotal.length < (config.freezeEncourage ?? 0);
const totalDict = {};
for (const t of teamTotal) {
totalDict[t.problem] = t.contestproblem;
}
const shouldPrint = this.contest.info.freeze_time ? (balloon.time * 1000) < this.contest.info.freeze_time || encourage : true;
if (!shouldPrint && !balloon.done) await this.setBalloonDone(balloon.balloonid);
const contestproblem = {
id: String.fromCharCode(this.contest.pids.indexOf(balloon.pid) + 65),
name: body.pdict[balloon.pid].title,
rgb: this.contest.balloon[balloon.pid].color,
color: this.contest.balloon[balloon.pid].name,
};
await this.ctx.db.balloon.update({ balloonid: balloon.balloonid }, {
$set: {
balloonid: balloon._id,
time: mongoId(balloon._id).timestamp,
problem: contestproblem.id,
contestproblem,
team: body.udict[balloon.uid].displayName,
teamid: balloon.uid,
location: body.udict[balloon.uid].studentId,
affiliation: body.udict[balloon.uid].school,
awards: balloon.first ? 'First of Problem' : (
this.contest.info.freeze_time && (balloon.time * 1000) > this.contest.info.freeze_time
&& encourage ? 'Encourage Balloon' : ''
),
done: balloon.sent,
total: totalDict,
printDone: balloon.done ? 1 : 0,
shouldPrint,
},
}, { upsert: true });
}
logger.debug(`Found ${balloons.length} balloons`);
}

async setBalloonDone(bid) {
await fetch(`/d/${this.contest.domainId}/contest/${this.contest.id}/balloon`, 'post').send({ balloon: bid });
logger.debug(`Balloon ${bid} set done`);
}
}

const fetcherList = {
server: BasicFetcher,
domjudge: DomJudgeFetcher,
hydro: BasicFetcher, // TODO: HydroFetcher
domjudge: DOMjudgeFetcher,
hydro: HydroFetcher,
};

export async function apply(ctx) {
Expand Down
12 changes: 12 additions & 0 deletions packages/server/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export function sleep(timeout: number) {
});
}

// https://github.com/andrasq/node-mongoid-js/blob/master/mongoid.js
export function mongoId(idstring: string) {
if (typeof idstring !== 'string') idstring = String(idstring);
return {
timestamp: parseInt(idstring.slice(0, 0 + 8), 16),
machineid: parseInt(idstring.slice(8, 8 + 6), 16),
pid: parseInt(idstring.slice(14, 14 + 4), 16),
sequence: parseInt(idstring.slice(18, 18 + 6), 16),
};
}

export * as fs from 'fs-extra';
export * as yaml from 'js-yaml';
export { Logger };
Expand All @@ -102,3 +113,4 @@ export function decodeBinary(file: string) {
export * from './commandRunner';
export * from './printers';
export * from './color';
export * from './receipt';
6 changes: 3 additions & 3 deletions packages/server/utils/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ export function initWinPrinter() {
});
}

const windowsPrinterStatus = {
export const windowsPrinterStatus = {
3: 'idle',
4: 'printing',
};

export async function getPrinters(): Promise<Printer[]> {
export async function getPrinters(raw = false): Promise<object[]> {
if (process.platform === 'win32') {
const winprinters = await wingetPrinters();
return winprinters.filter((p: any) => p.DeviceID).map((p: any) => ({
return raw ? winprinters : winprinters.filter((p: any) => p.DeviceID).map((p: any) => ({
printer: p.DeviceID,
description: p.Caption,
status: windowsPrinterStatus[p.PrinterStatus] ? windowsPrinterStatus[p.PrinterStatus] : 'unknown',
Expand Down
Loading

0 comments on commit 936baf3

Please sign in to comment.