feat: integrate Tailwind CSS for improved styling and layout

- Added Tailwind CSS and its Vite plugin to the frontend project.
- Updated App.svelte to enhance UI with Tailwind classes, including a new layout for the serial monitor and command tester.
- Improved logging functionality by increasing log history and adding more detailed log messages.
- Refactored SendBox.svelte for consistent styling with Tailwind.
- Enhanced Pico firmware to support structured event logging and command parsing.
- Updated README_PICO_SERIAL.md to provide comprehensive documentation on serial communication and backend integration.
- Added .dockerignore to optimize Docker builds by excluding unnecessary files.
This commit is contained in:
2025-08-30 14:18:57 +02:00
parent d607308b1d
commit 46899ef7be
13 changed files with 1258 additions and 184 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
**/node_modules
npm-debug.log
yarn-error.log
dist
tmp
temp
.git
.gitignore
Dockerfile*
frontend/dist
backend/coverage

View File

@@ -1,8 +1,29 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true,
});
// Serve static frontend (built assets copied to /app/public in container)
const publicDir = join(process.cwd(), 'public');
app.useStaticAssets(publicDir);
// Basic health endpoint
app.getHttpAdapter().get('/healthz', (_req: any, res: any) => {
res.json({ ok: true, ts: Date.now() });
});
const port = Number(process.env.PORT || 3000);
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[bootstrap] Listening on :${port}`);
}
bootstrap();
bootstrap().catch((e) => {
// eslint-disable-next-line no-console
console.error('Fatal bootstrap error', e);
process.exit(1);
});

View File

@@ -139,16 +139,44 @@ export class SerialService implements OnModuleDestroy, OnModuleInit {
this.close();
}
onModuleInit() {
const port = process.env.SERIAL_PORT;
const baud = process.env.SERIAL_BAUD ? Number(process.env.SERIAL_BAUD) : undefined;
if (port) {
async onModuleInit() {
// Priority: explicit env vars > auto-detect
const explicit = process.env.SERIAL_PORT || process.env.PICO_SERIAL_PORT;
const baud = Number(process.env.SERIAL_BAUD || process.env.PICO_BAUD || 115200);
if (explicit) {
try {
console.log('[SerialService] AUTO opening port from SERIAL_PORT=', port, 'baud=', baud ?? 115200);
this.open(port, baud ?? 115200);
console.log('[SerialService] AUTO opening explicit port', explicit, 'baud=', baud);
this.open(explicit, baud);
return;
} catch (e: any) {
console.warn('[SerialService] AUTO open failed', e?.message ?? e);
console.warn('[SerialService] explicit open failed', e?.message ?? e);
}
}
// Attempt auto-detect of Pico (USB first, then common UART paths)
try {
const ports = await this.listPorts();
// Prefer vendorId 2e8a (Pico)
let candidate = ports.find(p => (p.vendorId || '').toLowerCase() === '2e8a');
if (!candidate) {
const common = ['/dev/serial0', '/dev/ttyACM0', '/dev/ttyAMA0'];
candidate = ports.find(p => p.path && common.includes(p.path));
}
// Windows fallback: highest numbered COM port
if (!candidate) {
const winCom = ports
.filter(p => /^COM\d+$/i.test(p.path))
.sort((a, b) => Number(b.path.replace(/\D/g, '')) - Number(a.path.replace(/\D/g, '')))[0];
if (winCom) candidate = winCom;
}
if (candidate && candidate.path) {
console.log('[SerialService] AUTO opening detected port', candidate.path);
this.open(candidate.path, baud);
} else {
console.log('[SerialService] No serial port auto-detected (will remain idle)');
}
} catch (e: any) {
console.warn('[SerialService] auto-detect failed', e?.message ?? e);
}
}
}

View File

@@ -1,20 +1,48 @@
# Multi-stage build for SvelteKit (static client + node server fallback)
FROM node:20-bookworm-slim AS builder
WORKDIR /app
######## Combined Frontend + Backend build (single Node runtime) ########
# This Dockerfile now builds BOTH the Svelte frontend and the NestJS backend
# and serves the compiled frontend through the backend (Express static).
# Install deps (use package-lock if present)
COPY package*.json ./
########################
# 1) Frontend build #
########################
FROM node:20-bookworm-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund
# Copy sources and build
COPY . .
COPY frontend/ .
RUN npm run build
# Use a lightweight nginx to serve the client build output
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
########################
# 2) Backend build #
########################
FROM node:20-bookworm-slim AS backend-build
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --no-audit --no-fund
COPY backend/ .
# Copy compiled frontend into backend/public (served statically by Nest)
COPY --from=frontend-build /frontend/dist ./public
RUN npm run build
# Default nginx port
EXPOSE 80
########################
# 3) Production image #
########################
FROM node:20-bookworm-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
CMD ["nginx", "-g", "daemon off;"]
# Install only production deps (reuse original package.json)
COPY backend/package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
# Copy backend dist + public assets
COPY --from=backend-build /app/dist ./dist
COPY --from=backend-build /app/public ./public
# Environment (override at runtime as needed)
ENV PORT=3000 \
SERIAL_BAUD=115200
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@@ -1,8 +1,19 @@
services:
frontend:
build: .
app:
build:
context: ..
dockerfile: frontend/Dockerfile
container_name: schafkop-app
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
# Optionally set SERIAL_PORT or PICO_SERIAL_PORT for auto-open
# - SERIAL_PORT=/dev/serial0
# - SERIAL_BAUD=115200
ports:
- "80:80"
- "80:3000"
# Uncomment and adjust one of the following for hardware access on a Pi:
# devices:
# - /dev/serial0:/dev/serial0
# - /dev/ttyACM0:/dev/ttyACM0

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,11 @@
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.12",
"typescript": "~5.8.3",
"vite": "^7.1.2"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.12"
}
}

View File

@@ -10,7 +10,8 @@
let es: EventSource | null = null;
function addLog(line: string) {
log.update((l) => [new Date().toISOString() + ' ' + line, ...l].slice(0, 200));
// keep a fairly large history and prefix with an ISO timestamp
log.update((l) => [new Date().toISOString() + ' ' + line, ...l].slice(0, 2000));
}
async function list() {
@@ -26,14 +27,18 @@
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
// log result and refresh status
if (j?.ok) addLog('PORT: open requested ' + path);
else addLog('PORT: open request failed - ' + (j?.error ?? 'unknown'));
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));
const j = await res.json().catch(() => null);
console.log('[serial] closePort result', j);
addLog('PORT: close requested');
}
async function sendLine(txt: string) {
@@ -61,13 +66,17 @@
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'); });
// connection lifecycle events - also log them so the serial box shows open/close/errors
es.addEventListener('open', () => { console.log('[serial][SSE] open'); connected.set(true); addLog('[SSE] connected'); });
es.addEventListener('close', () => { console.log('[serial][SSE] close'); connected.set(false); addLog('[SSE] disconnected'); });
es.addEventListener('error', (e: any) => { console.warn('[serial][SSE] error', e); addLog('[SSE] error: ' + (e?.message ?? JSON.stringify(e))); });
// data events contain the actual lines emitted by the Pico; parse JSON payloads but fall back to raw
es.addEventListener('data', (ev: any) => {
console.log('[serial][SSE] data event', ev.data);
try {
const d = JSON.parse(ev.data);
// always show the raw line emitted by the firmware
addLog('RX: ' + d.line);
} catch (e) {
addLog('RX (raw): ' + ev.data);
@@ -83,6 +92,10 @@
}
let statusInterval: any;
// quick test command defaults
let stepVal: string = '4096';
let dirVal: string = '1';
let speedVal: string = '3000';
onMount(() => {
list();
startStream();
@@ -93,43 +106,109 @@
stopStream();
clearInterval(statusInterval);
});
function sendStepCmd() {
const steps = Number(stepVal);
const dir = Number(dirVal) === 1 ? 1 : 0;
if (!Number.isFinite(steps) || steps <= 0) {
addLog('TX error: invalid STEP value: ' + stepVal);
return;
}
const line = `STEP ${steps} ${dir}`;
sendLine(line);
addLog('TX: ' + line);
}
function sendSpeedCmd() {
const d = Number(speedVal);
if (!Number.isFinite(d) || d <= 0) {
addLog('TX error: invalid SPEED value: ' + speedVal);
return;
}
const line = `SPEED ${d}`;
sendLine(line);
addLog('TX: ' + line);
}
</script>
<main>
<h1>Raspberry Pico — Serial Monitor</h1>
<main class="min-h-screen bg-gradient-to-b from-bg to-surface text-gray-100 p-8">
<h1 class="text-3xl font-bold underline">
Hello world!
</h1>
<div class="container mx-auto">
<header class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Raspberry Pico</h1>
<p class="text-sm text-gray-400">Serial monitor & command tester</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-300">SSE: {$connected ? 'connected' : 'disconnected'}</span>
<button class="px-3 py-1 bg-primary text-white rounded-md text-sm" on:click={startStream}>Start</button>
<button class="px-3 py-1 bg-gray-700 text-white rounded-md text-sm" on:click={stopStream}>Stop</button>
</div>
</header>
<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="grid grid-cols-12 gap-6">
<aside class="col-span-4 bg-gray-900/60 p-4 rounded-lg border border-gray-800">
<section class="mb-4">
<h3 class="text-sm text-gray-300 font-medium">Available ports</h3>
<div class="flex items-center gap-2 mt-2">
<button class="px-2 py-1 text-sm bg-gray-700 rounded" on:click={list}>Refresh</button>
<button class="px-2 py-1 text-sm bg-red-600 rounded" on:click={closePort}>Close port</button>
</div>
<ul class="mt-3 space-y-2">
{#each $ports as p}
<li class="flex items-center justify-between bg-gray-800/40 p-2 rounded">
<code class="text-xs text-gray-200">{p.path}</code>
<button class="px-2 py-1 text-sm bg-primary rounded" on:click={() => openPort(p.path)}>Open</button>
</li>
{/each}
</ul>
</section>
<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); }} />
<section class="mb-4">
<h3 class="text-sm text-gray-300 font-medium">Send</h3>
<div class="mt-2 flex items-center gap-2">
<div class="text-xs text-gray-300">Port: <span class="font-medium">{$portOpen ? 'open' : 'closed'}</span></div>
</div>
<div class="mt-2">
<SendBox disabled={!$portOpen} on:send={(e) => { sendLine(e.detail); addLog('TX: ' + e.detail); }} />
</div>
</section>
<section>
<h3 class="text-sm text-gray-300 font-medium">Quick commands</h3>
<div class="mt-2 space-y-2">
<div class="flex gap-2 items-center">
<input class="w-28 p-2 rounded bg-gray-800 text-sm" type="number" bind:value={stepVal} min="1" />
<select class="p-2 rounded bg-gray-800 text-sm" bind:value={dirVal}>
<option value="1">1 (forward)</option>
<option value="0">0 (reverse)</option>
</select>
<button class="px-3 py-1 bg-primary text-white rounded" on:click={sendStepCmd} disabled={!$portOpen}>Send STEP</button>
</div>
<div class="flex gap-2 items-center">
<input class="w-28 p-2 rounded bg-gray-800 text-sm" type="number" bind:value={speedVal} min="1" />
<button class="px-3 py-1 bg-primary text-white rounded" on:click={sendSpeedCmd} disabled={!$portOpen}>Send SPEED</button>
</div>
</div>
</section>
</aside>
<section class="col-span-8 bg-gray-900/60 p-4 rounded-lg border border-gray-800 flex flex-col">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm text-gray-300 font-medium">Live log</h3>
<div class="text-xs text-gray-400">Showing last {2000} lines</div>
</div>
<div class="flex-1 overflow-auto bg-[#0b0b0b] p-3 rounded text-sm font-mono text-gray-100">
{#each $log as l}
<div class="py-0.5"><code class="text-xs">{l}</code></div>
{/each}
</div>
</section>
</div>
</section>
<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>
</div>
</main>
<style>

View File

@@ -12,7 +12,7 @@
}
</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>
<div class="mt-2 flex gap-2 items-center">
<input class="flex-1 p-2 rounded bg-gray-800 text-sm text-gray-100" bind:value={txt} placeholder={placeholder} on:keydown={(e) => e.key === 'Enter' && doSend()} disabled={disabled} />
<button class="px-3 py-1 bg-primary text-white rounded text-sm" on:click={doSend} disabled={disabled}>Send</button>
</div>

View File

@@ -30,3 +30,15 @@ a:hover { opacity: 0.9; }
@media (prefers-color-scheme: light) {
:root { --bg: #fafafa; --surface:#fff; --text:#111827; --muted:#556; }
}
/* small helpers that complement tailwind tokens */
.container { max-width: 1100px; margin: 0 auto; padding: 1.25rem; }
:focus { outline: 3px solid rgba(124,108,255,0.18); outline-offset: 2px; }
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
plugins: [
svelte(),
tailwindcss(),
],
server: {
proxy: {
// forward serial API calls (including SSE) to backend running on :3000

View File

@@ -1,35 +1,280 @@
# Pico Serial Motor Control Integration
# Pico Serial Motor Control — README for Copilot/Developers
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.
Purpose
-------
This file documents the exact serial message contract between the Raspberry Pi Pico firmware and a TypeScript backend. It's written so another Copilot or developer can implement a backend and extend firmware features without guesswork.
## 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.
Transport modes
---------------
The firmware supports two serial transports (selected automatically):
## Firmware Files
- `src/main.cpp`: Main firmware logic, serial command parsing, motor control, logging.
- `src/ULN2003Stepper.h`, `src/Stepper.h`: Stepper motor driver classes.
1. USB CDC (preferred)
- When the Pico is connected over USB and a host opens the port, firmware uses `Serial`.
- Typical device name:
- Windows: `COMx`
- Linux (Pi): `/dev/ttyACM0`
- Vendor/Product IDs: 0x2E8A / 0x000A (can be used for auto-detection).
## 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.
2. UART0 on GPIO0/GP0 (TX) and GPIO1/GP1 (RX)
- Activated if USB CDC is not opened within ~2 seconds at boot; firmware falls back to `Serial1`.
- Used for headless integration when the Pico is cabled to a Raspberry Pi's UART pins.
## 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.
Wiring (Pico UART0 <-> Raspberry Pi UART)
----------------------------------------
All signals are 3.3V. DO NOT connect 5V to Pico GPIOs.
| Function | Pico Pin | Pi (BCM) | Pi Physical Pin |
|----------|----------|----------|-----------------|
| UART0 TX | GP0 | RXD0 (15)| 10 |
| UART0 RX | GP1 | TXD0 (14)| 8 |
| GND | GND | GND | (any GND) |
Power options:
- Preferred: Power Pico over USB (isolated data + power). Only connect GND and TX/RX for UART logic level link.
- Alternate: Power from Pi 3V3 pin to Pico 3V3 pin (NOT VBUS) plus GND. Never power from USB and Pi 3V3 simultaneously unless you know backfeed protection is in place.
Raspberry Pi configuration (enable UART)
---------------------------------------
Edit `/boot/firmware/config.txt` (newer Raspberry Pi OS) or `/boot/config.txt` (older) and ensure:
## Example Serial Session
```
SPEED 3000
enable_uart=1
```
If the serial console/login is enabled, you may need to disable it (via `sudo raspi-config` -> Interface Options -> Serial) so `/dev/serial0` is free.
After reboot you should see one of:
- `/dev/serial0` (symlink to the primary UART)
- `/dev/ttyAMA0` or `/dev/ttyS0` depending on Pi model.
Docker usage on Raspberry Pi
----------------------------
Expose the UART device inside the container:
```
docker run \
--device /dev/serial0:/dev/serial0 \
-e PICO_SERIAL_PORT=/dev/serial0 \
your-image:tag
```
If using USB instead of GPIO UART, expose `/dev/ttyACM0` (or appropriate) similarly.
Single combined app container (frontend + backend)
-------------------------------------------------
The project Dockerfile now builds both the frontend and backend. To run on a Pi with GPIO UART wiring:
```
docker build -t schafkop-app .
docker run --rm \
--device /dev/serial0:/dev/serial0 \
-e PICO_SERIAL_PORT=/dev/serial0 \
-p 80:3000 \
schafkop-app
```
For USB Pico:
```
docker run --rm \
--device /dev/ttyACM0:/dev/ttyACM0 \
-e PICO_SERIAL_PORT=/dev/ttyACM0 \
-p 80:3000 \
schafkop-app
```
If you omit PICO_SERIAL_PORT the backend will attempt auto-detection (vendorId 2e8a or common paths).
Environment variables (suggested backend behavior):
- `PICO_SERIAL_PORT`: If set, backend uses this path directly.
- `PICO_BAUD`: Override baud rate (default 115200).
Backend port auto-detection logic (recommended order):
1. If `PICO_SERIAL_PORT` env var exists, use it.
2. Else list serial ports; prefer any with vendorId `2e8a` (Pico USB).
3. Else probe common paths: `/dev/serial0`, `/dev/ttyACM0`, `/dev/ttyAMA0`, Windows `COM` ports (highest matching newly added), macOS `/dev/tty.usbmodem*`.
4. Fallback: error with clear message.
Contract (high-level)
---------------------
- Commands to Pico: ASCII text lines terminated by LF ("\\n") or CRLF ("\\r\\n").
- Events/logs from Pico: ASCII text lines, one event per line. Backend must split on newlines.
- Baud rate: 115200 (firmware uses Serial.begin(115200)).
Accepted commands (input to Pico)
---------------------------------
1) STEP <steps> <direction>
- steps: integer (positive number of micro-steps)
- direction: 1 (forward) or 0 (reverse)
- Example: `STEP 4096 1`
2) SPEED <delay_us>
- delay_us: integer microseconds between internal step micro-operations
- Example: `SPEED 3000`
Notes:
- Commands are trimmed for leading/trailing whitespace before parsing.
- Invalid or malformed commands result in an error event emitted by the Pico.
Events/log lines emitted by Pico (output)
----------------------------------------
The firmware emits structured lines with prefixes so the backend can parse them easily.
- `EVENT:RECEIVED <raw-command>`
- Emitted immediately when a command line is received (before execution).
- Example: `EVENT:RECEIVED STEP 4096 1`
- `EVENT:COMPLETED STEP <steps> <direction>`
- Emitted after a successful STEP command completes.
- Example: `EVENT:COMPLETED STEP 4096 1`
- `EVENT:COMPLETED SPEED <delay_us>`
- Emitted after the SPEED command is applied.
- `EVENT:COMPLETED ERROR: <message>`
- Emitted when a command fails to parse or run.
- Example: `EVENT:COMPLETED ERROR: malformed STEP command`
- `STATUS: OK` or `STATUS: ERROR: <message>`
- Short summary always emitted after processing a command.
Parsing guidance for backend:
- Read raw bytes, split on `\\n` (handle `\\r\\n`).
- Ignore empty lines.
- Inspect prefixes `EVENT:RECEIVED`, `EVENT:COMPLETED`, `STATUS:` and parse the remainder.
Backend responsibilities (TypeScript)
------------------------------------
- Open the serial port at 115200 baud.
- Provide a small API:
- `sendStep(steps: number, direction: 0|1): Promise<void>`
- `setSpeed(delayUs: number): Promise<void>`
- `onEvent(cb: (evt: {type: string; payload: string}) => void)`
- Send commands as ASCII lines terminated by `\\n`.
- Listen for events; map them to higher-level promises if desired.
- Implement reconnect logic with exponential backoff if the device disconnects.
- Add basic validation before sending commands to avoid malformed requests.
Suggested libraries / implementation notes
-----------------------------------------
- Use `serialport` (npm) and its `ReadlineParser` or equivalent.
- Keep a small FIFO of pending commands if you want to wait for `EVENT:COMPLETED` per command.
- For each command you can:
1. Write the line `CMD\\n` to the port.
2. Wait for `EVENT:RECEIVED CMD` then `EVENT:COMPLETED ...` and `STATUS: ...`.
- Ensure the backend tolerates duplicate or out-of-order messages (don't assume perfect timing).
Examples
--------
Example sequence for a STEP command:
```
// backend writes:
STEP 4096 1
LOG Motor moved
// pico emits:
EVENT:RECEIVED STEP 4096 1
EVENT:COMPLETED STEP 4096 1
STATUS: OK
```
## Build & Upload
Use PlatformIO to build and upload the firmware to the Pico.
TypeScript example (auto-detect & simple API)
--------------------------------------------
```ts
import { SerialPort } from 'serialport';
import { ReadlineParser } from '@serialport/parser-readline';
interface PicoEvent { type: string; payload: string; raw: string; }
async function findPort(): Promise<string> {
if (process.env.PICO_SERIAL_PORT) return process.env.PICO_SERIAL_PORT;
const ports = await SerialPort.list();
// Prefer Pico USB (vendorId 2e8a)
const pico = ports.find(p => (p.vendorId||'').toLowerCase()==='2e8a');
if (pico && pico.path) return pico.path;
const candidates = ['/dev/serial0','/dev/ttyACM0','/dev/ttyAMA0'];
for (const c of candidates) if (ports.find(p=>p.path===c)) return c;
if (ports[0]) return ports[0].path; // fallback
throw new Error('No serial ports detected for Pico');
}
export class PicoClient {
private port!: SerialPort;
private parser!: ReadlineParser;
private listeners: ((e:PicoEvent)=>void)[] = [];
constructor(private baud = Number(process.env.PICO_BAUD)||115200) {}
async init() {
const path = await findPort();
this.port = new SerialPort({ path, baudRate: this.baud });
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\n' }));
this.parser.on('data', line => {
const l = line.trim();
if (!l) return;
let type='RAW'; let payload=l;
if (l.startsWith('EVENT:RECEIVED ')) { type='EVENT:RECEIVED'; payload=l.substring(15); }
else if (l.startsWith('EVENT:COMPLETED ')) { type='EVENT:COMPLETED'; payload=l.substring(17); }
else if (l.startsWith('STATUS: ')) { type='STATUS'; payload=l.substring(8); }
this.listeners.forEach(cb=>cb({ type, payload, raw:l }));
});
return this;
}
onEvent(cb:(e:PicoEvent)=>void){ this.listeners.push(cb); }
private write(cmd:string){ this.port.write(cmd.endsWith('\n')?cmd:cmd+'\n'); }
sendStep(steps:number, dir:0|1){ this.write(`STEP ${steps} ${dir}`); }
setSpeed(delayUs:number){ this.write(`SPEED ${delayUs}`); }
}
// Usage example
// (async () => {
// const pico = await new PicoClient().init();
// pico.onEvent(e => console.log('EVENT', e));
// pico.setSpeed(3000);
// pico.sendStep(4096,1);
// })();
```
Manual testing
--------------
- Build and upload firmware with PlatformIO (pico environment).
- Open a serial terminal at 115200 and send commands.
- Observe `EVENT:` and `STATUS:` lines.
Testing inside Docker (Pi UART example)
--------------------------------------
1. Connect wiring (see table above) and power Pico.
2. Confirm `/dev/serial0` exists on host (`ls -l /dev/serial0`).
3. Run container with device passed through.
4. Inside container: run a small Node script using the TypeScript example (compiled) or `screen /dev/serial0 115200` for manual check.
Troubleshooting
---------------
| Symptom | Likely Cause | Action |
|---------|--------------|--------|
| No output on UART | Pi serial console still enabled | Disable login shell on serial via `raspi-config` |
| Garbled characters | Baud mismatch | Ensure both sides at 115200 |
| Only EVENT:START appears | Backend not sending newline | Ensure commands end with `\n` |
| USB port not found | Missing udev permissions | Add user to `dialout` (Linux) or run with proper permissions |
| Command times out | Long step count | Consider splitting into smaller STEP commands |
Extending the firmware
----------------------
- Add new command parsing in `src/main.cpp`.
- Emit `EVENT:RECEIVED <cmd>` when a command is received.
- Emit `EVENT:COMPLETED <...>` once the command finishes (or `EVENT:COMPLETED ERROR: ...` on failure).
- Update this README with any new event formats.
Files of interest
-----------------
- `src/main.cpp` — serial parser, command dispatch, and event emission.
- `src/ULN2003Stepper.h`, `src/Stepper.h` — stepper implementation.
- `src/schafkopf-bot.cpp` — helper logic; references globals via `extern`.
Notes for a Copilot implementer
------------------------------
- Preserve the exact prefixes (`EVENT:RECEIVED`, `EVENT:COMPLETED`, `STATUS:`).
- Add unit tests for the backend parser to assert the event shapes.
- Keep CLI/manual examples minimal and copyable.
- When adding new commands, document: syntax, EVENT:COMPLETED form, error cases, sample sequence.
- Keep the README as the single source of truth for the serial protocol.
---
This README is intended for another developer or Copilot to implement further changes or features.
This README is intended as the definitive reference for implementing the TypeScript backend and for extending the Pico firmware. Follow the message formats exactly to avoid breaking existing parsers.

View File

@@ -1,50 +1,111 @@
// Supports two transport modes:
// 1. USB CDC (Serial) when connected over USB (preferred)
// 2. Hardware UART0 (Serial1) on GPIO pins for headless Raspberry Pi connection
// Wiring for UART mode (3.3V logic only):
// Pi GPIO14 (TXD0, physical pin 8) --> Pico GP1 (UART0 RX)
// Pi GPIO15 (RXD0, physical pin 10) <-- Pico GP0 (UART0 TX)
// Pi GND (any) <--------------------> Pico GND
// Optionally power Pico via USB. If powering from Pi 3V3, DO NOT also power via USB simultaneously.
#include <Arduino.h>
#include "Stepper.h"
#include "ULN2003Stepper.h"
ULN2003Stepper driver1({18, 19, 20, 21}, 4096);
int revSteps = 0;
// Generic IO pointers (assigned to USB Serial or Serial1 at runtime)
Stream* serialIn = nullptr; // for available()/read()
Print* serialOut = nullptr; // for print()/println()
void setup() {
// Start USB CDC first
Serial.begin(115200);
unsigned long start = millis();
while (!Serial && (millis() - start) < 2000UL) {
// wait up to 2 seconds for host to open USB (non-blocking fallback)
}
if (Serial) {
serialIn = &Serial;
serialOut = &Serial;
Serial.println("INFO: Using USB CDC Serial");
} else {
// Fall back to UART0 on GPIO0 (TX) / GPIO1 (RX)
Serial1.begin(115200); // default pins for UART0 in Arduino-Pico core
serialIn = &Serial1;
serialOut = &Serial1;
Serial1.println("INFO: Using UART0 (Serial1) on GP0(TX)/GP1(RX)");
}
revSteps = driver1.get_steps_per_rev();
Serial.print("Steps: ");
Serial.println(revSteps);
serialOut->print("EVENT:START STEPS_PER_REV ");
serialOut->println(revSteps);
}
// helper: trim leading/trailing whitespace
static String trim(const String &s) {
int start = 0;
int end = s.length() - 1;
while (start <= end && isspace(s.charAt(start))) start++;
while (end >= start && isspace(s.charAt(end))) end--;
if (end < start) return String("");
return s.substring(start, end + 1);
}
void loop() {
if (!serialIn || !serialOut) return; // safety
static String input = "";
while (Serial.available()) {
char c = Serial.read();
while (serialIn->available()) {
char c = serialIn->read();
if (c == '\n' || c == '\r') {
if (input.length() > 0) {
Serial.print("Received: ");
Serial.println(input);
String cmd = trim(input);
if (cmd.length() > 0) {
// Emit event: received
serialOut->print("EVENT:RECEIVED ");
serialOut->println(cmd);
bool success = true;
String result = "OK";
// 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();
if (cmd.startsWith("STEP ")) {
int idx1 = cmd.indexOf(' ');
int idx2 = cmd.indexOf(' ', idx1 + 1);
if (idx1 != -1 && idx2 != -1) {
int steps = cmd.substring(idx1 + 1, idx2).toInt();
int dir = cmd.substring(idx2 + 1).toInt();
// execute
driver1.step(steps, dir != 0);
// report completion
serialOut->print("EVENT:COMPLETED STEP ");
serialOut->print(steps);
serialOut->print(" ");
serialOut->println(dir);
} else {
success = false;
result = "ERROR: malformed STEP command";
}
} else if (cmd.startsWith("SPEED ")) {
int delay_us = cmd.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));
serialOut->print("EVENT:COMPLETED SPEED ");
serialOut->println(delay_us);
} else {
Serial.println("Unknown command");
success = false;
result = "ERROR: unknown command";
serialOut->print("EVENT:COMPLETED ");
serialOut->println(result);
}
// always print a short status summary
if (success) {
serialOut->print("STATUS: ");
serialOut->println("OK");
} else {
serialOut->print("STATUS: ");
serialOut->println(result);
}
input = "";
}
input = "";
} else {
input += c;
}