Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "always",
"source.organizeImports.ruff": "always"
},
"python.analysis.typeCheckingMode": "standard",
"ruff.lineLength": 120,
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "always",
"source.organizeImports.ruff": "always"
},
"python.analysis.typeCheckingMode": "standard",
"ruff.lineLength": 120,
"black-formatter.args": ["--line-length", "120"]
}
147 changes: 121 additions & 26 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,16 @@
from collections.abc import Coroutine
from contextlib import asynccontextmanager
from http import HTTPStatus
from importlib import import_module
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from src.napta_matrix import MATRIX_SCRIPTS
from src.helpers.control import RDY, SERVER_PORT, WAIT
from src.napta_matrix import MATRIX_SCRIPTS, PlayableMatrixScript

# Import scripts
THIS_DIR = Path(__file__).resolve().parent
for file in sorted(THIS_DIR.glob("src/**/*.py")):
if file.stem != "__init__":
module_path = ".".join(file.relative_to(THIS_DIR).parts).removesuffix(".py")
import_module(module_path)


DEFAULT_PROGRAM = MATRIX_SCRIPTS["display_screensaver"]()
DEFAULT_PROGRAM = MATRIX_SCRIPTS["display_screensaver"].function()


_main_program_task: asyncio.Task[None]
Expand All @@ -29,9 +20,7 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
global _main_program_task
_main_program_task = asyncio.create_task(
DEFAULT_PROGRAM, name=DEFAULT_PROGRAM.__name__
)
_main_program_task = asyncio.create_task(DEFAULT_PROGRAM, name=DEFAULT_PROGRAM.__name__)
try:
yield
finally:
Expand All @@ -55,31 +44,137 @@ def switch_program(program: Coroutine[Any, Any, None]) -> None:


class ScriptResponse(BaseModel):
script_id: str
script_name: str
is_playable: bool


class GetScriptsResponse(BaseModel):
scripts: list[str]
current_script: str
scripts: list[ScriptResponse]
current_script: ScriptResponse


@app.get("/scripts", operation_id="get_scripts")
async def scripts() -> GetScriptsResponse:
global _main_program_task
scripts = list(MATRIX_SCRIPTS.keys())
current_script = _main_program_task.get_name()
return GetScriptsResponse(scripts=scripts, current_script=current_script)
scripts = [
ScriptResponse(
script_id=script_name,
script_name=script.script_name,
is_playable=isinstance(script, PlayableMatrixScript),
)
for script_name, script in MATRIX_SCRIPTS.items()
]
current_script = MATRIX_SCRIPTS[_main_program_task.get_name()]
return GetScriptsResponse(
scripts=scripts,
current_script=ScriptResponse(
script_id=_main_program_task.get_name(),
script_name=current_script.script_name,
is_playable=isinstance(current_script, PlayableMatrixScript),
),
)


class ChangeScriptRequest(BaseModel):
script: str
script_id: str


@app.post("/scripts/change", operation_id="post_change_script")
async def change_script(change_script_request: ChangeScriptRequest):
async def change_script(change_script_request: ChangeScriptRequest) -> None:
try:
script = MATRIX_SCRIPTS[change_script_request.script]
script = MATRIX_SCRIPTS[change_script_request.script_id].function
except KeyError:
raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, f"Unknown program: {change_script_request.script}")
raise HTTPException(
HTTPStatus.UNPROCESSABLE_ENTITY,
f"Unknown program: {change_script_request.script_id}",
)
switch_program(script())
return "OK"


class PlayableScriptResponse(BaseModel):
script_id: str
script_name: str
min_player_number: int
max_player_number: int
keys: list[str]


@app.get("/scripts/playable/{script_id}", operation_id="get_playable_script")
async def playable_script(script_id: str) -> PlayableScriptResponse:
try:
script = MATRIX_SCRIPTS[script_id]
except KeyError:
raise HTTPException(
HTTPStatus.UNPROCESSABLE_ENTITY,
f"Unknown program: {script_id}",
)

if not isinstance(script, PlayableMatrixScript):
raise HTTPException(
HTTPStatus.UNPROCESSABLE_ENTITY,
f"Program {script_id} is not a playable program",
)

return PlayableScriptResponse(
script_id=script_id,
script_name=script.script_name,
min_player_number=script.min_player_number,
max_player_number=script.max_player_number,
keys=[key.value for key in script.keys],
)


