feat: add Ground Station UART baseline
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.piksar.eu/piotr/groundstation
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/sys v0.40.0
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user