WhiteVests and code to parse data from eggtimer

This commit is contained in:
Afonso Baldo 2022-12-03 18:55:43 +00:00
parent 98f9549f9e
commit a9214847c4
56 changed files with 2777 additions and 0 deletions

5
ground/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
src
data
build
bin
pkg

200
ground/core/computed.go Normal file
View File

@ -0,0 +1,200 @@
package core
import (
"math"
)
func basePressure(stream FlightData) float64 {
pressures := make([]float64, 0)
for _, segment := range stream.AllSegments() {
if segment.Computed.SmoothedPressure > 0 {
pressures = append(pressures, segment.Computed.SmoothedPressure)
}
if len(pressures) >= 10 {
var sum float64 = 0
for _, v := range pressures {
sum += v
}
return nanSafe(sum / float64(len(pressures)))
}
}
return 0
}
func altitude(bp float64, raw RawDataSegment) float64 {
if bp == 0 {
return 0
}
return nanSafe(44307.7 * (1 - math.Pow((raw.Pressure/100)/bp, 0.190284)))
}
func normalizedPressure(raw RawDataSegment) float64 {
return nanSafe(raw.Pressure / 100.0)
}
func velocity(stream FlightData, bp float64, raw RawDataSegment) float64 {
altitude := altitude(bp, raw)
segments := stream.AllSegments()
for i := len(segments) - 1; i >= 0; i -= 1 {
if segments[i].Computed.Altitude != altitude {
return nanSafe((altitude - segments[i].Computed.Altitude) / (raw.Timestamp - segments[i].Raw.Timestamp))
}
}
return 0.0
}
func yaw(raw RawDataSegment) float64 {
return nanSafe(math.Atan2(-1.0*raw.Acceleration.X, raw.Acceleration.Z) * (180.0 / math.Pi))
}
func pitch(raw RawDataSegment) float64 {
return nanSafe(math.Atan2(-1.0*raw.Acceleration.Y, raw.Acceleration.Z) * (180.0 / math.Pi))
}
func toRadians(degrees float64) float64 {
return nanSafe(degrees * math.Pi / 180)
}
func toDegrees(radians float64) float64 {
return nanSafe(radians * 180 / math.Pi)
}
func bearing(origin Coordinate, raw RawDataSegment) float64 {
if origin.Lat == 0 || origin.Lon == 0 || raw.Coordinate.Lat == 0 || raw.Coordinate.Lon == 0 {
return 0
}
startLat := toRadians(origin.Lat)
startLng := toRadians(origin.Lon)
destLat := toRadians(raw.Coordinate.Lon)
destLng := toRadians(raw.Coordinate.Lon)
y := math.Sin(destLng-startLng) * math.Cos(destLat)
x := math.Cos(startLat)*math.Sin(destLat) - math.Sin(startLat)*math.Cos(destLat)*math.Cos(destLng-startLng)
brng := math.Atan2(y, x)
brng = toDegrees(brng)
return nanSafe(math.Mod(brng+360, 360))
}
func distance(origin Coordinate, raw RawDataSegment) float64 {
if origin.Lat == 0 || origin.Lon == 0 || raw.Coordinate.Lat == 0 || raw.Coordinate.Lon == 0 {
return 0
}
R := 6371e3
φ1 := origin.Lat * math.Pi / 180
φ2 := raw.Coordinate.Lat * math.Pi / 180
Δφ := (raw.Coordinate.Lat - origin.Lat) * math.Pi / 180
Δλ := (raw.Coordinate.Lon - origin.Lon) * math.Pi / 180
a := math.Sin(Δφ/2)*math.Sin(Δφ/2) + math.Cos(φ1)*math.Cos(φ2)*math.Sin(Δλ/2)*math.Sin(Δλ/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return nanSafe(R * c)
}
func dataRate(stream FlightData) float64 {
totalsMap := make(map[int]float64)
for _, timestamp := range stream.Time() {
second := int(math.Floor(timestamp))
if total, ok := totalsMap[second]; ok {
totalsMap[second] = total + 1
} else {
totalsMap[second] = 1
}
}
total := 0.0
for _, secondTotal := range totalsMap {
total += secondTotal
}
return nanSafe(total / float64(len(totalsMap)))
}
func averageComputedValue(seconds float64, stream FlightData, raw RawDataSegment, computed ComputedDataSegment, accessor func(seg ComputedDataSegment) float64) float64 {
total := accessor(computed)
n := 1.0
i := len(stream.AllSegments()) - 1
for i >= 0 && raw.Timestamp-stream.Time()[i] <= seconds {
total += accessor(stream.AllSegments()[i].Computed)
n++
i--
}
return nanSafe(total / n)
}
func determineFlightMode(stream FlightData, raw RawDataSegment, computed ComputedDataSegment) FlightMode {
length := len(stream.AllSegments())
if length == 0 {
return ModePrelaunch
}
lastMode := stream.AllSegments()[length-1].Computed.FlightMode
avgVelocity := averageComputedValue(1, stream, raw, computed, func(seg ComputedDataSegment) float64 {
return seg.SmoothedVelocity
})
avgAcceleration := averageComputedValue(1, stream, raw, computed, func(seg ComputedDataSegment) float64 {
return seg.SmoothedVerticalAcceleration
})
if lastMode == ModePrelaunch && avgVelocity > 5 {
return ModeAscentPowered
}
if lastMode == ModeAscentPowered && avgAcceleration < 0 && avgVelocity > 0 {
return ModeAscentUnpowered
}
if (lastMode == ModeAscentPowered || lastMode == ModeAscentUnpowered) && avgVelocity < 0 {
return ModeDescentFreefall
}
if lastMode == ModeDescentFreefall && math.Abs(avgAcceleration) < 0.5 {
return ModeDescentParachute
}
if (lastMode == ModeDescentFreefall || lastMode == ModeDescentParachute) && math.Abs(avgAcceleration) < 0.5 && math.Abs(avgVelocity) < 0.5 {
return ModeRecovery
}
return lastMode
}
func ComputeDataSegment(stream FlightData, raw RawDataSegment) (ComputedDataSegment, float64, Coordinate) {
bp := stream.BasePressure()
if bp == 0 {
bp = basePressure(stream)
}
origin := stream.Origin()
if origin.Lat == 0 && origin.Lon == 0 && raw.Coordinate.Lat != 0 && raw.Coordinate.Lon != 0 {
origin = raw.Coordinate
}
alt := altitude(bp, raw)
vel := velocity(stream, bp, raw)
press := normalizedPressure(raw)
smoothedAlt := alt
smoothedVel := vel
smoothedVertAccel := 0.0
smoothedPress := press
smoothedTemp := raw.Temperature
s := len(stream.AllSegments())
if s > 0 {
alpha := 0.5
smoothedAlt = smoothed(alpha, alt, stream.SmoothedAltitude()[s-1])
smoothedVel = smoothed(alpha, vel, stream.SmoothedVelocity()[s-1])
smoothedPress = smoothed(alpha, press, stream.SmoothedPressure()[s-1])
smoothedTemp = smoothed(alpha, raw.Temperature, stream.SmoothedTemperature()[s-1])
smoothedVertAccel = (smoothedVel - stream.SmoothedVelocity()[s-1]) / (raw.Timestamp - stream.Time()[s-1])
}
computed := ComputedDataSegment{
Altitude: alt,
Velocity: vel,
Yaw: yaw(raw),
Pitch: pitch(raw),
Bearing: bearing(origin, raw),
Distance: distance(origin, raw),
DataRate: dataRate(stream),
SmoothedAltitude: smoothedAlt,
SmoothedVelocity: smoothedVel,
SmoothedPressure: smoothedPress,
SmoothedTemperature: smoothedTemp,
SmoothedVerticalAcceleration: smoothedVertAccel,
}
computed.FlightMode = determineFlightMode(stream, raw, computed)
return computed, bp, origin
}

View File

@ -0,0 +1,176 @@
package core
import (
"math"
"math/rand"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBasePressureSet(t *testing.T) {
segments, avg := makeDataSeries(0)
b := basePressure(&FlightDataConcrete{
Base: 0,
Segments: segments,
OriginCoordinate: Coordinate{},
})
assert.Equal(t, b, avg)
}
func TestAltitudeNoBase(t *testing.T) {
alt := altitude(0, RawDataSegment{
Pressure: rand.Float64(),
})
assert.Equal(t, alt, 0.0)
}
func TestAltitudeBase(t *testing.T) {
alt := altitude(1012, RawDataSegment{
Pressure: 1010,
})
assert.Equal(t, alt, 25868.260058108597)
}
func TestNormalizedPressure(t *testing.T) {
p := rand.Float64() * 1000
v := normalizedPressure(RawDataSegment{Pressure: p})
assert.Equal(t, v, p/100)
}
func TestVelocity(t *testing.T) {
bp := 1012.0
segments, _ := makeDataSeries(bp)
val := (rand.Float64()*20 + 1000) * 100.0
seg := RawDataSegment{
Timestamp: float64(len(segments)),
Pressure: val,
}
vel := velocity(&FlightDataConcrete{
Base: 0,
Segments: segments,
OriginCoordinate: Coordinate{},
}, bp, seg)
vel1 := (altitude(bp, seg) - segments[len(segments)-1].Computed.Altitude) / (seg.Timestamp - segments[len(segments)-1].Raw.Timestamp)
assert.Equal(t, vel, vel1)
}
func TestYaw(t *testing.T) {
val := yaw(RawDataSegment{
Acceleration: XYZ{
X: 100,
Y: 110,
Z: 120,
},
})
assert.Equal(t, val, -39.80557109226519)
}
func TestPitch(t *testing.T) {
val := pitch(RawDataSegment{
Acceleration: XYZ{
X: 100,
Y: 110,
Z: 120,
},
})
assert.Equal(t, val, -42.51044707800084)
}
func TestToDegrees(t *testing.T) {
val := toDegrees(math.Pi)
assert.Equal(t, val, 180.0)
}
func TestToRadians(t *testing.T) {
val := toRadians(90)
assert.Equal(t, val, math.Pi/2)
}
func TestBearing(t *testing.T) {
origin := Coordinate{
38.811423646113546,
-77.054951464077,
}
seg := RawDataSegment{
Coordinate: Coordinate{
38,
-77,
},
}
b := bearing(origin, seg)
assert.Equal(t, b, 179.9862686631269)
}
func TestDistance(t *testing.T) {
origin := Coordinate{
38.811423646113546,
-77.054951464077,
}
seg := RawDataSegment{
Coordinate: Coordinate{
38,
-77,
},
}
b := distance(origin, seg)
assert.Equal(t, b, 90353.15173806295)
}
func TestDataRate(t *testing.T) {
segments, _ := makeDataSeries(0)
rate := dataRate(&FlightDataConcrete{
Segments: segments,
})
assert.Equal(t, rate, 1.0)
}
func TestComputeDataSegment(t *testing.T) {
segments, avg := makeDataSeries(0)
segment, bp, origin := ComputeDataSegment(&FlightDataConcrete{
Segments: segments,
OriginCoordinate: Coordinate{37, -76},
}, RawDataSegment{
WriteProgress: 1.0,
Timestamp: float64(len(segments) + 1),
Pressure: 1014.0,
Temperature: 30.0,
Acceleration: XYZ{1, 2, 3},
Magnetic: XYZ{1, 2, 3},
Coordinate: Coordinate{38, -77},
GPSInfo: GPSInfo{0.0, 0.0},
Rssi: 0,
})
assert.Equal(t, bp, avg)
assert.NotEqual(t, origin.Lat, 0.0)
assert.NotEqual(t, origin.Lon, 0.0)
assert.NotEqual(t, segment.Altitude, 0.0)
assert.NotEqual(t, segment.Velocity, 0.0)
assert.NotEqual(t, segment.Yaw, 0.0)
assert.NotEqual(t, segment.Pitch, 0.0)
assert.NotEqual(t, segment.Bearing, 0.0)
assert.NotEqual(t, segment.Distance, 0.0)
assert.NotEqual(t, segment.DataRate, 0.0)
}
func makeDataSeries(bp float64) ([]DataSegment, float64) {
series := make([]DataSegment, 10)
total := 0.0
for i := 0; i < len(series); i++ {
val := rand.Float64()*20 + 1000
total += val
series[i] = DataSegment{
RawDataSegment{
Timestamp: float64(i),
Pressure: val * 100.0,
},
ComputedDataSegment{
Altitude: altitude(bp, RawDataSegment{
Pressure: val * 100.0,
}),
SmoothedPressure: val,
},
}
}
return series, total / float64(len(series))
}

26
ground/core/consts.go Normal file
View File

@ -0,0 +1,26 @@
package core
const (
ModePrelaunch = "P"
ModeAscentPowered = "AP"
ModeAscentUnpowered = "AU"
ModeDescentFreefall = "DF"
ModeDescentParachute = "DP"
ModeRecovery = "R"
)
const (
IndexTimestamp = 0
IndexPressure = 1
IndexTemperature = 2
IndexAccelerationX = 3
IndexAccelerationY = 4
IndexAccelerationZ = 5
IndexMagneticX = 6
IndexMagneticY = 7
IndexMagneticZ = 8
IndexCoordinateLat = 9
IndexCoordinateLon = 10
IndexGpsQuality = 11
IndexGpsSats = 12
)

View File

@ -0,0 +1,85 @@
package core
func NewFlightData() FlightDataConcrete {
return FlightDataConcrete{0, make([]DataSegment, 0), Coordinate{}}
}
func (f *FlightDataConcrete) AppendData(segments []DataSegment) {
f.Segments = append(f.Segments, segments...)
}
func (f *FlightDataConcrete) SetBasePressure(bp float64) {
f.Base = bp
}
func (f *FlightDataConcrete) SetOrigin(coord Coordinate) {
f.OriginCoordinate = coord
}
func (f *FlightDataConcrete) AllSegments() []DataSegment {
return f.Segments
}
func (f *FlightDataConcrete) BasePressure() float64 {
return f.Base
}
func (f *FlightDataConcrete) SmoothedAltitude() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Computed.SmoothedAltitude
})
}
func (f *FlightDataConcrete) SmoothedVelocity() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Computed.SmoothedVelocity
})
}
func (f *FlightDataConcrete) SmoothedTemperature() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Computed.SmoothedTemperature
})
}
func (f *FlightDataConcrete) SmoothedPressure() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Computed.SmoothedPressure
})
}
func (f *FlightDataConcrete) GpsQuality() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Raw.GPSInfo.Quality
})
}
func (f *FlightDataConcrete) GpsSats() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Raw.GPSInfo.Sats
})
}
func (f *FlightDataConcrete) Time() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return segment.Raw.Timestamp
})
}
func (f *FlightDataConcrete) Rssi() []float64 {
return singleFlightDataElement(f, func(segment DataSegment) float64 {
return float64(segment.Raw.Rssi)
})
}
func (f *FlightDataConcrete) Origin() Coordinate {
return f.OriginCoordinate
}
func (f *FlightDataConcrete) FlightModes() []FlightMode {
data := make([]FlightMode, len(f.AllSegments()))
for i, segment := range f.AllSegments() {
data[i] = segment.Computed.FlightMode
}
return data
}

