commit 515c3bae69a1499b705e8f91a92a640b25a44ab8 Author: Hermes Date: Tue Sep 29 11:25:59 2026 +0200 feat: add Ground Station UART baseline diff --git a/README.md b/README.md new file mode 100644 index 0000000..263ce7b --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/groundstation/main.go b/cmd/groundstation/main.go new file mode 100644 index 0000000..70d08e5 --- /dev/null +++ b/cmd/groundstation/main.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3df57b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.piksar.eu/piotr/groundstation + +go 1.24.0 + +require golang.org/x/sys v0.40.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d117642 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/protocol/rc.go b/internal/protocol/rc.go new file mode 100644 index 0000000..1d57e0a --- /dev/null +++ b/internal/protocol/rc.go @@ -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 +} diff --git a/internal/protocol/rc_test.go b/internal/protocol/rc_test.go new file mode 100644 index 0000000..5d043e9 --- /dev/null +++ b/internal/protocol/rc_test.go @@ -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") + } +} diff --git a/internal/serialport/serial_linux.go b/internal/serialport/serial_linux.go new file mode 100644 index 0000000..99167e4 --- /dev/null +++ b/internal/serialport/serial_linux.go @@ -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 +}