mirror of
https://codeberg.org/portospaceteam/ground-dashboard.git
synced 2024-12-01 10:42:25 +00:00
WhiteVests and code to parse data from eggtimer
This commit is contained in:
parent
98f9549f9e
commit
a9214847c4
5
ground/.gitignore
vendored
Normal file
5
ground/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
src
|
||||
data
|
||||
build
|
||||
bin
|
||||
pkg
|
200
ground/core/computed.go
Normal file
200
ground/core/computed.go
Normal 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
|
||||
}
|
176
ground/core/computed_test.go
Normal file
176
ground/core/computed_test.go
Normal 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
26
ground/core/consts.go
Normal 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
|
||||
)
|
85
ground/core/flight_data.go
Normal file
85
ground/core/flight_data.go
Normal 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
5
ground/core/go.mod
Normal 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
10
ground/core/go.sum
Normal 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
77
ground/core/types.go
Normal 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
22
ground/core/util.go
Normal 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
20
ground/dashboard/Makefile
Normal 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
|
5
ground/dashboard/consts.go
Normal file
5
ground/dashboard/consts.go
Normal file
@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
PointsPerDataFrame = 2
|
||||
)
|
78
ground/dashboard/data_provider.go
Normal file
78
ground/dashboard/data_provider.go
Normal 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
0
ground/dashboard/file
Normal file
14
ground/dashboard/go.mod
Normal file
14
ground/dashboard/go.mod
Normal 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
25
ground/dashboard/go.sum
Normal 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=
|
73
ground/dashboard/logger.go
Normal file
73
ground/dashboard/logger.go
Normal 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
47
ground/dashboard/main.go
Normal 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
233
ground/dashboard/render.go
Normal 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)
|
||||
}
|
28
ground/dashboard/static/index.html
Normal file
28
ground/dashboard/static/index.html
Normal 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>
|
18
ground/dashboard/static/js/attitudeWidget.js
Normal file
18
ground/dashboard/static/js/attitudeWidget.js
Normal 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() {}
|
||||
}
|
96
ground/dashboard/static/js/dashboard.js
Normal file
96
ground/dashboard/static/js/dashboard.js
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
54
ground/dashboard/static/js/kvTableWidget.js
Normal file
54
ground/dashboard/static/js/kvTableWidget.js
Normal 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() {
|
||||
|
||||
}
|
||||
}
|
69
ground/dashboard/static/js/lineChartWidget.js
Normal file
69
ground/dashboard/static/js/lineChartWidget.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
10
ground/dashboard/static/js/main.js
Normal file
10
ground/dashboard/static/js/main.js
Normal 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)
|
||||
}
|
||||
})()
|
31
ground/dashboard/static/js/mapWidget.js
Normal file
31
ground/dashboard/static/js/mapWidget.js
Normal 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: '© <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)
|
||||
}
|
||||
}
|
36
ground/dashboard/static/js/missionInfoWidget.js
Normal file
36
ground/dashboard/static/js/missionInfoWidget.js
Normal 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() {
|
||||
|
||||
}
|
||||
}
|
39
ground/dashboard/static/js/util.js
Normal file
39
ground/dashboard/static/js/util.js
Normal 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
|
||||
}
|
44
ground/dashboard/static/js/widget.js
Normal file
44
ground/dashboard/static/js/widget.js
Normal 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() {}
|
||||
}
|
162
ground/dashboard/static/style/style.css
Normal file
162
ground/dashboard/static/style/style.css
Normal 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;
|
||||
}
|
98
ground/dashboard/telemetryio.go
Normal file
98
ground/dashboard/telemetryio.go
Normal 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
|
||||
}
|
27
ground/dashboard/telemetryio_test.go
Normal file
27
ground/dashboard/telemetryio_test.go
Normal 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
31
ground/dashboard/types.go
Normal 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
20
ground/tool/Makefile
Normal 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
|
47
ground/tool/charts/altitude.go
Normal file
47
ground/tool/charts/altitude.go
Normal 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
|
||||
}
|
7
ground/tool/charts/types.go
Normal file
7
ground/tool/charts/types.go
Normal 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
|
||||
}
|
11
ground/tool/charts/util.go
Normal file
11
ground/tool/charts/util.go
Normal 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
|
||||
}
|
47
ground/tool/charts/velocity.go
Normal file
47
ground/tool/charts/velocity.go
Normal 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
|
||||
}
|
82
ground/tool/conversion/inboard_reader.go
Normal file
82
ground/tool/conversion/inboard_reader.go
Normal 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
|
||||
}
|
7
ground/tool/conversion/types.go
Normal file
7
ground/tool/conversion/types.go
Normal 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
12
ground/tool/go.mod
Normal 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
39
ground/tool/go.sum
Normal 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
27
ground/tool/main.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
33
ground/tool/summerizers/altitude.go
Normal file
33
ground/tool/summerizers/altitude.go
Normal 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"
|
||||
}
|
47
ground/tool/summerizers/mode_time.go
Normal file
47
ground/tool/summerizers/mode_time.go
Normal 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 ""
|
||||
}
|
||||
}
|
36
ground/tool/summerizers/origin.go
Normal file
36
ground/tool/summerizers/origin.go
Normal 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"
|
||||
}
|
36
ground/tool/summerizers/touchdown.go
Normal file
36
ground/tool/summerizers/touchdown.go
Normal 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"
|
||||
}
|
33
ground/tool/summerizers/travel.go
Normal file
33
ground/tool/summerizers/travel.go
Normal 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"
|
||||
}
|
9
ground/tool/summerizers/types.go
Normal file
9
ground/tool/summerizers/types.go
Normal 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
|
||||
}
|
15
ground/tool/summerizers/util.go
Normal file
15
ground/tool/summerizers/util.go
Normal 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
|
||||
}
|
34
ground/tool/summerizers/velocity.go
Normal file
34
ground/tool/summerizers/velocity.go
Normal 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
55
ground/tool/task_chart.go
Normal 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
|
||||
}
|
72
ground/tool/task_convert.go
Normal file
72
ground/tool/task_convert.go
Normal 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
|
||||
}
|
64
ground/tool/task_summary.go
Normal file
64
ground/tool/task_summary.go
Normal 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
11
ground/tool/types.go
Normal 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
32
ground/tool/util.go
Normal 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
157
receiver/receiver.ino
Normal 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() {
|
||||
}
|
Loading…
Reference in New Issue
Block a user