5
ground/core/go.mod Normal file
View File

@ -0,0 +1,5 @@
module core
go 1.16
require github.com/stretchr/testify v1.7.0

10
ground/core/go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

77
ground/core/types.go Normal file
View File

@ -0,0 +1,77 @@
package core
type FlightMode string
type Coordinate struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type GPSInfo struct {
Quality float64 `json:"quality"`
Sats float64 `json:"sats"`
}
type XYZ struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
type RawDataSegment struct {
WriteProgress float64 `json:"writeProgress"`
Timestamp float64 `json:"timestamp"`
Pressure float64 `json:"pressure"`
Temperature float64 `json:"temperature"`
Acceleration XYZ `json:"acceleration"`
Magnetic XYZ `json:"magnetic"`
Coordinate Coordinate `json:"coordinate"`
GPSInfo GPSInfo `json:"gpsInfo"`
Rssi int16 `json:"rssi"`
}
type ComputedDataSegment struct {
Altitude float64 `json:"altitude"`
Velocity float64 `json:"velocity"`
SmoothedVerticalAcceleration float64 `json:"smoothedVerticalAcceleration"`
Yaw float64 `json:"yaw"`
Pitch float64 `json:"pitch"`
Bearing float64 `json:"bearing"`
Distance float64 `json:"distance"`
DataRate float64 `json:"dataRate"`
SmoothedAltitude float64 `json:"smoothedAltitude"`
SmoothedVelocity float64 `json:"smoothedVelocity"`
SmoothedPressure float64 `json:"smoothedPressure"`
SmoothedTemperature float64 `json:"smoothedTemperature"`
FlightMode FlightMode `json:"flightMode"`
}
type DataSegment struct {
Raw RawDataSegment `json:"raw"`
Computed ComputedDataSegment `json:"computed"`
}
type FlightDataConcrete struct {
Base float64
Segments []DataSegment
OriginCoordinate Coordinate
}
type FlightData interface {
// IngestNewSegment(bytes []byte) ([]DataSegment, error)
AppendData(segments []DataSegment)
SetBasePressure(bp float64)
SetOrigin(coord Coordinate)
AllSegments() []DataSegment
BasePressure() float64
Origin() Coordinate
Time() []float64
SmoothedAltitude() []float64
SmoothedVelocity() []float64
SmoothedTemperature() []float64
SmoothedPressure() []float64
GpsQuality() []float64
GpsSats() []float64
Rssi() []float64
FlightModes() []FlightMode
}

