refactor: structure pico project

This commit is contained in:
2025-09-04 23:00:48 +02:00
parent db44b15717
commit 0a376487df
8 changed files with 117 additions and 463 deletions

View File

@@ -1,280 +0,0 @@
# Pico Serial Motor Control — README for Copilot/Developers
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.
Transport modes
---------------
The firmware supports two serial transports (selected automatically):
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).
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.
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:
```
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
// pico emits:
EVENT:RECEIVED STEP 4096 1
EVENT:COMPLETED STEP 4096 1
STATUS: OK
```
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 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,37 +0,0 @@
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

View File

@@ -1,46 +0,0 @@
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

View File

@@ -0,0 +1,22 @@
#pragma once
class Stepper {
protected:
int steps_per_rev;
virtual void step(int steps, bool direction) = 0;
public:
void step_rev(double revs, bool direction){
if(revs == 0.0) return;
if(revs < 0.0){
direction = !direction;
revs = -revs;
}
long total_steps = (long)(revs * steps_per_rev + 0.5);
if(total_steps <= 0) return;
step((int)total_steps, direction);
}
virtual ~Stepper() {}
};

View File

@@ -1,10 +1,13 @@
#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},
@@ -16,24 +19,40 @@ private:
{0, 0, 0, 1},
{1, 0, 0, 1}
};
int step_delay_us = 5000;
int current_seq_idx = 0;
void step(int steps, bool direction) override {
if (steps <= 0) return;
for (int i = 0; i < steps; ++i) {
// calculate next step index
current_seq_idx = direction
? (current_seq_idx + 1) % steps_per_seq
: (current_seq_idx - 1 + steps_per_seq) % steps_per_seq;
// set pins according to the current sequence
digitalWrite(pins[0], sequence[current_seq_idx][0]);
digitalWrite(pins[1], sequence[current_seq_idx][1]);
digitalWrite(pins[2], sequence[current_seq_idx][2]);
digitalWrite(pins[3], sequence[current_seq_idx][3]);
// wait for the specified delay
delayMicroseconds(step_delay_us);
}
}
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;
}
void setStepDelay(int delay_us) { step_delay_us = delay_us; }
void resetPhase() { current_seq_idx = 0; }
};

View File

@@ -1,15 +0,0 @@
#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() {}
};

View File

