diff --git a/docs/MXW01_PRINTING.md b/docs/MXW01_PRINTING.md new file mode 100644 index 0000000..4045c2b --- /dev/null +++ b/docs/MXW01_PRINTING.md @@ -0,0 +1,47 @@ +# Печать чеков команды на MXW01 + +Печать работает только на локальной площадке (`APP_MODE=venue`). На вкладке +«Команды» выберите «Печать чека» → проверьте предпросмотр → нажмите «Печатать». +Firefox и Chrome используют один серверный API; Web Bluetooth и настройки +браузера не нужны. Сервер берёт название и пароль команды из базы, а адрес QR — +из `APP_BASE_URL` площадки. + +## Настройка площадки на Linux + +1. Сервер venue и MXW01 должны находиться рядом на одном Linux-компьютере + с включённым Bluetooth. Установите термобумагу и включите принтер. +2. В локальном `venue/.env` задайте `MXW01_ADDRESS=`. + Адрес текущего MXW01 уже настроен локально; `.env` не включается в Git. +3. Соберите комплект существующей командой `make build`, затем запустите + `make venue`. Служба `receipt_printer` запускается из того же Go-образа. +4. Если приложение телефона подключено к принтеру, закройте его. + +Служба печати использует прямое Bluetooth LE-соединение. Её контейнер работает +с `network_mode: host` и `NET_RAW`, без `privileged`, доступа к базе и TCP-порта. +API передаёт растровые данные через закрытый Unix-сокет в общем Docker-volume. +Остальные контейнеры сохраняют существующую сеть. Python, Bleak и BlueZ D-Bus +для печати не нужны. + +Без `MXW01_ADDRESS` игра продолжает работать; при попытке печати сервер сообщает, +что принтер не настроен. На публичном сервере кнопки печати нет, и API её запрещает. + +## Поведение и проверка + +- За один запрос печатается один чек шириной 384 пикселя. Одновременный запрос + получает сообщение «принтер занят»; постоянной очереди нет. +- Сервер проверяет бумагу и состояние MXW01, затем ждёт подтверждение завершения + и проверяет контрольную сумму изображения. Общий таймаут — 110 секунд. +- Длинное название переносится. Неподдерживаемые символы, слишком длинная ссылка + или чек выше 1024 пикселей отклоняются до печати. +- После разрыва связи или таймаута сначала проверьте бумажный чек: он мог уже + выйти. Автоматической повторной печати нет. После устранения проблемы повторите + печать вручную; спящий принтер разбудите кнопкой. +- Перед игрой напечатайте две разные команды, проверьте названия и отсканируйте + QR телефоном в Wi-Fi площадки. Ссылка должна открывать напечатанную команду. + +Чек содержит ссылку доступа команды; передавайте его участникам этой команды. +PNG-предпросмотр требует авторизации и отдаётся с `Cache-Control: no-store`. + +Протокол проверен на текущем MXW01: фактический ответ состояния содержит +10 байт, а ответ завершения — подтверждение и две копии CRC изображения. +Источник описания формата: [измерения протокола MXW01](https://github.com/eliorpom-cmd/print-on-my-desk/blob/main/docs/09-protocol.md). diff --git a/proto/main.proto b/proto/main.proto index 5008508..dee482e 100644 --- a/proto/main.proto +++ b/proto/main.proto @@ -915,11 +915,27 @@ service EveningDetectiveServer { rpc DownloadGamePrintMaterials(GetGameReq) returns (google.api.HttpBody) { option (google.api.http) = {get: "/api/games/{ID}/print-materials"}; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Игры"; + tags : "Игры"; summary: "Скачать материалы игры для печати ZIP-архивом"; }; } + rpc PreviewTeamReceipt(TeamReceiptReq) returns (google.api.HttpBody) { + option (google.api.http) = {get: "/api/games/{gameID}/teams/{teamID}/receipt.png"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Игры"; + summary: "Предпросмотр чека команды на локальной площадке"; + }; + } + + rpc PrintTeamReceipt(TeamReceiptReq) returns (PrintTeamReceiptRsp) { + option (google.api.http) = {post: "/api/games/{gameID}/teams/{teamID}/receipt/print" body: "*"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Игры"; + summary: "Напечатать один чек команды на MXW01 локальной площадки"; + }; + } + rpc DownloadTeamQR(GetGameReq) returns (google.api.HttpBody) { option (google.api.http) = { get: "/api/games/{ID}/teams/qr.pdf" @@ -2409,13 +2425,13 @@ message GameResultsPackageRsp { } message DeviceHeartbeatReq { - string deviceID = 1; - string connectionID = 2; - optional int32 organizerGameID = 3; + string deviceID = 1; + string connectionID = 2; + optional int32 organizerGameID = 3; optional string teamID = 4; - string password = 5; - string screenID = 6; - string accessToken = 7; + string password = 5; + string screenID = 6; + string accessToken = 7; } message DeviceHeartbeatRsp { @@ -2428,7 +2444,7 @@ message GetDevicesReq { optional int32 gameID = 1; } message GetDevicesRsp { repeated VenueDevice devices = 1; google.protobuf.Timestamp serverTime = 2; - VenueWiFi wifi = 3; // Entire venue, independent of the game filter. + VenueWiFi wifi = 3; // Entire venue, independent of the game filter. } message VenueDevice { @@ -2453,16 +2469,23 @@ message DeviceConnection { } message VenueWiFi { - bool enabled = 1; + bool enabled = 1; google.protobuf.Timestamp collectedAt = 2; - bool stale = 3; - bool hasError = 4; - repeated WiFiClient clients = 5; + bool stale = 3; + bool hasError = 4; + repeated WiFiClient clients = 5; } message WiFiClient { - string IP = 1; - string MAC = 2; - string band = 3; + string IP = 1; + string MAC = 2; + string band = 3; optional int32 signal = 4; // dBm; absent when the router does not report it. } + +message TeamReceiptReq { + int32 gameID = 1; + string teamID = 2; +} + +message PrintTeamReceiptRsp {} diff --git a/src/api/binary_client.ts b/src/api/binary_client.ts index 36089b5..28dd677 100644 --- a/src/api/binary_client.ts +++ b/src/api/binary_client.ts @@ -61,6 +61,13 @@ function buildBinaryClient( return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`) } + function downloadTeamReceipt(gameID: number, teamID: string): Promise { + return download( + `api/games/${gameID}/teams/${encodeURIComponent(teamID)}/receipt.png`, + 'receipt.png', + ) + } + function downloadGamePrintMaterials(id: number): Promise { return download(`api/games/${id}/print-materials`, `game-${id}-print-materials.zip`) } @@ -136,6 +143,7 @@ function buildBinaryClient( return { downloadScenarioArchive, downloadTeamQR, + downloadTeamReceipt, downloadGamePrintMaterials, uploadScenarioArchive, downloadGamePackage, diff --git a/src/api/generated/crabs/evening_detective_server/index.ts b/src/api/generated/crabs/evening_detective_server/index.ts index 803004a..0c6c49f 100644 --- a/src/api/generated/crabs/evening_detective_server/index.ts +++ b/src/api/generated/crabs/evening_detective_server/index.ts @@ -1342,6 +1342,14 @@ export type WiFiClient = { signal?: number; }; +export type TeamReceiptReq = { + gameID: number | undefined; + teamID: string | undefined; +}; + +export type PrintTeamReceiptRsp = { +}; + export interface EveningDetectiveServer { DeviceHeartbeat(request: DeviceHeartbeatReq): Promise; GetDevices(request: GetDevicesReq): Promise; @@ -1441,6 +1449,8 @@ export interface EveningDetectiveServer { SetScreenPlanStep(request: SetScreenPlanStepReq): Promise; GetScreenResults(request: GetScreenResultsReq): Promise; DownloadGamePrintMaterials(request: GetGameReq): Promise; + PreviewTeamReceipt(request: TeamReceiptReq): Promise; + PrintTeamReceipt(request: TeamReceiptReq): Promise; DownloadTeamQR(request: GetGameReq): Promise; GetGameAnswers(request: GetGameAnswersReq): Promise; GetGameResults(request: GetGameResultsReq): Promise; @@ -3126,6 +3136,52 @@ export function createEveningDetectiveServerClient( method: "DownloadGamePrintMaterials", }) as Promise; }, + PreviewTeamReceipt(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.gameID) { + throw new Error("missing required field request.gameID"); + } + if (!request.teamID) { + throw new Error("missing required field request.teamID"); + } + const path = `api/games/${request.gameID}/teams/${request.teamID}/receipt.png`; // eslint-disable-line quotes + const body = null; + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "GET", + body, + }, { + service: "EveningDetectiveServer", + method: "PreviewTeamReceipt", + }) as Promise; + }, + PrintTeamReceipt(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.gameID) { + throw new Error("missing required field request.gameID"); + } + if (!request.teamID) { + throw new Error("missing required field request.teamID"); + } + const path = `api/games/${request.gameID}/teams/${request.teamID}/receipt/print`; // eslint-disable-line quotes + const body = JSON.stringify(request); + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "POST", + body, + }, { + service: "EveningDetectiveServer", + method: "PrintTeamReceipt", + }) as Promise; + }, DownloadTeamQR(request) { // eslint-disable-line @typescript-eslint/no-unused-vars if (!request.ID) { throw new Error("missing required field request.ID"); diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 8d89b36..27272ec 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -50,6 +50,7 @@ import GameScreensPanel from '@/components/GameScreensPanel.vue' import GameStatePanel from '@/components/GameStatePanel.vue' import GameTeamsPanel from '@/components/GameTeamsPanel.vue' import HeaderMenu from '@/components/HeaderMenu.vue' +import TeamReceiptModal from '@/components/TeamReceiptModal.vue' import TicketCheckModal from '@/components/TicketCheckModal.vue' import UiBlock from '@/components/UiBlock.vue' import { useDeviceContext } from '@/composables/useDeviceContext' @@ -108,6 +109,13 @@ const deletedGameName = ref('') const showQR = ref(false) const urlQR = ref('') const titleQR = ref('') +const showTeamReceipt = ref(false) +const teamReceipt = ref({ name: '', teamID: '' }) +const isPrintingReceipt = ref(false) +const isPreparingReceipt = ref(false) +const receiptPreview = ref('') +const receiptPreparationError = ref('') +let receiptPreparationVersion = 0 const showTicketCheckModal = ref(false) const paymentURL = 'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111' @@ -281,6 +289,8 @@ onMounted(() => { }) onUnmounted(() => { isDisposed = true + receiptPreparationVersion++ + clearReceiptPreview() window.removeEventListener('click', enableNotificationSound) window.removeEventListener('keydown', enableNotificationSound) void notificationAudio?.close().catch((error: unknown) => { @@ -892,6 +902,62 @@ function openTeamQR(team: Team) { openQR(getTeamStoryURL(team.ID!, team.password!), 'Подсмотреть игру') } +function openTeamReceipt(team: Team) { + if ( + uiStore.environment !== 'venue' || + !team.ID || + !team.name || + team.participationStatus === 'cancelled' + ) { + message.error('У команды нет данных для печати чека') + return + } + teamReceipt.value = { name: team.name, teamID: team.ID } + showTeamReceipt.value = true +} + +watch( + [showTeamReceipt, () => teamReceipt.value.teamID, canManageGame, () => uiStore.environment], + prepareTeamReceipt, +) + +function clearReceiptPreview() { + if (receiptPreview.value) URL.revokeObjectURL(receiptPreview.value) + receiptPreview.value = '' +} + +async function prepareTeamReceipt() { + const version = ++receiptPreparationVersion + clearReceiptPreview() + receiptPreparationError.value = '' + isPreparingReceipt.value = false + if (!showTeamReceipt.value || !canManageGame.value || uiStore.environment !== 'venue') return + isPreparingReceipt.value = true + try { + const { blob } = await binaryClient.downloadTeamReceipt(Number(gameId), teamReceipt.value.teamID) + if (version === receiptPreparationVersion) receiptPreview.value = URL.createObjectURL(blob) + } catch (error) { + if (version === receiptPreparationVersion) + receiptPreparationError.value = + error instanceof Error ? error.message : 'Не удалось подготовить чек' + } finally { + if (version === receiptPreparationVersion) isPreparingReceipt.value = false + } +} + +async function printTeamReceipt() { + if (!receiptPreview.value || isPrintingReceipt.value || isPreparingReceipt.value) return + isPrintingReceipt.value = true + try { + await client.PrintTeamReceipt({ gameID: Number(gameId), teamID: teamReceipt.value.teamID }) + message.success('Чек напечатан') + } catch (error) { + message.error(error instanceof Error ? error.message : 'Не удалось напечатать чек') + } finally { + isPrintingReceipt.value = false + } +} + async function openQR(url: string, text: string) { urlQR.value = url titleQR.value = text @@ -1276,9 +1342,11 @@ useSimplePolling(() => { { + diff --git a/src/components/GameTeamsPanel.vue b/src/components/GameTeamsPanel.vue index 0dbcc14..4b5717b 100644 --- a/src/components/GameTeamsPanel.vue +++ b/src/components/GameTeamsPanel.vue @@ -6,12 +6,14 @@ import UiBlock from '@/components/UiBlock.vue' defineProps<{ teams: Team[] + canPrintReceipts: boolean participationOptions: { label: string; value: string }[] }>() const emit = defineEmits<{ add: [] 'open-qr': [team: Team] + print: [team: Team] preview: [id: string, password: string] delete: [team: Team] 'give-application': [team: Team, application: Application] @@ -47,6 +49,12 @@ const emit = defineEmits<{ + + Печать чека + QR diff --git a/src/components/TeamReceiptModal.vue b/src/components/TeamReceiptModal.vue new file mode 100644 index 0000000..4f03470 --- /dev/null +++ b/src/components/TeamReceiptModal.vue @@ -0,0 +1,68 @@ + + + + +