22
ground/core/util.go Normal file
View File

@ -0,0 +1,22 @@
package core
import "math"
func singleFlightDataElement(ds FlightData, accessor func(DataSegment) float64) []float64 {
data := make([]float64, len(ds.AllSegments()))
for i, segment := range ds.AllSegments() {
data[i] = accessor(segment)
}
return data
}
func smoothed(alpha float64, xt float64, stm1 float64) float64 {
return alpha*xt + (1-alpha)*stm1
}
func nanSafe(val float64) float64 {
if math.IsNaN(val) {
return 0.0
}
return val
}

20
ground/dashboard/Makefile Normal file
View File

@ -0,0 +1,20 @@
.PHONY: generate_test_data run
OS=$(shell uname)
ARCH=$(shell arch)
install:
go get ./...
go get -t ./...
run:
go run . $(source)
build:
go build -o build/white-vest-dashboard-$(OS)-$(ARCH) .
test:
go test .
clean:
rm -rf build

View File

@ -0,0 +1,5 @@
package main
const (
PointsPerDataFrame = 2
)

View File

@ -0,0 +1,78 @@
package main
import (
"bytes"
"io/ioutil"
"time"
"github.com/jacobsa/go-serial/serial"
)
// DataProvider / DataProviderFile
func NewDataProviderFile(path string) (DataProvider, error) {
fileBytes, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
dsBytes := make([][]byte, 0)
rangeStart := 0
for i, b := range fileBytes {
if b == '\n' {
segment := fileBytes[rangeStart:i]
dsBytes = append(dsBytes, segment)
rangeStart = i + 1
}
}
return DataProviderFile{dsBytes}, nil
}
func (f DataProviderFile) Stream() <-chan []byte {
channel := make(chan []byte, 256)
go func() {
lastLine := 0
for {
time.Sleep(time.Second)
if lastLine >= len(f.Bytes) {
return
}
channel <- f.Bytes[lastLine]
lastLine += 1
}
}()
return channel
}
// DataProvider / DataProviderSerial
func NewDataProviderSerial(input string, speed uint) (DataProviderSerial, error) {
options := serial.OpenOptions{
PortName: input,
BaudRate: speed,
DataBits: 8,
StopBits: 1,
MinimumReadSize: 4,
}
port, err := serial.Open(options)
return DataProviderSerial{port}, err
}
func (f DataProviderSerial) Stream() <-chan []byte {
channel := make(chan []byte, 256)
go func() {
var buffer bytes.Buffer
for {
readBytes := make([]byte, 1024)
n, _ := f.Port.Read(readBytes)
for i := 0; i < n; i++ {
if readBytes[i] == '\n' {
channel <- buffer.Bytes()
buffer = *bytes.NewBuffer([]byte{})
} else {
buffer.WriteByte(readBytes[i])
}
}
}
}()
return channel
}

0
ground/dashboard/file Normal file
View File

14
ground/dashboard/go.mod Normal file
View File

@ -0,0 +1,14 @@
module main
go 1.16
replace github.com/johnjones4/model-rocket-telemetry/dashboard/core => ../core
require (
github.com/gizak/termui/v3 v3.1.0
github.com/gorilla/websocket v1.4.2
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
github.com/johnjones4/model-rocket-telemetry/dashboard/core v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.7.0
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect
)

25
ground/dashboard/go.sum Normal file
View File

@ -0,0 +1,25 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac h1:oN6lz7iLW/YC7un8pq+9bOLyXrprv2+DKfkJY+2LJJw=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path"
"sync"
"time"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
func dataSegmentToString(ds core.DataSegment) string {
bytes, err := json.Marshal(ds)
if err != nil {
return ""
} else {
return string(bytes)
}
}
func generateLogFilePath() (string, error) {
dirname, err := os.UserHomeDir()
if err != nil {
return "", err
}
tstamp := time.Now().Unix()
filename := fmt.Sprintf("whitevest_%d.log", tstamp)
return path.Join(dirname, filename), nil
}
func NewLogger() LoggerControl {
logger := Logger{
DataChannel: make(chan core.DataSegment, 100),
ContinueRunning: true,
Mutex: sync.Mutex{},
}
go func() {
logPath, err := generateLogFilePath()
if err != nil {
panic(err)
}
file, err := os.Create(logPath)
if err != nil {
panic(err)
}
defer file.Close()
for {
ds := <-logger.DataChannel
_, err = file.WriteString(dataSegmentToString(ds))
if err != nil {
panic(err)
}
logger.Mutex.Lock()
if !logger.ContinueRunning {
return
}
logger.Mutex.Unlock()
}
}()
return &logger
}
func (l *Logger) Kill() {
l.Mutex.Lock()
l.ContinueRunning = false
l.Mutex.Unlock()
}
func (l *Logger) Log(ds core.DataSegment) {
l.DataChannel <- ds
}

47
ground/dashboard/main.go Normal file
View File