async def _handle_choose_player(
data: dict, websocket: WebSocket, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
player_number = data.get("data", {}).get("player_number", None)
if player_number is None:
await websocket.send_json({"error": "Missing player number"})
return

writer.write(f"P{player_number}".encode() + b"\n")
await writer.drain()
message = await reader.readuntil(b"\n")
if message == RDY:
await websocket.send_json({"type": "status", "data": "READY"})
elif message == WAIT:
await websocket.send_json({"type": "status", "data": "WAITING"})
else:
await websocket.send_json({"type": "error", "data": "Unknown message"})


async def _handle_key(data: dict, websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
key = data.get("data", {}).get("key", None)
if key is None:
await websocket.send_json({"type": "error", "data": "Missing key"})
return

if key == "UP":
key = "\x1b[A"
elif key == "DOWN":
key = "\x1b[B"
elif key == "LEFT":
key = "\x1b[D"
elif key == "RIGHT":
key = "\x1b[C"
else:
await websocket.send_json({"type": "error", "data": "Unknown key"})
return

writer.write(key.encode())


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
reader, writer = await asyncio.open_connection(host="localhost", port=SERVER_PORT)

await websocket.accept()
while True:
data = await websocket.receive_json()
if data.get("type") == "choose_player":
await _handle_choose_player(data, websocket, reader, writer)
elif data.get("type") == "key":
await _handle_key(data, websocket, writer)
else:
await websocket.send_json({"type": "error", "data": "Unknown message"})
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"@tabler/icons-react": "^3.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-query": "^3.39.3"
"react-query": "^3.39.3",
"react-use-websocket": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
Expand Down
14 changes: 14 additions & 0 deletions client/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { MantineProvider } from "@mantine/core";

import { client } from "./api";
import { Layout } from "./components/layout";
import { getBaseURL } from "./api/get-base-url";

client.setConfig({
baseUrl: `http://${window.location.hostname}:8042`,
baseUrl: getBaseURL(),
});
const queryClient = new QueryClient();

Expand Down
2 changes: 2 additions & 0 deletions client/src/api/get-base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const getBaseURL = () => `http://${window.location.hostname}:8042`;
export const getWSBaseURL = () => `ws://${window.location.hostname}:8042`;
62 changes: 56 additions & 6 deletions client/src/api/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,27 @@

export const $ChangeScriptRequest = {
properties: {
script: {
script_id: {
type: 'string',
title: 'Script'
title: 'Script Id'
}
},
type: 'object',
required: ['script'],
required: ['script_id'],
title: 'ChangeScriptRequest'
} as const;

export const $GetScriptsResponse = {
properties: {
scripts: {
items: {
type: 'string'
'$ref': '#/components/schemas/ScriptResponse'
},
type: 'array',
title: 'Scripts'
},
current_script: {
type: 'string',
title: 'Current Script'
'$ref': '#/components/schemas/ScriptResponse'
}
},
type: 'object',
Expand All @@ -45,6 +44,57 @@ export const $HTTPValidationError = {
title: 'HTTPValidationError'
} as const;

export const $PlayableScriptResponse = {
properties: {
script_id: {
type: 'string',
title: 'Script Id'
},
script_name: {
type: 'string',
title: 'Script Name'
},
min_player_number: {
type: 'integer',
title: 'Min Player Number'
},
max_player_number: {
type: 'integer',
title: 'Max Player Number'
},
keys: {
items: {
type: 'string'
},
type: 'array',
title: 'Keys'
}
},
type: 'object',
required: ['script_id', 'script_name', 'min_player_number', 'max_player_number', 'keys'],
title: 'PlayableScriptResponse'
} as const;

export const $ScriptResponse = {
properties: {
script_id: {
type: 'string',
title: 'Script Id'
},
script_name: {
type: 'string',
title: 'Script Name'
},
is_playable: {
type: 'boolean',
title: 'Is Playable'
}
},
type: 'object',
required: ['script_id', 'script_name', 'is_playable'],
title: 'ScriptResponse'
} as const;

export const $ValidationError = {
properties: {
loc: {
Expand Down
10 changes: 9 additions & 1 deletion client/src/api/services.gen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts

import { createClient, createConfig, type Options } from '@hey-api/client-fetch';
import type { GetScriptsError, GetScriptsResponse2, PostChangeScriptData, PostChangeScriptError, PostChangeScriptResponse } from './types.gen';
import type { GetScriptsError, GetScriptsResponse2, PostChangeScriptData, PostChangeScriptError, PostChangeScriptResponse, GetPlayableScriptData, GetPlayableScriptError, GetPlayableScriptResponse } from './types.gen';

export const client = createClient(createConfig());

Expand All @@ -19,4 +19,12 @@ export const getScripts = <ThrowOnError extends boolean = false>(options?: Optio
export const postChangeScript = <ThrowOnError extends boolean = false>(options: Options<PostChangeScriptData, ThrowOnError>) => { return (options?.client ?? client).post<PostChangeScriptResponse, PostChangeScriptError, ThrowOnError>({
...options,
url: '/scripts/change'
}); };

/**
* Playable Script
*/
export const getPlayableScript = <ThrowOnError extends boolean = false>(options: Options<GetPlayableScriptData, ThrowOnError>) => { return (options?.client ?? client).get<GetPlayableScriptResponse, GetPlayableScriptError, ThrowOnError>({
...options,
url: '/scripts/playable/{script_id}'
}); };
Loading