feat: add Ground Station UART baseline

This commit is contained in:
2026-09-29 11:25:59 +02:00
commit 515c3bae69
7 changed files with 279 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Ground Station
Orange Pi Zero 3W application for the custom OpenIPC/WFB ground station.
## Current baseline
- receives framed GX12 RC channel data over the dedicated AUX1 UART at 115200 8N1;
- validates protocol version, frame length and CRC-16;
- logs final mixed channel values;
- can start the existing `fpv-video` launcher for the Viture primary display.
The first baseline is intentionally small. It does **not** yet control an AUX, manipulate the flight-control link, or replace the primary video pipeline.
## Run
```bash
# Hardware UART mode (device path will be confirmed during Orange Pi bring-up)
groundstation -serial /dev/ttyS1 -start-video
# Test input mode
groundstation -stdin
```
## Protocol
The v1 protocol is described in `AGENTS.md` once the repository policy file is approved. It carries 16 final EdgeTX channel outputs as a binary framed message with CRC.
## Project direction
1. GX12 Ground Station UART mode sends final RC/switch state to Orange Pi.
2. Orange Pi logs and renders state while supervising the FPV video launcher.
3. Add WFB quality, recorder state, and MIPI touch display independently of pilot video.
4. Add a watchdog-protected override of exactly one reserved `SAFE/NORMAL/HIGH` AUX.
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"git.piksar.eu/piotr/groundstation/internal/serialport"
)
func main() {
serialPath := flag.String("serial", "", "GX12 AUX1 UART device, e.g. /dev/ttyS1")
stdin := flag.Bool("stdin", false, "read framed data from standard input")
startVideo := flag.Bool("start-video", false, "start fpv-video with Ground Station")
flag.Parse()
if *stdin == (*serialPath != "") {
log.Fatal("select exactly one of -stdin or -serial")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if *startVideo {
cmd := exec.CommandContext(ctx, "fpv-video")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("start fpv-video: %v", err)
}
defer func() { _ = cmd.Wait() }()
}
var input io.Reader = os.Stdin
var port *os.File
var err error
if *serialPath != "" {
port, err = serialport.Open115200(*serialPath)
if err != nil {
log.Fatal(err)
}
defer port.Close()
input = port
}
if err := run(ctx, input, os.Stdout); err != nil && err != context.Canceled {
log.Fatal(err)
}
}
func run(ctx context.Context, input io.Reader, output io.Writer) error {
buf := make([]byte, protocol.RCSize)
for {
if err := readFull(ctx, input, buf); err != nil {
return err
}
frame, err := protocol.DecodeRC(buf)
if err != nil {
log.Printf("discard frame: %v", err)
continue
}
fmt.Fprintf(output, "seq=%d t=%d ch1=%d ch2=%d ch3=%d ch4=%d aux1=%d aux2=%d\n",
frame.Sequence, frame.Timestamp,
frame.Channels[0], frame.Channels[1], frame.Channels[2], frame.Channels[3],
frame.Channels[4], frame.Channels[5])
}
}
func readFull(ctx context.Context, input io.Reader, dst []byte) error {
read := 0
for read < len(dst) {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, err := input.Read(dst[read:])
read += n
if err != nil {
return err
}
}
return nil
}
+5
View File
@@ -0,0 +1,5 @@
module git.piksar.eu/piotr/groundstation
go 1.24.0
require golang.org/x/sys v0.40.0
+2
View File
@@ -0,0 +1,2 @@
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+86
View File
@@ -0,0 +1,86 @@
package protocol
import (
"encoding/binary"
"errors"
"fmt"
)
const (
Magic0 byte = 'G'
Magic1 byte = 'S'
Version byte = 1
TypeRCChannels byte = 1
Channels = 16
Header = 10
CRCSize = 2
RCSize = Header + Channels*2 + CRCSize
)
var (
ErrFrameLength = errors.New("groundstation: invalid frame length")
ErrMagic = errors.New("groundstation: invalid frame magic")
ErrVersion = errors.New("groundstation: unsupported frame version")
ErrCRC = errors.New("groundstation: invalid frame crc")
ErrType = errors.New("groundstation: unexpected frame type")
)
type RCFrame struct {
Sequence uint16
Timestamp uint32
Channels [Channels]int16
}
func EncodeRC(frame RCFrame) []byte {
buf := make([]byte, RCSize)
buf[0], buf[1], buf[2], buf[3] = Magic0, Magic1, Version, TypeRCChannels
binary.LittleEndian.PutUint16(buf[4:6], frame.Sequence)
binary.LittleEndian.PutUint32(buf[6:10], frame.Timestamp)
for i, value := range frame.Channels {
binary.LittleEndian.PutUint16(buf[Header+i*2:Header+i*2+2], uint16(value))
}
binary.LittleEndian.PutUint16(buf[RCSize-CRCSize:], CRC16CCITT(buf[:RCSize-CRCSize]))
return buf
}
func DecodeRC(buf []byte) (RCFrame, error) {
var frame RCFrame
if len(buf) != RCSize {
return frame, ErrFrameLength
}
if buf[0] != Magic0 || buf[1] != Magic1 {
return frame, ErrMagic
}
if buf[2] != Version {
return frame, ErrVersion
}
if buf[3] != TypeRCChannels {
return frame, ErrType
}
if got, want := binary.LittleEndian.Uint16(buf[RCSize-CRCSize:]), CRC16CCITT(buf[:RCSize-CRCSize]); got != want {
return frame, fmt.Errorf("%w: got %04x want %04x", ErrCRC, got, want)
}
frame.Sequence = binary.LittleEndian.Uint16(buf[4:6])
frame.Timestamp = binary.LittleEndian.Uint32(buf[6:10])
for i := range frame.Channels {
frame.Channels[i] = int16(binary.LittleEndian.Uint16(buf[Header+i*2 : Header+i*2+2]))
}
return frame, nil
}
func CRC16CCITT(data []byte) uint16 {
crc := uint16(0xffff)
for _, b := range data {
crc ^= uint16(b) << 8
for bit := 0; bit < 8; bit++ {
if crc&0x8000 != 0 {
crc = (crc << 1) ^ 0x1021
} else {
crc <<= 1
}
}
}
return crc
}
+26
View File
@@ -0,0 +1,26 @@
package protocol
import "testing"
func TestRCFrameRoundTrip(t *testing.T) {
input := RCFrame{Sequence: 42, Timestamp: 123456}
for i := range input.Channels {
input.Channels[i] = int16(i*100 - 750)
}
got, err := DecodeRC(EncodeRC(input))
if err != nil {
t.Fatal(err)
}
if got != input {
t.Fatalf("round trip mismatch: got %#v want %#v", got, input)
}
}
func TestRCFrameRejectsBadCRC(t *testing.T) {
packet := EncodeRC(RCFrame{})
packet[Header] ^= 1
if _, err := DecodeRC(packet); err == nil {
t.Fatal("expected CRC error")
}
}
+37
View File
@@ -0,0 +1,37 @@
package serialport
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func Open115200(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
termios, err := unix.IoctlGetTermios(int(file.Fd()), unix.TCGETS)
if err != nil {
file.Close()
return nil, err
}
termios.Iflag = 0
termios.Oflag = 0
termios.Lflag = 0
termios.Cflag &^= unix.CSIZE | unix.PARENB | unix.CSTOPB | unix.CRTSCTS
termios.Cflag |= unix.CS8 | unix.CREAD | unix.CLOCAL
termios.Ispeed = unix.B115200
termios.Ospeed = unix.B115200
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(int(file.Fd()), unix.TCSETS, termios); err != nil {
file.Close()
return nil, fmt.Errorf("configure %s: %w", path, err)
}
return file, nil
}