@ -0,0 +1,47 @@
package main
import (
"flag"
"strings"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
const (
OUTPUT_TYPE_TEXT = "text"
OUTPUT_TYPE_WEB = "web"
)
func main() {
var input = flag.String("input", "", "input (file or device)")
var output = flag.String("output", OUTPUT_TYPE_WEB, "output style (web, text)")
flag.Parse()
var provider DataProvider
var err error
if *input == "" {
flag.Usage()
return
} else if strings.HasPrefix(*input, "/dev/") {
var providerSerial DataProviderSerial
providerSerial, err = NewDataProviderSerial(*input, 9600)
provider = providerSerial
} else {
provider, err = NewDataProviderFile(*input)
}
if err != nil {
panic(err)
}
df := core.NewFlightData()
logger := NewLogger()
defer logger.Kill()
switch *output {
case OUTPUT_TYPE_TEXT:
err = StartTextLogger(provider, &df, logger)
case OUTPUT_TYPE_WEB:
err = StartWeb(provider, &df, logger)
}
if err != nil {
panic(err)
}
}

233
ground/dashboard/render.go Normal file
View File

@ -0,0 +1,233 @@
package main
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"path"
"sync"
"time"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"github.com/gorilla/websocket"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
const SecondsWindow = 20
//go:embed static/**
var static embed.FS
type staticFS struct {
content embed.FS
}
func (c staticFS) Open(name string) (fs.File, error) {
return c.content.Open(path.Join("static", name))
}
func StartTextLogger(p DataProvider, ds core.FlightData, logger LoggerControl) error {
if err := ui.Init(); err != nil {
return err
}
defer ui.Close()
headers := []string{
"Time",
"Prog",
"Pressure",
"Temp",
"Accel X",
"Accel Y",
"Accel Z",
"Mag X",
"Mag Y",
"Mag Z",
"Lat",
"Lon",
"Sats",
"Qual",
"RSSI",
}
grid := ui.NewGrid()
termWidth, termHeight := ui.TerminalDimensions()
grid.SetRect(0, 0, termWidth, termHeight)
table := widgets.NewTable()
table.Title = "Data Stream"
table.Rows = [][]string{headers}
errorsui := widgets.NewList()
errorsui.Title = "Errors"
errorsui.Rows = []string{}
errorsui.WrapText = false
errorsList := make([]error, 0)
grid.Set(
ui.NewRow(0.8,
ui.NewCol(1.0, table),
),
ui.NewRow(0.2,
ui.NewCol(1.0, errorsui),
),
)
uiEvents := ui.PollEvents()
streamChannel := p.Stream()
renderTable := func() {
data := ds.AllSegments()
if len(data) == 0 {
ui.Render(grid)
return
}
nRows := (table.Inner.Dy() + 1) / 2
if nRows <= 0 {
nRows = 10
}
if nRows > len(data)+1 {
nRows = len(data) + 1
}
rows := make([][]string, nRows)
rows[0] = headers
for i := 0; i < nRows-1; i++ {
j := len(data) - nRows + 1 + i
seg := data[j]
rows[i+1] = []string{
fmt.Sprintf("%0.2f", seg.Raw.Timestamp),
fmt.Sprintf("%0.2f", seg.Raw.WriteProgress),
fmt.Sprintf("%0.2f", seg.Raw.Pressure),
fmt.Sprintf("%0.2f", seg.Raw.Temperature),
fmt.Sprintf("%0.2f", seg.Raw.Acceleration.X),
fmt.Sprintf("%0.2f", seg.Raw.Acceleration.Y),
fmt.Sprintf("%0.2f", seg.Raw.Acceleration.Z),
fmt.Sprintf("%0.2f", seg.Raw.Magnetic.X),
fmt.Sprintf("%0.2f", seg.Raw.Magnetic.Y),
fmt.Sprintf("%0.2f", seg.Raw.Magnetic.Z),
fmt.Sprintf("%0.2f", seg.Raw.Coordinate.Lat),
fmt.Sprintf("%0.2f", seg.Raw.Coordinate.Lon),
fmt.Sprintf("%0.2f", seg.Raw.GPSInfo.Sats),
fmt.Sprintf("%0.2f", seg.Raw.GPSInfo.Quality),
fmt.Sprintf("%d", seg.Raw.Rssi),
}
}
table.Rows = rows
ui.Render(grid)
}
renderErrors := func() {
if len(errorsList) == 0 {
ui.Render(grid)
return
}
nRows := errorsui.Inner.Dy()
if nRows <= 0 {
nRows = 10
}
if nRows > len(errorsList) {
nRows = len(errorsList)
}
rows := make([]string, nRows)
for i := 0; i < nRows; i++ {
j := len(errorsList) - nRows + i
rows[i] = fmt.Sprint(errorsList[j])
}
errorsui.Rows = rows
ui.Render(grid)
}
renderTable()
for {
select {
case e := <-uiEvents:
switch e.ID {
case "q", "<C-c>":
return nil
}
case bytes := <-streamChannel:
latestSegments, basePressure, origin, err := bytesToDataSegment(ds, bytes)
if err != nil {
errorsList = append(errorsList, err)
renderErrors()
} else {
ds.AppendData(latestSegments)
ds.SetBasePressure(basePressure)
ds.SetOrigin(origin)
renderTable()
for _, seg := range latestSegments {
logger.Log(seg)
}
}
}
}
}
func StartWeb(p DataProvider, ds core.FlightData, logger LoggerControl) error {
var mutex sync.Mutex
var upgrader = websocket.Upgrader{}
http.HandleFunc("/api/data", func(w http.ResponseWriter, req *http.Request) {
c, err := upgrader.Upgrade(w, req, nil)
if err != nil {
fmt.Println(err)
return
}
defer c.Close()
mutex.Lock()
data := ds.AllSegments()
lastLength := len(data)
err = c.WriteJSON(data)
mutex.Unlock()
if err != nil {
fmt.Println(err)
return
}
for {
mutex.Lock()
data = ds.AllSegments()
if len(data) > lastLength {
err = c.WriteJSON(data[lastLength:])
if err != nil {
fmt.Println(err)
mutex.Unlock()
return
}
lastLength = len(data)
}
mutex.Unlock()
time.Sleep(1 * time.Second)
}
})
if os.Getenv("DEV_MODE") == "" {
http.Handle("/", http.FileServer(http.FS(staticFS{static})))
} else {
http.Handle("/", http.FileServer(http.Dir("dashboard/static")))
}
go func() {
streamChannel := p.Stream()
for {
bytes := <-streamChannel
latestSegments, basePressure, origin, err := bytesToDataSegment(ds, bytes)
if err != nil {
fmt.Println(err)
} else {
mutex.Lock()
ds.AppendData(latestSegments)
ds.SetBasePressure(basePressure)
ds.SetOrigin(origin)
mutex.Unlock()
for _, seg := range latestSegments {
logger.Log(seg)
}
}
}
}()
return http.ListenAndServe(":8080", nil)
}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, width=device-width" />
<title></title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<link href="style/style.css" rel="stylesheet" />
</head>
<body>
<div id="main"></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js" integrity="sha256-bC3LCZCwKeehY6T4fFi9VfOU0gztUa+S4cnkIhVPZ5E=" crossorigin="anonymous"></script>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossorigin=""></script>
<script type="text/javascript" src="js/util.js"></script>
<script type="text/javascript" src="js/widget.js"></script>
<script type="text/javascript" src="js/lineChartWidget.js"></script>
<script type="text/javascript" src="js/kvTableWidget.js"></script>
<script type="text/javascript" src="js/mapWidget.js"></script>
<script type="text/javascript" src="js/missionInfoWidget.js"></script>
<script type="text/javascript" src="js/attitudeWidget.js"></script>
<script type="text/javascript" src="js/dashboard.js"></script>
<script type="text/javascript" src="js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
class AttitudeWidget extends Widget {
update(data) {
const angle = this.extractor(data)
this.setDetails(angle.toFixed(2) + '°')
this.arrow.style.transform = `rotate(${angle}deg)`
}
initDOM() {
const attitudeContainer = document.createElement('div')
attitudeContainer.className = 'attitude-container'
this.arrow = document.createElement('div')
this.arrow.className = 'attitude-arrow'
attitudeContainer.appendChild(this.arrow)
return attitudeContainer
}
initContent() {}
}

View File

@ -0,0 +1,96 @@
const signalTimeout = 5000
class Dashboard {
constructor(parent) {
this.parent = parent
this.children = []
this.lastUpdate = null
this.timeout = null
this.data = []
}
attach() {
this.container = document.createElement('div')
this.container.className = 'dashboard'
this.parent.appendChild(this.container)
this.children = [
new MissionInfoWidget('Flight Info', this.container, makeInfoExtractor()),
new LineChartWidget('Altitude', 'm', this.container, makeXYExtractor('computed', 'smoothedAltitude')),
new LineChartWidget('Velocity', 'm/s', this.container, makeXYExtractor('computed', 'smoothedVelocity')),
new LineChartWidget('Temperature', '°C', this.container, makeXYExtractor('computed', 'smoothedTemperature')),
new LineChartWidget('Pressure', 'mBar', this.container, makeXYExtractor('computed', 'smoothedPressure')),
new AttitudeWidget('Pitch', this.container, makeSingleExtractor('computed', 'pitch')),
new AttitudeWidget('Yaw', this.container, makeSingleExtractor('computed', 'yaw')),
new LineChartWidget('RSSI', 'RSSI', this.container, makeXYExtractor('raw', 'rssi')),
new KVTableWidget('Signal Stats', ['Data Points', 'Data Rate', 'Last Event Age', 'Receiving', 'RSSI', 'GPS Num Stats', 'GPS Signal Quality'], this.container, (d) => this.signalStatsExtractor(d)),
new MapWidget('Location', this.container, makeCoordinateExtractor()),
]
this.children.forEach(c => c.attach())
}
update(data) {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
if (data !== null) {
this.data = data
this.container.classList.add('receiving')
} else {
this.container.classList.remove('receiving')
}
this.children.forEach(child => child.update(this.data))
if (data !== null) {
this.lastUpdate = new Date()
}
this.timeout = setTimeout(() => this.update(null), signalTimeout)
}
signalStatsExtractor(data) {
if (data.length === 0) {
return []
}
const lastEventAge = this.lastUpdate === null ? null : (new Date().getTime() - this.lastUpdate.getTime())
return [
{
key: 'Data Points',
value: data.length,
normal: data.length > 0
},
{
key: 'Data Rate',
value: data[data.length - 1].computed.dataRate.toFixed(2) + '/s',
normal: data[data.length - 1].computed.dataRate > 1
},
{
key: 'Last Event Age',
value: lastEventAge === null ? 'Never' : (lastEventAge / 1000).toFixed(2) + 's',
normal: lastEventAge !== null && lastEventAge < signalTimeout
},
{
key: 'Receiving',
value: lastEventAge !== null && lastEventAge < signalTimeout ? 'Yes' : 'No',
normal: lastEventAge !== null && lastEventAge < signalTimeout
},
{
key: 'RSSI',
value: data[data.length - 1].raw.rssi,
normal: data[data.length - 1].raw.rssi > -70
},
{
key: 'GPS Num Stats',
value: data[data.length - 1].raw.gpsInfo.sats,
normal: data[data.length - 1].raw.gpsInfo.sats > 0
},
{
key: 'GPS Signal Quality',
value: data[data.length - 1].raw.gpsInfo.quality,
normal: data[data.length - 1].raw.gpsInfo.quality > 0
}
]
}
}

View File

@ -0,0 +1,54 @@
class KVTableWidget extends Widget {
constructor(title, keys, parent, extractor) {
super(title, parent, extractor)
this.keys = keys
}
update(data) {
const extractedData = this.extractor(data)
extractedData.forEach(({key, value, normal}) => {
if (this.valueTds[key]) {
this.valueTds[key].textContent = value
if (normal) {
this.valueTds[key].classList.add('normal')
} else {
this.valueTds[key].classList.remove('normal')
}
}
})
}
initDOM() {
const table = document.createElement('table')
table.className = 'kv-table'
const thead = document.createElement('thead')
table.appendChild(thead)
const tr = document.createElement('tr')
thead.appendChild(tr)
const headers = ['Attribute', 'Value']
headers.forEach(h => {
const th = document.createElement('th')
th.textContent = h
tr.appendChild(th)
})
const tbody = document.createElement('tbody')
table.appendChild(tbody)
this.valueTds = {}
this.keys.forEach(key => {
const tr = document.createElement('tr')
tbody.appendChild(tr)
const keyTd = document.createElement('td')
keyTd.textContent = key
tr.appendChild(keyTd)
const valueTd = document.createElement('td')
valueTd.className = 'kv-table-value'
tr.appendChild(valueTd)
this.valueTds[key] = valueTd
})
return table
}
initContent() {
}
}

View File

@ -0,0 +1,69 @@
class LineChartWidget extends Widget {
constructor(title, units, parent, extractor) {
super(title, parent, extractor)
this.units = units
}
update(data) {
const extractedData = this.extractor(data)
this.chart.data.datasets[0].data = extractedData
this.chart.update()
if (extractedData.length > 0) {
this.setDetails(`${extractedData[extractedData.length - 1].y.toFixed(2)} ${this.units}`)
}
}
initDOM() {
this.canvas = document.createElement('canvas')
return this.canvas
}
initContent() {
const ctx = this.canvas.getContext('2d')
this.chart = new Chart(ctx, {
type: 'scatter',
data: {
datasets: [{
data: [],
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
showLine: true,
elements: {
point: {
radius: 0
},
line: {
borderColor: '#000',
borderWidth: 1,
}
},
plugins: {
legend: {
display: false
}
},
animation: {
duration: 0,
},
scales: {
x: {
title: {
display: true,
text: 'Seconds'
}
},
y: {
title: {
display: true,
text: this.units
}
}
}
}
})
}
}

View File

@ -0,0 +1,10 @@
(() => {
let data = []
const dashboard = new Dashboard(document.getElementById('main'))
dashboard.attach()
const webSocket = new WebSocket(`ws://${window.location.host}/api/data`)
webSocket.onmessage = (e) => {
data = data.concat(JSON.parse(e.data))
dashboard.update(data)
}
})()

View File

@ -0,0 +1,31 @@
class MapWidget extends Widget {
constructor(title, parent, extractor) {
super(title, parent, extractor)
}
update(data) {
const mapData = this.extractor(data)
this.map.setView(mapData.coordinates, this.map.getZoom())
this.marker.setLatLng(mapData.coordinates)
this.setDetails(`Bearing: ${mapData.bearing.toFixed(2)}°, Distance: ${mapData.distance.toFixed(2)}m`)
}
initDOM() {
this.mapContainer = document.createElement('div')
this.mapContainer.className = 'map-container'
return this.mapContainer
}
initContent() {
this.map = L.map(this.mapContainer).setView([0,0], 17)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map)
this.marker = L.circle([0,0], {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
radius: 5
}).addTo(this.map)
}
}

View File

@ -0,0 +1,36 @@
const modeMap = {
'P': 'Prelaunch',
'AP': 'Powered Ascent',
'AU': 'Unpowered Ascent',
'DF': 'Freefall Descent',
'DP': 'Parachute Descent',
'R': 'Recovery'
}
class MissionInfoWidget extends Widget {
update(data) {
const extractedData = this.extractor(data)
this.time.textContent = formatSeconds(extractedData.time)
this.mode.textContent = modeMap[extractedData.mode]
this.mode.className = ['mission-info-mode', 'mission-info-mode-' + extractedData.mode.toLowerCase()].join(' ')
}
initDOM() {
const container = document.createElement('div')
container.className = 'mission-info-container'
this.time = document.createElement('div')
this.time.className = 'mission-info-time'
container.appendChild(this.time)
this.mode = document.createElement('div')
this.mode.className = 'mission-info-mode'
container.appendChild(this.mode)
return container
}
initContent() {
}
}

View File

@ -0,0 +1,39 @@
function makeXYExtractor(propType, propName) {
return (data) => data.map(segment => ({
x: segment.raw.timestamp,
y: segment[propType][propName]
}))
}
function makeCoordinateExtractor() {
return (data) => ({
coordinates: [data[data.length - 1].raw.coordinate.lat, data[data.length - 1].raw.coordinate.lon],
bearing: data[data.length - 1].computed.bearing,
distance: data[data.length - 1].computed.distance
})
}
function makeInfoExtractor() {
return (data) => ({
pcnt: data[data.length - 1].raw.cameraProgress,
time: data[data.length - 1].raw.timestamp,
mode: data[data.length - 1].computed.flightMode
})
}
function makeSingleExtractor(propType, propName) {
return (data) => data[data.length - 1][propType][propName]
}
function formatSeconds(time) {
const hrs = ~~(time / 3600)
const mins = ~~((time % 3600) / 60)
const secs = ~~time % 60
let ret = ''
if (hrs > 0) {
ret += '' + hrs + ':' + (mins < 10 ? '0' : '')
}
ret += '' + mins + ':' + (secs < 10 ? '0' : '')
ret += '' + secs
return ret
}

View File

@ -0,0 +1,44 @@
class Widget {
constructor(title, parent, extractor) {
this.title = title
this.parent = parent
this.extractor = extractor
}
attach() {
const widget = document.createElement('div')
widget.className = 'widget'
const header = document.createElement('div')
header.className = 'widget-header'
widget.appendChild(header)
const title = document.createElement('div')
title.className = 'widget-title'
title.textContent = this.title
header.appendChild(title)
this.details = document.createElement('div')
this.details.className = 'widget-details'
header.appendChild(this.details)
const content = document.createElement('div')
content.className = 'widget-content'
content.appendChild(this.initDOM())
widget.appendChild(content)
this.parent.appendChild(widget)
this.initContent()
}
setDetails(details) {
this.details.textContent = details
}
update(data) {}
initDOM() {}
initContent() {}
}

View File

@ -0,0 +1,162 @@
body {
margin: 0;
padding: 0;
}
.dashboard {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-gap: 10px;
font-family: 'Courier New', Courier, monospace;
border: solid 5px red;
padding: 10px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: scroll;
}
.dashboard.receiving {
border-color: green;
}
.widget {
border: solid 1px black;
display: flex;
justify-content: space-evenly;
flex-direction: column;
}
.widget-header {
padding: 5px;
background: black;
color: white;
display: flex;
flex-direction: row;
}
.widget-title {
font-weight: bold;
}
.widget-details {
text-align: right;
flex-grow: 1;
}
.widget-content {
padding: 5px;
flex-grow: 1;
position: relative;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
}
.widget-content table {
width: 100%;
}
.widget-content table th {
width: 50%;
}
.map-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.attitude-container {
position: absolute;
right: 10px;
left: 10px;
height: 0;
padding-bottom: 100%;
background: gray;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
}
.attitude-arrow {
width: 0;
height: 0;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
border-bottom: 200px solid white;
margin-top: 100%;
}
.mission-info-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mission-info-time {
font-size: 5em;
}
.mission-info-mode {
padding: 5px;
min-width: 75%;
text-align: center;
font-size: 1.5em;
color: black;
}
.mission-info-mode-p {
background-color: gray;
}
.mission-info-mode-ap {
background-color: green;
}
.mission-info-mode-au {
background-color: yellow;
}
.mission-info-mode-df {
background-color: red;
}
.mission-info-mode-dp {
background-color: orange;
}
.mission-info-mode-r {
background-color: blue;
}
.kv-table td {
border: solid 1px gray;
}
.kv-table thead {
background: black;
color: white;
}
.kv-table-value {
text-align: center;
background: red;
}
.kv-table-value.normal {
background: green;
}

View File

@ -0,0 +1,98 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/binary"
"errors"
"math"
"strings"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
func telemetryFloatFromByteIndex(bytes []byte, index int) float64 {
start := 8 * index
end := start + 8
if start >= len(bytes) || end >= len(bytes) {
return 0
}
bits := binary.LittleEndian.Uint64(bytes[start:end])
float := math.Float64frombits(bits)
return float
}
func telemetryIntFromBytes(b []byte) int16 {
buffer := bytes.NewReader(b)
if len(b) != 2 {
return 0
}
var val int16
binary.Read(buffer, binary.LittleEndian, &val)
return val
}
func decodeTelemetryBytes(bytes []byte) ([]byte, []byte, error) {
parts := strings.Split(string(bytes), ",")
if len(parts) != 3 || parts[0] != "T" {
return nil, nil, errors.New("bad telemetry")
}
telemetryBytes, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return nil, nil, err
}
rssiBytes, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return nil, nil, err
}
return telemetryBytes, rssiBytes, nil
}
func bytesToDataSegment(stream core.FlightData, bytes []byte) ([]core.DataSegment, float64, core.Coordinate, error) {
telemetryBytes, rssiBytes, err := decodeTelemetryBytes(bytes)
if err != nil {
return nil, 0, core.Coordinate{}, err
}
segments := make([]core.DataSegment, PointsPerDataFrame)
var basePressure float64
var origin core.Coordinate
for i := len(segments) - 1; i >= 0; i-- {
offset := 1 + (i * 13)
raw := core.RawDataSegment{
WriteProgress: telemetryFloatFromByteIndex(telemetryBytes, 0),
Timestamp: telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexTimestamp),
Pressure: telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexPressure),
Temperature: telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexTemperature),
Acceleration: core.XYZ{
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexAccelerationX),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexAccelerationY),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexAccelerationZ),
},
Magnetic: core.XYZ{
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexMagneticX),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexMagneticY),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexMagneticZ),
},
Coordinate: core.Coordinate{
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexCoordinateLat),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexCoordinateLon),
},
GPSInfo: core.GPSInfo{
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexGpsQuality),
telemetryFloatFromByteIndex(telemetryBytes, offset+core.IndexGpsSats),
},
Rssi: telemetryIntFromBytes(rssiBytes),
}
var computed core.ComputedDataSegment
computed, basePressure, origin = core.ComputeDataSegment(stream, raw)
segments[i] = core.DataSegment{
raw,
computed,
}
}
return segments, basePressure, origin, nil
}

