From a9214847c45d6a9b34735781dc1fea603bc7f446 Mon Sep 17 00:00:00 2001 From: Afonso Baldo Date: Sat, 3 Dec 2022 18:55:43 +0000 Subject: [PATCH] WhiteVests and code to parse data from eggtimer --- ground/.gitignore | 5 + ground/core/computed.go | 200 +++++++++++++++ ground/core/computed_test.go | 176 +++++++++++++ ground/core/consts.go | 26 ++ ground/core/flight_data.go | 85 +++++++ ground/core/go.mod | 5 + ground/core/go.sum | 10 + ground/core/types.go | 77 ++++++ ground/core/util.go | 22 ++ ground/dashboard/Makefile | 20 ++ ground/dashboard/consts.go | 5 + ground/dashboard/data_provider.go | 78 ++++++ ground/dashboard/file | 0 ground/dashboard/go.mod | 14 ++ ground/dashboard/go.sum | 25 ++ ground/dashboard/logger.go | 73 ++++++ ground/dashboard/main.go | 47 ++++ ground/dashboard/render.go | 233 ++++++++++++++++++ ground/dashboard/static/index.html | 28 +++ ground/dashboard/static/js/attitudeWidget.js | 18 ++ ground/dashboard/static/js/dashboard.js | 96 ++++++++ ground/dashboard/static/js/kvTableWidget.js | 54 ++++ ground/dashboard/static/js/lineChartWidget.js | 69 ++++++ ground/dashboard/static/js/main.js | 10 + ground/dashboard/static/js/mapWidget.js | 31 +++ .../dashboard/static/js/missionInfoWidget.js | 36 +++ ground/dashboard/static/js/util.js | 39 +++ ground/dashboard/static/js/widget.js | 44 ++++ ground/dashboard/static/style/style.css | 162 ++++++++++++ ground/dashboard/telemetryio.go | 98 ++++++++ ground/dashboard/telemetryio_test.go | 27 ++ ground/dashboard/types.go | 31 +++ ground/tool/Makefile | 20 ++ ground/tool/charts/altitude.go | 47 ++++ ground/tool/charts/types.go | 7 + ground/tool/charts/util.go | 11 + ground/tool/charts/velocity.go | 47 ++++ ground/tool/conversion/inboard_reader.go | 82 ++++++ ground/tool/conversion/types.go | 7 + ground/tool/go.mod | 12 + ground/tool/go.sum | 39 +++ ground/tool/main.go | 27 ++ ground/tool/summerizers/altitude.go | 33 +++ ground/tool/summerizers/mode_time.go | 47 ++++ ground/tool/summerizers/origin.go | 36 +++ ground/tool/summerizers/touchdown.go | 36 +++ ground/tool/summerizers/travel.go | 33 +++ ground/tool/summerizers/types.go | 9 + ground/tool/summerizers/util.go | 15 ++ ground/tool/summerizers/velocity.go | 34 +++ ground/tool/task_chart.go | 55 +++++ ground/tool/task_convert.go | 72 ++++++ ground/tool/task_summary.go | 64 +++++ ground/tool/types.go | 11 + ground/tool/util.go | 32 +++ receiver/receiver.ino | 157 ++++++++++++ 56 files changed, 2777 insertions(+) create mode 100644 ground/.gitignore create mode 100644 ground/core/computed.go create mode 100644 ground/core/computed_test.go create mode 100644 ground/core/consts.go create mode 100644 ground/core/flight_data.go create mode 100644 ground/core/go.mod create mode 100644 ground/core/go.sum create mode 100644 ground/core/types.go create mode 100644 ground/core/util.go create mode 100644 ground/dashboard/Makefile create mode 100644 ground/dashboard/consts.go create mode 100644 ground/dashboard/data_provider.go create mode 100644 ground/dashboard/file create mode 100644 ground/dashboard/go.mod create mode 100644 ground/dashboard/go.sum create mode 100644 ground/dashboard/logger.go create mode 100644 ground/dashboard/main.go create mode 100644 ground/dashboard/render.go create mode 100644 ground/dashboard/static/index.html create mode 100644 ground/dashboard/static/js/attitudeWidget.js create mode 100644 ground/dashboard/static/js/dashboard.js create mode 100644 ground/dashboard/static/js/kvTableWidget.js create mode 100644 ground/dashboard/static/js/lineChartWidget.js create mode 100644 ground/dashboard/static/js/main.js create mode 100644 ground/dashboard/static/js/mapWidget.js create mode 100644 ground/dashboard/static/js/missionInfoWidget.js create mode 100644 ground/dashboard/static/js/util.js create mode 100644 ground/dashboard/static/js/widget.js create mode 100644 ground/dashboard/static/style/style.css create mode 100644 ground/dashboard/telemetryio.go create mode 100644 ground/dashboard/telemetryio_test.go create mode 100644 ground/dashboard/types.go create mode 100644 ground/tool/Makefile create mode 100644 ground/tool/charts/altitude.go create mode 100644 ground/tool/charts/types.go create mode 100644 ground/tool/charts/util.go create mode 100644 ground/tool/charts/velocity.go create mode 100644 ground/tool/conversion/inboard_reader.go create mode 100644 ground/tool/conversion/types.go create mode 100644 ground/tool/go.mod create mode 100644 ground/tool/go.sum create mode 100644 ground/tool/main.go create mode 100644 ground/tool/summerizers/altitude.go create mode 100644 ground/tool/summerizers/mode_time.go create mode 100644 ground/tool/summerizers/origin.go create mode 100644 ground/tool/summerizers/touchdown.go create mode 100644 ground/tool/summerizers/travel.go create mode 100644 ground/tool/summerizers/types.go create mode 100644 ground/tool/summerizers/util.go create mode 100644 ground/tool/summerizers/velocity.go create mode 100644 ground/tool/task_chart.go create mode 100644 ground/tool/task_convert.go create mode 100644 ground/tool/task_summary.go create mode 100644 ground/tool/types.go create mode 100644 ground/tool/util.go create mode 100644 receiver/receiver.ino diff --git a/ground/.gitignore b/ground/.gitignore new file mode 100644 index 0000000..435b480 --- /dev/null +++ b/ground/.gitignore @@ -0,0 +1,5 @@ +src +data +build +bin +pkg diff --git a/ground/core/computed.go b/ground/core/computed.go new file mode 100644 index 0000000..ca40d83 --- /dev/null +++ b/ground/core/computed.go @@ -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 +} diff --git a/ground/core/computed_test.go b/ground/core/computed_test.go new file mode 100644 index 0000000..1786982 --- /dev/null +++ b/ground/core/computed_test.go @@ -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)) +} diff --git a/ground/core/consts.go b/ground/core/consts.go new file mode 100644 index 0000000..219f9ee --- /dev/null +++ b/ground/core/consts.go @@ -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 +) diff --git a/ground/core/flight_data.go b/ground/core/flight_data.go new file mode 100644 index 0000000..001f2a1 --- /dev/null +++ b/ground/core/flight_data.go @@ -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 +} diff --git a/ground/core/go.mod b/ground/core/go.mod new file mode 100644 index 0000000..72183b1 --- /dev/null +++ b/ground/core/go.mod @@ -0,0 +1,5 @@ +module core + +go 1.16 + +require github.com/stretchr/testify v1.7.0 diff --git a/ground/core/go.sum b/ground/core/go.sum new file mode 100644 index 0000000..b380ae4 --- /dev/null +++ b/ground/core/go.sum @@ -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= diff --git a/ground/core/types.go b/ground/core/types.go new file mode 100644 index 0000000..13ffa2f --- /dev/null +++ b/ground/core/types.go @@ -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 +} diff --git a/ground/core/util.go b/ground/core/util.go new file mode 100644 index 0000000..96ef53b --- /dev/null +++ b/ground/core/util.go @@ -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 +} diff --git a/ground/dashboard/Makefile b/ground/dashboard/Makefile new file mode 100644 index 0000000..c52167d --- /dev/null +++ b/ground/dashboard/Makefile @@ -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 diff --git a/ground/dashboard/consts.go b/ground/dashboard/consts.go new file mode 100644 index 0000000..e6420b2 --- /dev/null +++ b/ground/dashboard/consts.go @@ -0,0 +1,5 @@ +package main + +const ( + PointsPerDataFrame = 2 +) diff --git a/ground/dashboard/data_provider.go b/ground/dashboard/data_provider.go new file mode 100644 index 0000000..018058f --- /dev/null +++ b/ground/dashboard/data_provider.go @@ -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 +} diff --git a/ground/dashboard/file b/ground/dashboard/file new file mode 100644 index 0000000..e69de29 diff --git a/ground/dashboard/go.mod b/ground/dashboard/go.mod new file mode 100644 index 0000000..b1fe659 --- /dev/null +++ b/ground/dashboard/go.mod @@ -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 +) diff --git a/ground/dashboard/go.sum b/ground/dashboard/go.sum new file mode 100644 index 0000000..b1e7dd6 --- /dev/null +++ b/ground/dashboard/go.sum @@ -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= diff --git a/ground/dashboard/logger.go b/ground/dashboard/logger.go new file mode 100644 index 0000000..a48c1f4 --- /dev/null +++ b/ground/dashboard/logger.go @@ -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 +} diff --git a/ground/dashboard/main.go b/ground/dashboard/main.go new file mode 100644 index 0000000..1af4c22 --- /dev/null +++ b/ground/dashboard/main.go @@ -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) + } +} diff --git a/ground/dashboard/render.go b/ground/dashboard/render.go new file mode 100644 index 0000000..ce54607 --- /dev/null +++ b/ground/dashboard/render.go @@ -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", "": + 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) +} diff --git a/ground/dashboard/static/index.html b/ground/dashboard/static/index.html new file mode 100644 index 0000000..4fbab85 --- /dev/null +++ b/ground/dashboard/static/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + +
+ + + + + + + + + + + + + diff --git a/ground/dashboard/static/js/attitudeWidget.js b/ground/dashboard/static/js/attitudeWidget.js new file mode 100644 index 0000000..ac8a525 --- /dev/null +++ b/ground/dashboard/static/js/attitudeWidget.js @@ -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() {} +} diff --git a/ground/dashboard/static/js/dashboard.js b/ground/dashboard/static/js/dashboard.js new file mode 100644 index 0000000..03cd7b0 --- /dev/null +++ b/ground/dashboard/static/js/dashboard.js @@ -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 + } + ] + } +} + diff --git a/ground/dashboard/static/js/kvTableWidget.js b/ground/dashboard/static/js/kvTableWidget.js new file mode 100644 index 0000000..3d14d69 --- /dev/null +++ b/ground/dashboard/static/js/kvTableWidget.js @@ -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() { + + } +} diff --git a/ground/dashboard/static/js/lineChartWidget.js b/ground/dashboard/static/js/lineChartWidget.js new file mode 100644 index 0000000..6a2ba42 --- /dev/null +++ b/ground/dashboard/static/js/lineChartWidget.js @@ -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 + } + } + } + } + }) + } +} diff --git a/ground/dashboard/static/js/main.js b/ground/dashboard/static/js/main.js new file mode 100644 index 0000000..742c91e --- /dev/null +++ b/ground/dashboard/static/js/main.js @@ -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) + } +})() diff --git a/ground/dashboard/static/js/mapWidget.js b/ground/dashboard/static/js/mapWidget.js new file mode 100644 index 0000000..4644b0e --- /dev/null +++ b/ground/dashboard/static/js/mapWidget.js @@ -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: '© OpenStreetMap contributors' + }).addTo(this.map) + this.marker = L.circle([0,0], { + color: 'red', + fillColor: '#f03', + fillOpacity: 0.5, + radius: 5 + }).addTo(this.map) + } +} diff --git a/ground/dashboard/static/js/missionInfoWidget.js b/ground/dashboard/static/js/missionInfoWidget.js new file mode 100644 index 0000000..a9c6a27 --- /dev/null +++ b/ground/dashboard/static/js/missionInfoWidget.js @@ -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() { + + } +} diff --git a/ground/dashboard/static/js/util.js b/ground/dashboard/static/js/util.js new file mode 100644 index 0000000..d24c6e5 --- /dev/null +++ b/ground/dashboard/static/js/util.js @@ -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 +} diff --git a/ground/dashboard/static/js/widget.js b/ground/dashboard/static/js/widget.js new file mode 100644 index 0000000..e9d9a3d --- /dev/null +++ b/ground/dashboard/static/js/widget.js @@ -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() {} +} diff --git a/ground/dashboard/static/style/style.css b/ground/dashboard/static/style/style.css new file mode 100644 index 0000000..07c9d12 --- /dev/null +++ b/ground/dashboard/static/style/style.css @@ -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; +} diff --git a/ground/dashboard/telemetryio.go b/ground/dashboard/telemetryio.go new file mode 100644 index 0000000..ec9c1a1 --- /dev/null +++ b/ground/dashboard/telemetryio.go @@ -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 +} diff --git a/ground/dashboard/telemetryio_test.go b/ground/dashboard/telemetryio_test.go new file mode 100644 index 0000000..e62bdff --- /dev/null +++ b/ground/dashboard/telemetryio_test.go @@ -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) +} diff --git a/ground/dashboard/types.go b/ground/dashboard/types.go new file mode 100644 index 0000000..f004ac3 --- /dev/null +++ b/ground/dashboard/types.go @@ -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 +} diff --git a/ground/tool/Makefile b/ground/tool/Makefile new file mode 100644 index 0000000..35366ba --- /dev/null +++ b/ground/tool/Makefile @@ -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 diff --git a/ground/tool/charts/altitude.go b/ground/tool/charts/altitude.go new file mode 100644 index 0000000..4cbadc4 --- /dev/null +++ b/ground/tool/charts/altitude.go @@ -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 +} diff --git a/ground/tool/charts/types.go b/ground/tool/charts/types.go new file mode 100644 index 0000000..920bc86 --- /dev/null +++ b/ground/tool/charts/types.go @@ -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 +} diff --git a/ground/tool/charts/util.go b/ground/tool/charts/util.go new file mode 100644 index 0000000..79e3016 --- /dev/null +++ b/ground/tool/charts/util.go @@ -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 +} diff --git a/ground/tool/charts/velocity.go b/ground/tool/charts/velocity.go new file mode 100644 index 0000000..8d5068f --- /dev/null +++ b/ground/tool/charts/velocity.go @@ -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 +} diff --git a/ground/tool/conversion/inboard_reader.go b/ground/tool/conversion/inboard_reader.go new file mode 100644 index 0000000..7f204df --- /dev/null +++ b/ground/tool/conversion/inboard_reader.go @@ -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 +} diff --git a/ground/tool/conversion/types.go b/ground/tool/conversion/types.go new file mode 100644 index 0000000..5599f30 --- /dev/null +++ b/ground/tool/conversion/types.go @@ -0,0 +1,7 @@ +package conversion + +import "github.com/johnjones4/model-rocket-telemetry/dashboard/core" + +type Reader interface { + Read(showProgress bool) (core.FlightData, error) +} diff --git a/ground/tool/go.mod b/ground/tool/go.mod new file mode 100644 index 0000000..8d4f0bb --- /dev/null +++ b/ground/tool/go.mod @@ -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 +) diff --git a/ground/tool/go.sum b/ground/tool/go.sum new file mode 100644 index 0000000..fb81ebd --- /dev/null +++ b/ground/tool/go.sum @@ -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= diff --git a/ground/tool/main.go b/ground/tool/main.go new file mode 100644 index 0000000..1dcc5c7 --- /dev/null +++ b/ground/tool/main.go @@ -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 + } + } +} diff --git a/ground/tool/summerizers/altitude.go b/ground/tool/summerizers/altitude.go new file mode 100644 index 0000000..37d6761 --- /dev/null +++ b/ground/tool/summerizers/altitude.go @@ -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" +} diff --git a/ground/tool/summerizers/mode_time.go b/ground/tool/summerizers/mode_time.go new file mode 100644 index 0000000..ca363e1 --- /dev/null +++ b/ground/tool/summerizers/mode_time.go @@ -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 "" + } +} diff --git a/ground/tool/summerizers/origin.go b/ground/tool/summerizers/origin.go new file mode 100644 index 0000000..be3bdc3 --- /dev/null +++ b/ground/tool/summerizers/origin.go @@ -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" +} diff --git a/ground/tool/summerizers/touchdown.go b/ground/tool/summerizers/touchdown.go new file mode 100644 index 0000000..48c5616 --- /dev/null +++ b/ground/tool/summerizers/touchdown.go @@ -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" +} diff --git a/ground/tool/summerizers/travel.go b/ground/tool/summerizers/travel.go new file mode 100644 index 0000000..f23ff76 --- /dev/null +++ b/ground/tool/summerizers/travel.go @@ -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" +} diff --git a/ground/tool/summerizers/types.go b/ground/tool/summerizers/types.go new file mode 100644 index 0000000..2a0ede4 --- /dev/null +++ b/ground/tool/summerizers/types.go @@ -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 +} diff --git a/ground/tool/summerizers/util.go b/ground/tool/summerizers/util.go new file mode 100644 index 0000000..5318c67 --- /dev/null +++ b/ground/tool/summerizers/util.go @@ -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 +} diff --git a/ground/tool/summerizers/velocity.go b/ground/tool/summerizers/velocity.go new file mode 100644 index 0000000..03bb5c8 --- /dev/null +++ b/ground/tool/summerizers/velocity.go @@ -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" +} diff --git a/ground/tool/task_chart.go b/ground/tool/task_chart.go new file mode 100644 index 0000000..346cbb3 --- /dev/null +++ b/ground/tool/task_chart.go @@ -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 +} diff --git a/ground/tool/task_convert.go b/ground/tool/task_convert.go new file mode 100644 index 0000000..a6d44cc --- /dev/null +++ b/ground/tool/task_convert.go @@ -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 +} diff --git a/ground/tool/task_summary.go b/ground/tool/task_summary.go new file mode 100644 index 0000000..cbf76ca --- /dev/null +++ b/ground/tool/task_summary.go @@ -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 +} diff --git a/ground/tool/types.go b/ground/tool/types.go new file mode 100644 index 0000000..3c33d6d --- /dev/null +++ b/ground/tool/types.go @@ -0,0 +1,11 @@ +package main + +import ( + "flag" +) + +type task interface { + FlagSet() *flag.FlagSet + name() string + run() error +} diff --git a/ground/tool/util.go b/ground/tool/util.go new file mode 100644 index 0000000..ff250c2 --- /dev/null +++ b/ground/tool/util.go @@ -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 +} diff --git a/receiver/receiver.ino b/receiver/receiver.ino new file mode 100644 index 0000000..e31d932 --- /dev/null +++ b/receiver/receiver.ino @@ -0,0 +1,157 @@ +#include +#include +#include + +#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() { +}