feat: Initialize backend with NestJS framework and serial communication

- Added package.json for backend dependencies and scripts.
- Implemented basic AppController and AppService with a simple "Hello World!" endpoint.
- Created SerialModule for handling serial communication with the Pico.
- Developed SerialController and SerialService to manage serial ports and commands.
- Implemented event streaming for serial data.
- Added Jest tests for AppController and e2e tests for the application.
- Set up TypeScript configuration for the backend.
- Created frontend SendBox component for sending commands to the backend.
- Established Pico firmware for controlling a stepper motor via serial commands.
- Documented project structure and usage in README files.
This commit is contained in:
2025-08-27 22:11:33 +02:00
parent bd41ffbe79
commit d607308b1d
32 changed files with 11622 additions and 165 deletions

56
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
backend/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

34
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10549
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
backend/package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"reflect-metadata": "^0.2.2",
"serialport": "^10.5.0",
"@serialport/parser-readline": "^2.0.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

11
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { SerialModule } from './serial/serial.module';
@Module({
imports: [SerialModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

8
backend/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,89 @@
import { Controller, Get, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
import { SerialService } from './serial.service';
@Controller('serial')
export class SerialController {
constructor(private readonly serial: SerialService) {}
@Get('ports')
async ports() {
console.log('[SerialController] GET /serial/ports');
const p = await this.serial.listPorts();
console.log('[SerialController] ports ->', p);
return p;
}
@Post('open')
async open(@Body() body: { path: string; baud?: number }) {
console.log('[SerialController] POST /serial/open', body);
try {
this.serial.open(body.path, body.baud ?? 115200);
return { ok: true };
} catch (e: any) {
console.error('[SerialController] open error', e);
return { ok: false, error: String(e) };
}
}
@Post('close')
async close() {
console.log('[SerialController] POST /serial/close');
this.serial.close();
return { ok: true };
}
@Post('send')
async send(@Body() body: { payload: string }) {
console.log('[SerialController] POST /serial/send', body.payload);
try {
this.serial.send(body.payload);
return { ok: true };
} catch (e: any) {
console.error('[SerialController] send error', e);
return { ok: false, error: String(e) };
}
}
@Get('status')
status() {
return this.serial.status();
}
@Get('stream')
stream(@Req() req: Request, @Res() res: Response) {
// SSE stream
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders?.();
const sendEvent = (event: string, data: any) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const offData = this.serial.on('data', (line) => sendEvent('data', { line }));
const offOpen = this.serial.on('open', () => sendEvent('open', { ts: Date.now() }));
const offClose = this.serial.on('close', () => sendEvent('close', { ts: Date.now() }));
const offErr = this.serial.on('error', (err) => sendEvent('error', { message: String(err) }));
console.log('[SerialController] SSE client connected');
// send a ping every 20s to keep connection alive
const ping = setInterval(() => res.write(': ping\n\n'), 20000);
req.on('close', () => {
console.log('[SerialController] SSE client disconnected');
clearInterval(ping);
offData();
offOpen();
offClose();
offErr();
});
return res;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SerialService } from './serial.service';
import { SerialController } from './serial.controller';
@Module({
providers: [SerialService],
controllers: [SerialController],
exports: [SerialService],
})
export class SerialModule {}

View File

@@ -0,0 +1,154 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { EventEmitter } from 'events';
// serialport has ESM/CJS shape differences and limited typings; require at runtime and keep as any
const SP: any = require('serialport');
const ReadlineParserModule: any = require('@serialport/parser-readline');
// normalize constructor and parser
const SerialCtor: any = SP?.SerialPort ?? SP;
const ParserCtor: any = ReadlineParserModule?.ReadlineParser ?? ReadlineParserModule;
type PortInfo = {
path: string;
manufacturer?: string;
serialNumber?: string;
pnpId?: string;
locationId?: string;
productId?: string;
vendorId?: string;
};
@Injectable()
export class SerialService implements OnModuleDestroy, OnModuleInit {
private emitter = new EventEmitter();
private port: any = null;
private parser: any = null;
async listPorts(): Promise<PortInfo[]> {
console.log('[SerialService] listPorts()');
let ports: any[] = [];
try {
if (typeof SP.list === 'function') {
ports = await SP.list();
} else {
// try the separate list package
try {
const listMod: any = require('@serialport/list');
if (typeof listMod.list === 'function') ports = await listMod.list();
else if (typeof listMod === 'function') ports = await listMod();
} catch (e) {
// fallback: empty list
ports = [];
}
}
} catch (e) {
console.error('[SerialService] listPorts error', e);
ports = [];
}
console.log('[SerialService] listPorts ->', ports);
return ports.map((p: any) => ({
path: p.path || p.comName,
manufacturer: p.manufacturer,
serialNumber: p.serialNumber,
pnpId: p.pnpId,
locationId: p.locationId,
productId: p.productId,
vendorId: p.vendorId,
}));
}
open(path: string, baudRate = 115200) {
this.close();
console.log('[SerialService] opening port', path, 'baud', baudRate);
this.port = new SerialCtor({ path, baudRate, autoOpen: false } as any);
this.parser = this.port.pipe(new ParserCtor({ delimiter: '\n' }));
this.port.on('open', () => {
console.log('[SerialService] port open', path);
this.emitter.emit('open');
});
this.port.on('error', (err: any) => {
console.error('[SerialService] port error', err);
this.emitter.emit('error', err?.message ?? String(err));
});
this.parser.on('data', (line: string) => {
console.log('[SerialService] RX:', line);
this.emitter.emit('data', line);
});
// open the port
try {
this.port.open((err: any) => {
if (err) {
console.error('[SerialService] open callback error', err);
this.emitter.emit('error', err?.message ?? String(err));
} else {
console.log('[SerialService] open callback success');
}
});
} catch (e: any) {
console.error('[SerialService] open exception', e);
this.emitter.emit('error', e?.message ?? String(e));
}
}
close() {
if (this.parser) {
this.parser.removeAllListeners();
this.parser = null;
}
if (this.port) {
try {
console.log('[SerialService] closing port', this.port?.path ?? '(unknown)');
this.port.close();
} catch (e) {
console.error('[SerialService] close error', e);
}
this.port = null;
}
this.emitter.emit('close');
}
send(line: string) {
if (!this.port || !(this.port.writable || this.port.isOpen)) {
console.warn('[SerialService] send called but port not open');
throw new Error('Port not open');
}
// write and flush
console.log('[SerialService] TX:', line);
this.port.write(line + '\n', (err: any) => {
if (err) console.error('[SerialService] write error', err);
else console.log('[SerialService] write success');
});
}
status() {
const s = {
open: !!(this.port && (this.port.isOpen || this.port.writable)),
path: this.port?.path ?? null,
};
console.log('[SerialService] status ->', s);
return s;
}
on(event: 'data' | 'open' | 'close' | 'error', cb: (...args: any[]) => void) {
this.emitter.on(event, cb);
return () => this.emitter.off(event, cb);
}
onModuleDestroy() {
this.close();
}
onModuleInit() {
const port = process.env.SERIAL_PORT;
const baud = process.env.SERIAL_BAUD ? Number(process.env.SERIAL_BAUD) : undefined;
if (port) {
try {
console.log('[SerialService] AUTO opening port from SERIAL_PORT=', port, 'baud=', baud ?? 115200);
this.open(port, baud ?? 115200);
} catch (e: any) {
console.warn('[SerialService] AUTO open failed', e?.message ?? e);
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}

View File

@@ -1,179 +1,143 @@
<script lang="ts">
// Landing page for the Schafkopf Bot.
import Game from './lib/Game.svelte';
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import SendBox from './SendBox.svelte';
const ports = writable<Array<any>>([]);
const log = writable<string[]>([]);
const connected = writable(false);
const portOpen = writable(false);
let es: EventSource | null = null;
function addLog(line: string) {
log.update((l) => [new Date().toISOString() + ' ' + line, ...l].slice(0, 200));
}
async function list() {
console.log('[serial] list() called');
const res = await fetch('/serial/ports');
const data = await res.json();
console.log('[serial] list() ->', data);
ports.set(data);
}
async function openPort(path: string) {
console.log('[serial] openPort()', path);
const res = await fetch('/serial/open', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) });
const j = await res.json().catch(() => null);
console.log('[serial] openPort result', j);
// refresh status
setTimeout(getStatus, 300);
}
async function closePort() {
console.log('[serial] closePort()');
const res = await fetch('/serial/close', { method: 'POST' });
console.log('[serial] closePort result', await res.json().catch(() => null));
}
async function sendLine(txt: string) {
console.log('[serial] sendLine()', txt);
const res = await fetch('/serial/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ payload: txt }) });
const j = await res.json().catch(() => null);
console.log('[serial] sendLine result', j);
if (!j?.ok) addLog('TX error: ' + (j?.error ?? 'unknown'));
}
async function getStatus() {
console.log('[serial] getStatus()');
try {
const res = await fetch('/serial/status');
const j = await res.json();
console.log('[serial] getStatus ->', j);
portOpen.set(!!j.open);
} catch (e) {
console.warn('[serial] getStatus failed', e);
portOpen.set(false);
}
}
function startStream() {
if (es) return;
console.log('[serial] startStream()');
es = new EventSource('/serial/stream');
es.addEventListener('open', () => { console.log('[serial][SSE] open'); connected.set(true); });
es.addEventListener('close', () => { console.log('[serial][SSE] close'); connected.set(false); });
es.addEventListener('error', (e: any) => { console.warn('[serial][SSE] error', e); addLog('SSE error'); });
es.addEventListener('data', (ev: any) => {
console.log('[serial][SSE] data event', ev.data);
try {
const d = JSON.parse(ev.data);
addLog('RX: ' + d.line);
} catch (e) {
addLog('RX (raw): ' + ev.data);
}
});
}
function stopStream() {
console.log('[serial] stopStream()');
es?.close();
es = null;
connected.set(false);
}
let statusInterval: any;
onMount(() => {
list();
startStream();
statusInterval = setInterval(getStatus, 1000);
getStatus();
});
onDestroy(() => {
stopStream();
clearInterval(statusInterval);
});
</script>
<main id="app">
<header class="nav">
<div class="container">
<h1 class="brand">Schafkopf Bot</h1>
<nav>
<a href="#features">Features</a>
<a href="#how">How it works</a>
<a href="#contact">Contact</a>
<a class="cta" href="#try">Try</a>
</nav>
</div>
</header>
<main>
<h1>Raspberry Pico — Serial Monitor</h1>
<section class="hero container">
<div class="hero-content">
<h2>Play, train and analyse Schafkopf with an intelligent bot</h2>
<p class="tagline">A friendly opponent and coach for solo practice, teaching games and building your skills.</p>
<div class="actions">
<a class="btn primary" id="try" href="mailto:hello@schafkopf.bot?subject=I%20want%20to%20try%20the%20bot">Play with the bot</a>
<a class="btn" href="#features">See features</a>
</div>
</div>
<section>
<h3>Available ports</h3>
<button on:click={list}>Refresh</button>
<ul>
{#each $ports as p}
<li>
<code>{p.path}</code>
<button on:click={() => openPort(p.path)}>Open</button>
</li>
{/each}
</ul>
<button on:click={closePort}>Close port</button>
</section>
<div class="hero-visual" aria-hidden="true">
<svg width="360" height="220" viewBox="0 0 360 220" fill="none" xmlns="http://www.w3.org/2000/svg" role="img">
<rect x="0" y="0" width="360" height="220" rx="12" fill="var(--card)" />
<g transform="translate(24,28)">
<rect width="80" height="120" rx="8" fill="#fff" opacity="0.95" />
<rect x="90" width="80" height="120" rx="8" fill="#fff" opacity="0.95" />
<rect x="180" width="80" height="120" rx="8" fill="#fff" opacity="0.95" />
</g>
<text x="24" y="190" fill="var(--muted)" font-size="12">Schafkopf Bot — practice anytime</text>
</svg>
<section>
<h3>Send</h3>
<div style="display:flex; align-items:center; gap:1rem;">
<div>Port: {$portOpen ? 'open' : 'closed'}</div>
<SendBox disabled={!$portOpen} on:send={(e) => { sendLine(e.detail); addLog('TX: ' + e.detail); }} />
</div>
</section>
<section id="features" class="features container">
<h3>Key features</h3>
<div class="grid">
<article class="card">
<h4>Play against an AI</h4>
<p>Fast opponents at multiple skill levels — perfect for solo training and testing strategies.</p>
</article>
<article class="card">
<h4>Hand analysis</h4>
<p>Review played hands with suggestions and common mistakes highlighted to accelerate learning.</p>
</article>
<article class="card">
<h4>Compatible interfaces</h4>
<p>Connect via chat platforms or a web interface — invite the bot to your games and tournaments.</p>
</article>
<article class="card">
<h4>Privacy-first</h4>
<p>Local game history and optional anonymous usage — designed with player privacy in mind.</p>
</article>
<section>
<h3>Live log ({$connected ? 'connected' : 'disconnected'})</h3>
<button on:click={startStream}>Start stream</button>
<button on:click={stopStream}>Stop stream</button>
<div style="height:320px; overflow:auto; background:#111; color:#dfe6ef; padding:8px; border-radius:6px;">
{#each $log as l}
<div><code>{l}</code></div>
{/each}
</div>
</section>
<section id="how" class="how container">
<h3>How it works</h3>
<ol>
<li>Start a new game and choose a difficulty.</li>
<li>Play hands while the bot offers optional hints and post-game analysis.</li>
<li>Export or review hands to track progress.</li>
</ol>
</section>
<section id="game" class="game container">
<h3>Physical robot game</h3>
<p class="muted">Boot the robot into an in-person game and let it play as one of the four seats.</p>
<div class="grid">
<div>
<!-- Game starter component -->
<Game />
</div>
<div class="card">
<h4>Setup checklist</h4>
<ul>
<li>Connect robot to the local network.</li>
<li>Place the robot at the chosen seat and align card feeder.</li>
<li>Calibrate cameras and touch sensors if needed.</li>
</ul>
</div>
</div>
</section>
<section id="contact" class="contact container">
<h3>Get early access</h3>
<p>Interested in trying the bot or integrating it into a platform? Send a message and we'll reply with next steps.</p>
<a class="btn primary" href="mailto:hello@schafkopf.bot?subject=Early%20access%20request">Contact us</a>
</section>
<footer class="site-footer">
<div class="container">
<small>© {new Date().getFullYear()} Schafkopf Bot — built with Svelte</small>
<nav>
<a href="https://github.com/Vale54321/schafkop-neu" target="_blank" rel="noreferrer">Source</a>
</nav>
</div>
</footer>
</main>
<style>
/* keep styles local to App for the landing layout */
:global(:root) {
--bg: #121212;
--text: #eaeaea;
--muted: #9aa3b2;
--card: #1a1a1a;
--accent: #7c6cff;
--glass: rgba(255,255,255,0.04);
}
main {
color: var(--text);
background: linear-gradient(180deg, #0d0d0d 0%, #121212 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2rem;
}
.nav {
border-bottom: 1px solid rgba(255,255,255,0.04);
backdrop-filter: blur(6px);
}
.nav .container {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { font-size: 1.2rem; font-weight: 700; margin: 0; }
nav a { color: var(--muted); margin-left: 1rem; text-decoration: none; }
nav a.cta { color: var(--text); background: var(--accent); padding: 0.5rem 0.9rem; border-radius: 8px; }
.hero {
display: grid;
grid-template-columns: 1fr 420px;
gap: 2rem;
align-items: center;
padding-top: 3rem;
padding-bottom: 3rem;
}
.hero h2 { font-size: 2.1rem; margin: 0 0 0.5rem 0; }
.tagline { color: var(--muted); margin-bottom: 1rem; }
.actions { display:flex; gap:0.75rem; }
.btn { display:inline-block; padding:0.6rem 1rem; border-radius:8px; text-decoration:none; color:var(--text); background:var(--glass); border:1px solid rgba(255,255,255,0.02); }
.btn.primary { background: var(--accent); color: #fff; }
.hero-visual { display:flex; justify-content:center; }
.features { padding-top: 2rem; padding-bottom:2rem; }
.features h3 { margin-bottom:1rem; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:1rem; }
.card { background: var(--card); padding:1rem; border-radius:12px; box-shadow: 0 6px 20px rgba(0,0,0,0.4); }
.how { background: linear-gradient(180deg, transparent, rgba(255,255,255,0.01)); padding:2rem; border-radius:12px; margin-bottom:1rem; }
ol { padding-left:1.2rem; color:var(--muted); }
.contact { text-align:center; padding:2rem 0; }
.site-footer { border-top:1px solid rgba(255,255,255,0.04); margin-top:auto; }
.site-footer .container { display:flex; justify-content:space-between; align-items:center; padding:1rem 2rem; }
.site-footer small { color:var(--muted); }
@media (max-width: 880px) {
.hero { grid-template-columns: 1fr; }
.site-footer .container { flex-direction:column; gap:0.75rem; }
}
main { padding: 1rem; font-family: system-ui, -apple-system, 'Segoe UI', Roboto; color:#111 }
h1 { margin-bottom:0.5rem }
section { margin-top:1rem; }
button { margin-left:0.5rem }
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let placeholder = 'Type a line to send';
export let disabled: boolean = false;
const dispatch = createEventDispatcher();
let txt = '';
function doSend() {
if (!txt) return;
console.log('[SendBox] dispatch send', txt);
dispatch('send', txt);
txt = '';
}
</script>
<div style="margin-top:0.5rem; display:flex; gap:0.5rem; align-items:center;">
<input bind:value={txt} placeholder={placeholder} on:keydown={(e) => e.key === 'Enter' && doSend()} style="flex:1; padding:0.4rem;" disabled={disabled} />
<button on:click={doSend} disabled={disabled}>Send</button>
</div>

View File

@@ -4,4 +4,14 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
// forward serial API calls (including SSE) to backend running on :3000
'/serial': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: false,
},
},
},
})

5
pico/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
pico/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -0,0 +1,35 @@
# Pico Serial Motor Control Integration
This project enables a Raspberry Pi Pico to be controlled via serial commands from a TypeScript backend. The Pico firmware parses commands received over serial and controls a stepper motor accordingly, while also sending log/status messages back.
## Serial Commands Supported
- `STEP <steps> <direction>`: Move the motor by the specified number of steps. Direction: 1 for forward, 0 for reverse.
- `SPEED <delay_us>`: Set the motor speed (delay in microseconds between steps).
- `LOG <message>`: Print a log message to serial.
## Firmware Files
- `src/main.cpp`: Main firmware logic, serial command parsing, motor control, logging.
- `src/ULN2003Stepper.h`, `src/Stepper.h`: Stepper motor driver classes.
## How to Extend
- Add new serial commands by updating the parser in `main.cpp`.
- Implement additional motor features or logging as needed.
- Ensure any new global variables are only defined in one source file, and declared as `extern` elsewhere.
## TypeScript Backend
- Use a library like `serialport` to communicate with the Pico.
- Send commands as plain text terminated by `\n`.
- Read and process log/status messages from serial.
## Example Serial Session
```
SPEED 3000
STEP 4096 1
LOG Motor moved
```
## Build & Upload
Use PlatformIO to build and upload the firmware to the Pico.
---
This README is intended for another developer or Copilot to implement further changes or features.

37
pico/include/README Normal file
View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

46
pico/lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

15
pico/platformio.ini Normal file
View File

@@ -0,0 +1,15 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:pico]
platform = raspberrypi
board = pico
framework = arduino
; build_flags removed, no longer needed

15
pico/src/Stepper.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
// ...existing code...
class Stepper {
protected:
int steps_per_rev;
public:
virtual void step(int steps, bool direction) = 0;
void step_rev(int revs, bool direction){
step(steps_per_rev*revs, direction);
}
int get_steps_per_rev(){
return steps_per_rev;
}
virtual ~Stepper() {}
};

39
pico/src/ULN2003Stepper.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <Arduino.h>
#include "Stepper.h"
#include <array>
class ULN2003Stepper : public Stepper {
private:
std::array<uint8_t, 4> pins;
static constexpr int steps_per_seq = 8;
const uint8_t sequence[8][4] = {
{1, 0, 0, 0},
{1, 1, 0, 0},
{0, 1, 0, 0},
{0, 1, 1, 0},
{0, 0, 1, 0},
{0, 0, 1, 1},
{0, 0, 0, 1},
{1, 0, 0, 1}
};
int step_delay_us = 5000;
public:
ULN2003Stepper(std::array<uint8_t, 4> pins, int rev_steps) : pins(pins) {
steps_per_rev = rev_steps;
for (auto pin : pins) {
pinMode(pin, OUTPUT);
}
}
void step(int steps, bool direction) override {
for (int i = 0; i < steps; ++i) {
int seq_idx = direction ? (i % steps_per_seq) : (steps_per_seq - 1 - (i % steps_per_seq));
for (int j = 0; j < 4; ++j) {
digitalWrite(pins[j], sequence[seq_idx][j]);
}
delayMicroseconds(step_delay_us);
}
}
void setSpeed(int delay_us) {
step_delay_us = delay_us;
}
};

53
pico/src/main.cpp Normal file
View File

@@ -0,0 +1,53 @@
#include <Arduino.h>
#include "Stepper.h"
#include "ULN2003Stepper.h"
ULN2003Stepper driver1({18, 19, 20, 21}, 4096);
int revSteps = 0;
void setup() {
Serial.begin(115200);
revSteps = driver1.get_steps_per_rev();
Serial.print("Steps: ");
Serial.println(revSteps);
}
void loop() {
static String input = "";
while (Serial.available()) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (input.length() > 0) {
Serial.print("Received: ");
Serial.println(input);
// Parse command
if (input.startsWith("STEP ")) {
int idx1 = input.indexOf(' ');
int idx2 = input.indexOf(' ', idx1 + 1);
int steps = input.substring(idx1 + 1, idx2).toInt();
int dir = input.substring(idx2 + 1).toInt();
driver1.step(steps, dir != 0);
Serial.print("Motor moved ");
Serial.print(steps);
Serial.print(" steps in direction ");
Serial.println(dir);
} else if (input.startsWith("SPEED ")) {
int delay_us = input.substring(6).toInt();
driver1.setSpeed(delay_us);
Serial.print("Speed set to ");
Serial.println(delay_us);
} else if (input.startsWith("LOG ")) {
Serial.print("LOG: ");
Serial.println(input.substring(4));
} else {
Serial.println("Unknown command");
}
input = "";
}
} else {
input += c;
}
}
delay(10); // avoid busy loop
}

11
pico/test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html