View File

@ -0,0 +1,27 @@
package main
import (
"encoding/base64"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTelemetryFloatFromByteIndex(t *testing.T) {
b64 := "AAAAAAAA8D8AAAAAAAAAQAAAAAAAAAhA"
data, _ := base64.StdEncoding.DecodeString(b64)
n := telemetryFloatFromByteIndex(data, 1)
assert.Equal(t, n, 2.0)
}
func TestTelemetryIntFromBytes(t *testing.T) {
n := telemetryIntFromBytes([]byte{0x01, 0x00})
assert.Equal(t, n, int16(1))
}
func TestDecodeTelemetryBytes(t *testing.T) {
telemetryBytes, rssiBytes, err := decodeTelemetryBytes([]byte("T,wcqhRbbz3T8NAIDUU3JoQGzPymub5/dAAQAM3C/rIkCoKRPINnrovxg/EbSXB+Y/qdDM1YdeMMD+XHTRRRctQAAAAAAAgELAT6wPjfWhL0D2QZYFE2dDQCC4yhMIQ1PAAAAAAAAAAAAAAAAAAAAAAA==,//8="))
assert.NotNil(t, telemetryBytes)
assert.NotNil(t, rssiBytes)
assert.Nil(t, err)
}

31
ground/dashboard/types.go Normal file
View File

@ -0,0 +1,31 @@
package main
import (
"io"
"sync"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type Logger struct {
DataChannel chan core.DataSegment
ContinueRunning bool
Mutex sync.Mutex
}
type LoggerControl interface {
Kill()
Log(core.DataSegment)
}
type DataProvider interface {
Stream() <-chan []byte
}
type DataProviderFile struct {
Bytes [][]byte
}
type DataProviderSerial struct {
Port io.ReadWriteCloser
}

20
ground/tool/Makefile Normal file
View File

@ -0,0 +1,20 @@
.PHONY: generate_test_data run
OS=$(shell uname)
ARCH=$(shell arch)
install:
go get ./...
go get -t ./...
run:
go run . $(source)
build:
go build -o build/white-vest-tools-$(OS)-$(ARCH) .
test:
go test .
clean:
rm -rf build

View File

@ -0,0 +1,47 @@
package charts
import (
"bytes"
"os"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
"github.com/wcharczuk/go-chart/v2"
)
type AltitudeChart struct {
filePath string
}
func NewAltitudeChart(f string) ChartTask {
return AltitudeChart{f}
}
func (c AltitudeChart) Generate(offsetSeconds float64, fd []core.DataSegment) error {
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
Name: "Altitude",
XValues: singleFlightDataElement(fd, func(d core.DataSegment) float64 { return d.Raw.Timestamp - offsetSeconds }),
YValues: singleFlightDataElement(fd, func(d core.DataSegment) float64 { return d.Computed.SmoothedAltitude }),
},
},
XAxis: chart.XAxis{
Name: "Seconds",
},
YAxis: chart.YAxis{
Name: "Meters",
},
}
buffer := bytes.NewBuffer([]byte{})
err := graph.Render(chart.PNG, buffer)
if err != nil {
return err
}
err = os.WriteFile(c.filePath, buffer.Bytes(), 0777)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,7 @@
package charts
import "github.com/johnjones4/model-rocket-telemetry/dashboard/core"
type ChartTask interface {
Generate(offsetSeconds float64, fd []core.DataSegment) error
}