@@ -1,6 +1,6 @@
#include <Arduino.h>
#include "Stepper.h"
#include "ULN2003Stepper.h"
#include <Stepper.h>
#include <ULN2003Stepper.h>
#ifndef LED_BUILTIN
#define LED_BUILTIN 25
@@ -16,12 +16,10 @@ public:
Serial.begin(baud);
int revSteps = driver1.get_steps_per_rev();
Serial.print("EVENT:START STEPS_PER_REV ");
Serial.println(revSteps);
waitForHost(1500);
logStartup();
// Startup indication
for(int i=0;i<3;i++) blink(60,100);
}
void loop() {
@@ -31,7 +29,11 @@ public:
private:
String inputBuffer;
enum class CmdType { PING, STEP, SPEED, UNKNOWN };
struct CommandHandler {
const char* name;
void (SchafkopfSerialApp::*fn)(const String &args);
};
static const CommandHandler commandTable[]; // defined after class
void waitForHost(unsigned long timeoutMs){
unsigned long start = millis();
@@ -45,15 +47,10 @@ private:
if(offMs) delay(offMs);
}
void logStartup(){
Serial.println(F("HELLO START"));
for(int i=0;i<3;i++) blink(60,100);
}
void pollSerial(){
while(Serial.available()){
char c = Serial.read();
if(c=='\r') continue; // ignore CR
if(c=='\r') continue;
if(c=='\n') {
processLine(inputBuffer);
inputBuffer = "";
@@ -66,17 +63,21 @@ private:
void processLine(const String &raw){
String line = raw;
line.trim();
if(!line.length()) return;
String cmdToken = firstToken(line);
String rest = remainingAfterFirst(line);
CmdType type = classify(cmdToken);
handleCommand(type, cmdToken, rest);
cmdToken.toUpperCase();
dispatchCommand(cmdToken, rest);
}
static String firstToken(const String &line){
int sp = line.indexOf(' ');
return (sp==-1)? line : line.substring(0, sp);
}
static String remainingAfterFirst(const String &line){
int sp = line.indexOf(' ');
if (sp==-1) return String("");
@@ -85,67 +86,68 @@ private:
return r;
}
CmdType classify(String token){
token.toUpperCase();
if(token==F("PING")) return CmdType::PING;
if(token==F("STEP")) return CmdType::STEP;
if(token==F("SPEED")) return CmdType::SPEED;
return CmdType::UNKNOWN;
void dispatchCommand(const String &uppercaseToken, const String &args){
for(size_t i=0; commandTable[i].name != nullptr; ++i){
if(uppercaseToken.equals(commandTable[i].name)){
(this->*commandTable[i].fn)(args);
return;
}
}
Serial.print(F("ERR:UNKNOWN COMMAND '")); Serial.print(uppercaseToken); Serial.println("'");
blink(20);
}
void handleCommand(CmdType type, const String &token, const String &args){
switch(type){
case CmdType::PING: {
Serial.println(F("PONG"));
blink();
break; }
case CmdType::STEP: {
// Expected: STEP <steps> <dir>
int steps = -1; int dir = -1;
if(parseStepArgs(args, steps, dir)) {
Serial.print(F("STEP: moving ")); Serial.print(steps); Serial.print(F(" dir=")); Serial.println(dir);
driver1.step(steps, dir!=0);
blink(60);
} else {
Serial.println(F("ERR:STEP usage STEP <steps> <0|1>"));
blink(20);
}
break; }
case CmdType::SPEED: {
int delayUs = args.toInt();
if(delayUs > 0) {
driver1.setSpeed(delayUs);
Serial.print(F("SPEED: set delay_us=")); Serial.println(delayUs);
blink();
} else {
Serial.println(F("ERR:SPEED usage SPEED <positive_delay_us>"));
blink(20);
}
break; }
case CmdType::UNKNOWN: {
Serial.print(F("ERR:UNKNOWN COMMAND '")); Serial.print(token); Serial.println("'");
blink(20);
break; }
default: {
Serial.println(F("ERR:UNHANDLED"));
blink(20);
break; }
// ---- COMMANDS ----
void cmdHealthcheck(const String &){
Serial.println(F("OK"));
blink();
}
void cmdStep(const String &args){
double steps = -1; int dir = -1;
if(parseStepArgs(args, steps, dir)) {
Serial.print(F("STEP: moving ")); Serial.print(steps); Serial.print(F(" dir=")); Serial.println(dir);
driver1.step_rev(steps, dir!=0);
blink(60);
} else {
Serial.println(F("ERR:STEP usage STEP <revs> <0|1>"));
blink(20);
}
}
// Helpers for parsing arguments
bool parseStepArgs(const String &args, int &steps, int &dir){
void cmdSpeed(const String &args){
int delayUs = args.toInt();
if(delayUs > 0) {
driver1.setStepDelay(delayUs);
Serial.print(F("SPEED: set delay_us=")); Serial.println(delayUs);
blink();
} else {
Serial.println(F("ERR:SPEED usage SPEED <positive_delay_us>"));
blink(20);
}
}
bool parseStepArgs(const String &args, double &steps, int &dir){
int sp = args.indexOf(' ');
if(sp == -1) return false;
String a = args.substring(0, sp); a.trim();
String b = args.substring(sp+1); b.trim();
if(!a.length() || !b.length()) return false;
steps = a.toInt(); dir = b.toInt();
steps = a.toDouble(); dir = b.toInt();
if(steps <= 0) return false;
if(!(dir==0 || dir==1)) return false;
return true;
}
};
// ---- COMMAND TABLE ----
const SchafkopfSerialApp::CommandHandler SchafkopfSerialApp::commandTable[] = {
{"HEALTHCHECK", &SchafkopfSerialApp::cmdHealthcheck},
{"STEP", &SchafkopfSerialApp::cmdStep},
{"SPEED", &SchafkopfSerialApp::cmdSpeed},
{nullptr, nullptr}
};
SchafkopfSerialApp app;
void setup(){ app.begin(); }

View File

@@ -1,11 +0,0 @@
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