mirror of
https://codeberg.org/portospaceteam/ground-dashboard.git
synced 2025-02-26 22:46:59 +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…
x
Reference in New Issue
Block a user