View File

@ -0,0 +1,11 @@
package charts
import "github.com/johnjones4/model-rocket-telemetry/dashboard/core"
func singleFlightDataElement(fd []core.DataSegment, accessor func(core.DataSegment) float64) []float64 {
data := make([]float64, len(fd))
for i, segment := range fd {
data[i] = accessor(segment)
}
return data
}

View File

@ -0,0 +1,47 @@
package charts
import (
"bytes"
"os"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
"github.com/wcharczuk/go-chart/v2"
)
type VelocityChart struct {
filePath string
}
func NewVelocityChart(f string) ChartTask {
return VelocityChart{f}
}
func (c VelocityChart) Generate(offsetSeconds float64, fd []core.DataSegment) error {
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
Name: "Velocity",
XValues: singleFlightDataElement(fd, func(d core.DataSegment) float64 { return d.Raw.Timestamp - offsetSeconds }),
YValues: singleFlightDataElement(fd, func(d core.DataSegment) float64 { return d.Computed.SmoothedVelocity }),
},
},
XAxis: chart.XAxis{
Name: "Seconds",
},
YAxis: chart.YAxis{
Name: "Meters/Second",
},
}
buffer := bytes.NewBuffer([]byte{})
err := graph.Render(chart.PNG, buffer)
if err != nil {
return err
}
err = os.WriteFile(c.filePath, buffer.Bytes(), 0777)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,82 @@
package conversion
import (
"encoding/csv"
"os"
"strconv"
"github.com/cheggaaa/pb/v3"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type InboardReader struct {
filePath string
}
func NewInboardReader(f string) InboardReader {
return InboardReader{f}
}
func (i InboardReader) Read(showProgress bool) (core.FlightData, error) {
f, err := os.Open(i.filePath)
if err != nil {
return nil, err
}
defer f.Close()
csvReader := csv.NewReader(f)
data, err := csvReader.ReadAll()
if err != nil {
return nil, err
}
fd := core.NewFlightData()
var bar *pb.ProgressBar
if showProgress {
bar = pb.StartNew(len(data))
}
for i, row := range data {
rawSeg := core.RawDataSegment{
WriteProgress: 0,
Timestamp: quietParseFloat(row, core.IndexTimestamp),
Pressure: quietParseFloat(row, core.IndexPressure),
Temperature: quietParseFloat(row, core.IndexTemperature),
Acceleration: core.XYZ{
X: quietParseFloat(row, core.IndexAccelerationX),
Y: quietParseFloat(row, core.IndexAccelerationY),
Z: quietParseFloat(row, core.IndexAccelerationZ),
},
Magnetic: core.XYZ{
X: quietParseFloat(row, core.IndexMagneticX),
Y: quietParseFloat(row, core.IndexMagneticY),
Z: quietParseFloat(row, core.IndexMagneticZ),
},
Coordinate: core.Coordinate{
Lat: quietParseFloat(row, core.IndexCoordinateLat),
Lon: quietParseFloat(row, core.IndexCoordinateLon),
},
GPSInfo: core.GPSInfo{
Quality: quietParseFloat(row, core.IndexGpsQuality),
Sats: quietParseFloat(row, core.IndexGpsSats),
},
Rssi: 0,
}
computed, basePressure, origin := core.ComputeDataSegment(&fd, rawSeg)
fd.AppendData([]core.DataSegment{{
Raw: rawSeg,
Computed: computed,
}})
fd.SetBasePressure(basePressure)
fd.SetOrigin(origin)
if showProgress {
bar.SetCurrent(int64(i))
}
}
if showProgress {
bar.Finish()
}
return &fd, nil
}
func quietParseFloat(row []string, i int) float64 {
f, _ := strconv.ParseFloat(row[i], 64)
return f
}

View File

@ -0,0 +1,7 @@
package conversion
import "github.com/johnjones4/model-rocket-telemetry/dashboard/core"
type Reader interface {
Read(showProgress bool) (core.FlightData, error)
}

12
ground/tool/go.mod Normal file
View File

@ -0,0 +1,12 @@
module main
go 1.16
replace github.com/johnjones4/model-rocket-telemetry/dashboard/core => ../core
require (
github.com/cheggaaa/pb/v3 v3.0.8
github.com/johnjones4/model-rocket-telemetry/dashboard/core v0.0.0-00010101000000-000000000000
github.com/wcharczuk/go-chart/v2 v2.1.0
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
)

39
ground/tool/go.sum Normal file
View File

@ -0,0 +1,39 @@
github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdcM=
github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
github.com/cheggaaa/pb/v3 v3.0.8 h1:bC8oemdChbke2FHIIGy9mn4DPJ2caZYQnfbRqwmdCoA=
github.com/cheggaaa/pb/v3 v3.0.8/go.mod h1:UICbiLec/XO6Hw6k+BHEtHeQFzzBH4i2/qk/ow1EJTA=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 h1:F5Gozwx4I1xtr/sr/8CFbb57iKi3297KFs0QDbGN60A=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

27
ground/tool/main.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"log"
"os"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("not enough arguments")
}
cmds := []task{
newTaskConvert(),
newTaskSummary(),
newTaskChart(),
}
for _, cmd := range cmds {
if cmd.name() == os.Args[1] {
cmd.FlagSet().Parse(os.Args[2:])
err := cmd.run()
if err != nil {
log.Panic(err)
}
return
}
}
}

View File

@ -0,0 +1,33 @@
package summerizers
import (
"errors"
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type ApogeeSummerizer struct {
altitude float64
}
func (s *ApogeeSummerizer) Generate(fd []core.DataSegment) error {
s.altitude = 0
for _, d := range fd {
if d.Computed.SmoothedAltitude > s.altitude && d.Computed.FlightMode == core.ModeAscentUnpowered {
s.altitude = d.Computed.SmoothedAltitude
}
}
if s.altitude == 0 {
return errors.New("cannot determine apogee")
}
return nil
}
func (s *ApogeeSummerizer) Value() string {
return fmt.Sprintf("%f m", s.altitude)
}
func (s *ApogeeSummerizer) Name() string {
return "Apogee"
}

View File

@ -0,0 +1,47 @@
package summerizers
import (
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type ModeTimeSummerizer struct {
time float64
mode core.FlightMode
}
func NewModeTimeSummerizer(m core.FlightMode) Summarizer {
return &ModeTimeSummerizer{0, m}
}
func (s *ModeTimeSummerizer) Generate(fd []core.DataSegment) error {
s.time = timeInMode(fd, s.mode)
if s.time == 0 {
return fmt.Errorf("cannot determine time in %s", s.mode)
}
return nil
}
func (s *ModeTimeSummerizer) Value() string {
return fmt.Sprintf("%f s", s.time)
}
func (s *ModeTimeSummerizer) Name() string {
switch s.mode {
case core.ModePrelaunch:
return "Prelaunch"
case core.ModeAscentPowered:
return "Powered Ascent"
case core.ModeAscentUnpowered:
return "Unpowered Ascent"
case core.ModeDescentFreefall:
return "Freefall Descent"
case core.ModeDescentParachute:
return "Controlled Descent"
case core.ModeRecovery:
return "Recovery"
default:
return ""
}
}

View File

@ -0,0 +1,36 @@
package summerizers
import (
"errors"
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type OriginSummerizer struct {
origin core.Coordinate
originSet bool
}
func (s *OriginSummerizer) Generate(fd []core.DataSegment) error {
s.origin = core.Coordinate{}
s.originSet = false
for _, d := range fd {
if !s.originSet && d.Computed.FlightMode == core.ModeAscentPowered {
s.origin = d.Raw.Coordinate
s.originSet = true
}
}
if !s.originSet {
return errors.New("cannot determine origin")
}
return nil
}
func (s *OriginSummerizer) Value() string {
return fmt.Sprintf("%f/%f", s.origin.Lat, s.origin.Lon)
}
func (s *OriginSummerizer) Name() string {
return "Origin"
}

View File

@ -0,0 +1,36 @@
package summerizers
import (
"errors"
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type TouchdownSummerizer struct {
touchdown core.Coordinate
touchdownSet bool
}
func (s *TouchdownSummerizer) Generate(fd []core.DataSegment) error {
s.touchdown = core.Coordinate{}
s.touchdownSet = false
for _, d := range fd {
if !s.touchdownSet && d.Computed.FlightMode == core.ModeRecovery {
s.touchdown = d.Raw.Coordinate
s.touchdownSet = true
}
}
if !s.touchdownSet {
return errors.New("cannot determine touchdown")
}
return nil
}
func (s *TouchdownSummerizer) Value() string {
return fmt.Sprintf("%f/%f", s.touchdown.Lat, s.touchdown.Lon)
}
func (s *TouchdownSummerizer) Name() string {
return "Touchdown"
}

View File

@ -0,0 +1,33 @@
package summerizers
import (
"errors"
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type TravelSummerizer struct {
distance float64
}
func (s *TravelSummerizer) Generate(fd []core.DataSegment) error {
s.distance = 0
for _, d := range fd {
if d.Computed.Distance > s.distance && d.Computed.FlightMode == core.ModeRecovery {
s.distance = d.Computed.Distance
}
}
if s.distance == 0 {
return errors.New("cannot determine travel distance")
}
return nil
}
func (s *TravelSummerizer) Value() string {
return fmt.Sprintf("%f m", s.distance)
}
func (s *TravelSummerizer) Name() string {
return "Travel Distance"
}

View File

@ -0,0 +1,9 @@
package summerizers
import "github.com/johnjones4/model-rocket-telemetry/dashboard/core"
type Summarizer interface {
Generate(fd []core.DataSegment) error
Name() string
Value() string
}

View File

@ -0,0 +1,15 @@
package summerizers
import "github.com/johnjones4/model-rocket-telemetry/dashboard/core"
func timeInMode(fd []core.DataSegment, mode core.FlightMode) float64 {
startTime := -1.0
for _, d := range fd {
if d.Computed.FlightMode == mode && startTime < 0 {
startTime = d.Raw.Timestamp
} else if d.Computed.FlightMode != mode && startTime > 0 {
return d.Raw.Timestamp - startTime
}
}
return 0
}

View File

@ -0,0 +1,34 @@
package summerizers
import (
"errors"
"fmt"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type VelocitySummerizer struct {
mV float64
}
func (s *VelocitySummerizer) Generate(fd []core.DataSegment) error {
s.mV = 0
for _, d := range fd {
//TODO fix mode checking
if d.Computed.SmoothedVelocity > s.mV && d.Computed.FlightMode == core.ModeAscentPowered {
s.mV = d.Computed.SmoothedVelocity
}
}
if s.mV == 0 {
return errors.New("cannot determine max v")
}
return nil
}
func (s *VelocitySummerizer) Value() string {
return fmt.Sprintf("%f m/s", s.mV)
}
func (s *VelocitySummerizer) Name() string {
return "Max Velocity"
}

55
ground/tool/task_chart.go Normal file
View File

@ -0,0 +1,55 @@
package main
import (
"flag"
"log"
"main/charts"
"path"
)
type taskChart struct {
fs *flag.FlagSet
input *string
output *string
}
func newTaskChart() taskChart {
tc := taskChart{
fs: flag.NewFlagSet("chart", flag.ContinueOnError),
}
tc.input = tc.fs.String("input", "", "Input file path")
tc.output = tc.fs.String("output", "", "Output folder")
return tc
}
func (t taskChart) FlagSet() *flag.FlagSet {
return t.fs
}
func (t taskChart) name() string {
return t.fs.Name()
}
func (t taskChart) run() error {
log.Printf("Reading light data from %s\n", *t.input)
fd, err := flightDataFromFile(*t.input)
if err != nil {
return err
}
charts := []charts.ChartTask{
charts.NewAltitudeChart(path.Join(*t.output, "altitude.png")),
charts.NewVelocityChart(path.Join(*t.output, "velocity.png")),
}
offset := determineOffsetSeconds(fd)
for _, c := range charts {
err = c.Generate(offset, fd)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,72 @@
package main
import (
"encoding/json"
"errors"
"flag"
"log"
"main/conversion"
"os"
)
const (
DataTypeInboard = "inboard"
)
type taskConvert struct {
fs *flag.FlagSet
input *string
output *string
dtype *string
progress *bool
}
func newTaskConvert() taskConvert {
tc := taskConvert{
fs: flag.NewFlagSet("convert", flag.ContinueOnError),
}
tc.input = tc.fs.String("input", "", "Input file path")
tc.dtype = tc.fs.String("type", DataTypeInboard, "Data type (inboard or ground)")
tc.output = tc.fs.String("output", "converted_flight_data.json", "Output file path")
tc.progress = tc.fs.Bool("progress", true, "Show progress")
return tc
}
func (t taskConvert) FlagSet() *flag.FlagSet {
return t.fs
}
func (t taskConvert) name() string {
return t.fs.Name()
}
func (t taskConvert) run() error {
var readerInst conversion.Reader
switch *t.dtype {
case DataTypeInboard:
log.Printf("Will read inboard data from %s\n", *t.input)
readerInst = conversion.NewInboardReader(*t.input)
}
if readerInst == nil {
return errors.New("data type not supported")
}
fd, err := readerInst.Read(*t.progress)
if err != nil {
return err
}
log.Println("Converting data")
fdData, err := json.Marshal(fd.AllSegments())
if err != nil {
return err
}
log.Println("Writing data")
err = os.WriteFile(*t.output, fdData, 0777)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,64 @@
package main
import (
"flag"
"log"
"main/summerizers"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
type taskSummary struct {
fs *flag.FlagSet
input *string
}
func newTaskSummary() taskSummary {
tc := taskSummary{
fs: flag.NewFlagSet("summary", flag.ContinueOnError),
}
tc.input = tc.fs.String("input", "", "Input file path")
return tc
}
func (t taskSummary) FlagSet() *flag.FlagSet {
return t.fs
}
func (t taskSummary) name() string {
return t.fs.Name()
}
func (t taskSummary) run() error {
log.Printf("Reading light data from %s\n", *t.input)
fd, err := flightDataFromFile(*t.input)
if err != nil {
return err
}
summerizers := []summerizers.Summarizer{
&summerizers.ApogeeSummerizer{},
&summerizers.VelocitySummerizer{},
&summerizers.TravelSummerizer{},
&summerizers.OriginSummerizer{},
&summerizers.TouchdownSummerizer{},
summerizers.NewModeTimeSummerizer(core.ModeAscentPowered),
summerizers.NewModeTimeSummerizer(core.ModeAscentUnpowered),
summerizers.NewModeTimeSummerizer(core.ModeDescentFreefall),
summerizers.NewModeTimeSummerizer(core.ModeDescentParachute),
}
for _, s := range summerizers {
log.Printf("Calculating %s\n", s.Name())
err = s.Generate(fd)
if err != nil {
return err
}
}
for _, s := range summerizers {
log.Printf("%s: %s\n", s.Name(), s.Value())
}
return nil
}

11
ground/tool/types.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"flag"
)
type task interface {
FlagSet() *flag.FlagSet
name() string
run() error
}

32
ground/tool/util.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"encoding/json"
"os"
"github.com/johnjones4/model-rocket-telemetry/dashboard/core"
)
func flightDataFromFile(input string) ([]core.DataSegment, error) {
bytes, err := os.ReadFile(input)
if err != nil {
return nil, err
}
var segs []core.DataSegment
err = json.Unmarshal(bytes, &segs)
if err != nil {
return nil, err
}
return segs, err
}
func determineOffsetSeconds(ds []core.DataSegment) float64 {
for _, d := range ds {
if d.Computed.FlightMode == core.ModeAscentPowered {
return d.Raw.Timestamp
}
}
return 0
}

157
receiver/receiver.ino Normal file
View File

@ -0,0 +1,157 @@
#include <SPI.h>
#include <stdbool.h>
#include <string.h>
#define TRIGGER_FLIGHT_TIME '#'
#define TRIGGER_ALTITUDE_100 '{'
#define TRIGGER_ALTITUDE '<'
#define TRIGGER_VELOCITY '('
#define TRIGGER_ACCELERATION_10 '\\'
#define TRIGGER_FLIGHT_PHASE '@'
#define TRIGGER_CHANNEL '~'
#define TRIGGER_TEMPERATURE_10 '!'
#define TRIGGER_NAME '='
#define TRIGGER_BATERY_10 '?'
#define TRIGGER_APOGEE '%'
#define TRIGGER_MAX_VELOCITY '^'
#define TRIGGER_MAX_ACCELERATION '['
#define TRIGGER_END_PACKET '>'
typedef union EggtimerData
{
int fight_time;
int altitude_100;
int altitude;
int velocity;
int acceleration_10;
int fight_phase;
char channel[6];
int temperature_10;
char name[9];
int battery_voltage_10;
int apogee;
int max_velocity;
int max_acceleration;
}EggtimerData;
typedef struct EggtimerElementPacket{
char type;
EggtimerData data;
} EggtimerElementPacket;
static char _last_state = 0;
static size_t counter = 0;
/**
* @brief this function stores the data in the element packet
*/
void _save_data(char byte_received, EggtimerElementPacket *element){
switch (_last_state)
{
case TRIGGER_FLIGHT_TIME:
case TRIGGER_ALTITUDE_100:
case TRIGGER_ALTITUDE:
case TRIGGER_VELOCITY:
case TRIGGER_ACCELERATION_10:
case TRIGGER_FLIGHT_PHASE:
case TRIGGER_TEMPERATURE_10:
case TRIGGER_BATERY_10:
case TRIGGER_APOGEE:
case TRIGGER_MAX_VELOCITY:
case TRIGGER_MAX_ACCELERATION:
element->data.altitude *= 10;
element->data.altitude += byte_received - '0';
break;
case TRIGGER_CHANNEL:
case TRIGGER_NAME:
element->data.name[counter++] = byte_received;
break;
default:
break;
}
}
/**
* @brief This function is used to parse the data received from the Eggtimer
* @param byte_received byte received
* @param packet package to store the data
*
* @return true if receive new data packet, false otherwise
*/
bool decode_eggtimer_data(char byte_received, EggtimerElementPacket * packet){
switch (byte_received)
{
case TRIGGER_FLIGHT_TIME:
case TRIGGER_ALTITUDE_100:
case TRIGGER_ALTITUDE:
case TRIGGER_VELOCITY:
case TRIGGER_ACCELERATION_10:
case TRIGGER_FLIGHT_PHASE:
case TRIGGER_CHANNEL:
case TRIGGER_TEMPERATURE_10:
case TRIGGER_NAME:
case TRIGGER_BATERY_10:
case TRIGGER_APOGEE:
case TRIGGER_MAX_VELOCITY:
case TRIGGER_MAX_ACCELERATION:
_last_state = byte_received;
packet->type = byte_received;
memset(&packet->data, 0, sizeof packet->data);
Serial.println( byte_received);
counter = 0;
break;
case TRIGGER_END_PACKET:
if (_last_state){
packet->type = _last_state;
_last_state = 0;
return true;
}
_last_state = 0;
break;
default:
_save_data(byte_received, packet);
break;
}
return false;
}
bool test(){
char str[] = "{004>@5>#0132>~1B---->(00023>\\000>%04679>^0660>[025>?079>!201>=KM6ZFL>";
char *p = str;
EggtimerElementPacket packet;
for (; *p; p++)
{
if(decode_eggtimer_data(*p, &packet)){
Serial.print("Data packet: ");
if(packet.type == TRIGGER_NAME || packet.type == TRIGGER_CHANNEL){
Serial.println(packet.data.name);
}
else{
Serial.println(packet.data.altitude);
}
}
}
return false;
}
void setup() {
Serial.begin(9600);
while (!Serial);
Serial.println("Ready!");
test();
}
void loop() {
}