From fceaa4195056b071acff634dbf3de49806c25f2b Mon Sep 17 00:00:00 2001 From: John Jones Date: Wed, 8 Sep 2021 21:35:28 -0400 Subject: [PATCH] dasbhoard updates --- Readme.md | 2 +- air/whitevest/bin/air.py | 2 +- air/whitevest/bin/test_sensors.py | 4 +- air/whitevest/lib/atomic_buffer.py | 6 + air/whitevest/lib/hardware.py | 58 ++- air/whitevest/lib/utils.py | 57 +-- dashboard/dashboard/computed.go | 99 ++++- dashboard/dashboard/computed_test.go | 20 +- dashboard/dashboard/consts.go | 9 + dashboard/dashboard/data_provider.go | 2 +- dashboard/dashboard/flight_data.go | 16 +- dashboard/dashboard/logger.go | 34 +- dashboard/dashboard/render.go | 411 +++++++++++++----- dashboard/dashboard/static/index.html | 28 ++ .../dashboard/static/js/attitudeWidget.js | 18 + dashboard/dashboard/static/js/dashboard.js | 96 ++++ .../dashboard/static/js/kvTableWidget.js | 54 +++ .../dashboard/static/js/lineChartWidget.js | 69 +++ dashboard/dashboard/static/js/main.js | 10 + dashboard/dashboard/static/js/mapWidget.js | 31 ++ .../dashboard/static/js/missionInfoWidget.js | 36 ++ dashboard/dashboard/static/js/util.js | 39 ++ dashboard/dashboard/static/js/widget.js | 44 ++ dashboard/dashboard/static/style/style.css | 162 +++++++ dashboard/dashboard/telemetryio.go | 8 +- dashboard/dashboard/types.go | 67 +-- dashboard/dashboard/util.go | 66 +-- .../generate_test_data/generate_test_data.go | 58 ++- dashboard/go.mod | 2 + dashboard/go.sum | 4 + dashboard/main.go | 30 +- 31 files changed, 1203 insertions(+), 339 deletions(-) create mode 100644 dashboard/dashboard/static/index.html create mode 100644 dashboard/dashboard/static/js/attitudeWidget.js create mode 100644 dashboard/dashboard/static/js/dashboard.js create mode 100644 dashboard/dashboard/static/js/kvTableWidget.js create mode 100644 dashboard/dashboard/static/js/lineChartWidget.js create mode 100644 dashboard/dashboard/static/js/main.js create mode 100644 dashboard/dashboard/static/js/mapWidget.js create mode 100644 dashboard/dashboard/static/js/missionInfoWidget.js create mode 100644 dashboard/dashboard/static/js/util.js create mode 100644 dashboard/dashboard/static/js/widget.js create mode 100644 dashboard/dashboard/static/style/style.css diff --git a/Readme.md b/Readme.md index 8378fcd..5a86f6e 100644 --- a/Readme.md +++ b/Readme.md @@ -115,7 +115,7 @@ $ make install $ make build ``` -Then, run the dashboard using the following `build/dashboard-Darwin-i386 /dev/cu.usbmodem143101`. Note that `dashboard-Darwin-i386` will change based on the system you are using and `/dev/cu.usbmodem143101` is the path to the Arduino serial connection. +Then, run the dashboard using the following `build/dashboard-Darwin-i386 --input /dev/cu.usbmodem143101 --output text`. Note that `dashboard-Darwin-i386` will change based on the system you are using and `/dev/cu.usbmodem143101` is the path to the Arduino serial connection. To view the web dashboard, pass in `web` for the `--output` option and open [http://localhost:8080/](http://localhost:8080/). ### Air diff --git a/air/whitevest/bin/air.py b/air/whitevest/bin/air.py index 1d6e6c5..247b96d 100644 --- a/air/whitevest/bin/air.py +++ b/air/whitevest/bin/air.py @@ -33,7 +33,7 @@ def main(): start_time = time.time() # Thread safe place to store altitude reading - current_readings = AtomicBuffer(2) + current_readings = AtomicBuffer(50) # Holds the most recent GPS data gps_value = AtomicValue((0.0, 0.0, 0.0, 0.0)) diff --git a/air/whitevest/bin/test_sensors.py b/air/whitevest/bin/test_sensors.py index 679d025..f21cc7e 100644 --- a/air/whitevest/bin/test_sensors.py +++ b/air/whitevest/bin/test_sensors.py @@ -19,7 +19,7 @@ transmit_latest_readings, ) -TEST_TIME_LENGTH = 5 +TEST_TIME_LENGTH = 30 def main(): @@ -134,7 +134,7 @@ def test_gps(configuration: Configuration): gps_value = AtomicValue() start_time = time.time() readings = 0 - while readings == 0 or time.time() - start_time < TEST_TIME_LENGTH: + while readings < 10 or time.time() - start_time < TEST_TIME_LENGTH: try: if take_gps_reading(gps, gps_value): readings += 1 diff --git a/air/whitevest/lib/atomic_buffer.py b/air/whitevest/lib/atomic_buffer.py index bedfbe9..a8d80a2 100644 --- a/air/whitevest/lib/atomic_buffer.py +++ b/air/whitevest/lib/atomic_buffer.py @@ -24,3 +24,9 @@ def read(self): for i, _ in enumerate(self.buffer): output[i] = self.buffer[(i + self.pointer) % len(self.buffer)] return output + + def clear(self): + """Empty all of the data in buffer""" + with self.lock: + self.buffer = [self.default_value] * len(self.buffer) + self.pointer = 0 diff --git a/air/whitevest/lib/hardware.py b/air/whitevest/lib/hardware.py index 8f46e75..c5fef97 100644 --- a/air/whitevest/lib/hardware.py +++ b/air/whitevest/lib/hardware.py @@ -14,6 +14,21 @@ from whitevest.lib.atomic_value import AtomicValue from whitevest.lib.configuration import Configuration +class DummyBMP: + """Dummy class for the BMP3xx sensor""" + def _read(self): + """Return dummy data""" + return (0.0, 0.0) + +class DummyMag: + """Dummy class for the Magnetometer sensor""" + def __init__(self): + self.magnetic = (0.0, 0.0, 0.0) +class DummyAccel: + """Dummy class for the Magnetometer sensor""" + def __init__(self): + self.acceleration = (0.0, 0.0, 0.0) + def init_radio(configuration: Configuration): """Initialize the radio""" @@ -35,26 +50,37 @@ def init_radio(configuration: Configuration): def init_altimeter(configuration: Configuration): """Initialize the sensor for pressure, temperature, and altitude""" - logging.info("Initializing altimeter") - assignments = configuration.get_pin_assignments("bmp3xx") - if not assignments: - return None - i2c = busio.I2C(assignments.get("scl"), assignments.get("sda")) - bmp = adafruit_bmp3xx.BMP3XX_I2C(i2c) - bmp._wait_time = 0 # pylint: disable=protected-access - return bmp + try: + logging.info("Initializing altimeter") + assignments = configuration.get_pin_assignments("bmp3xx") + if not assignments: + return None + i2c = busio.I2C(assignments.get("scl"), assignments.get("sda")) + bmp = adafruit_bmp3xx.BMP3XX_I2C(i2c) + bmp._wait_time = 0 # pylint: disable=protected-access + return bmp + except Exception as ex: # pylint: disable=broad-except + bmp = DummyBMP() + logging.exception(ex) + return bmp def init_magnetometer_accelerometer(configuration: Configuration): """Initialize the sensor for magnetic and acceleration""" - logging.info("Initializing magnetometer/accelerometer") - assignments = configuration.get_pin_assignments("lsm303") - if not assignments: - return None, None - i2c = busio.I2C(assignments.get("scl"), assignments.get("sda")) - mag = adafruit_lsm303dlh_mag.LSM303DLH_Mag(i2c) - accel = adafruit_lsm303_accel.LSM303_Accel(i2c) - return mag, accel + try: + logging.info("Initializing magnetometer/accelerometer") + assignments = configuration.get_pin_assignments("lsm303") + if not assignments: + return None, None + i2c = busio.I2C(assignments.get("scl"), assignments.get("sda")) + mag = adafruit_lsm303dlh_mag.LSM303DLH_Mag(i2c) + accel = adafruit_lsm303_accel.LSM303_Accel(i2c) + return mag, accel + except Exception as ex: # pylint: disable=broad-except + mag = DummyMag() + accel = DummyAccel() + logging.exception(ex) + return mag, accel def init_gps(configuration: Configuration): diff --git a/air/whitevest/lib/utils.py b/air/whitevest/lib/utils.py index cc03dee..503cead 100644 --- a/air/whitevest/lib/utils.py +++ b/air/whitevest/lib/utils.py @@ -1,5 +1,6 @@ """Functions shared between air and ground runtimes""" import logging +import math import struct import time from queue import Queue @@ -38,16 +39,14 @@ def write_queue_log(outfile, new_data_queue: Queue, max_lines: int = 1000) -> in def take_gps_reading(sio, gps_value: AtomicValue) -> bool: """Grab the most recent data from GPS feed""" line = sio.readline() - if line[0:6] == "$GPGGA": - gps = pynmea2.parse(line) - gps_value.update( - ( - gps.latitude if gps else 0.0, - gps.longitude if gps else 0.0, - float(gps.gps_qual) if gps else 0.0, - float(gps.num_sats) if gps else 0.0, - ) - ) + gps = pynmea2.parse(line) + if isinstance(gps, pynmea2.types.talker.GGA): + gps_value.update(( + gps.latitude if gps else 0.0, + gps.longitude if gps else 0.0, + float(gps.gps_qual) if gps else 0.0, + float(gps.num_sats) if gps else 0.0, + )) return True return False @@ -141,22 +140,26 @@ def transmit_latest_readings( ) -> Tuple[int, float]: """Get the latest value from the sensor store and transmit it as a byte array""" infos = current_readings.read() - info = [] - for i in infos: - info += i - if info: - clean_info = [float(i) for i in info] - encoded = struct.pack( - "d" + TELEMETRY_STRUCT_STRING + TELEMETRY_STRUCT_STRING, - *(pcnt_to_limit.get_value(), *clean_info) + if len(infos) < 2: + return readings_sent, last_check + info1 = infos[0] + info2 = infos[int(math.ceil(len(infos) / 2))] + if not info1 or not info2: + return readings_sent, last_check + info = (*info1, *info2) + clean_info = [float(i) for i in info] + encoded = struct.pack( + "d" + TELEMETRY_STRUCT_STRING + TELEMETRY_STRUCT_STRING, + *(pcnt_to_limit.get_value(), *clean_info) + ) + current_readings.clear() + logging.debug("Transmitting %d bytes", len(encoded)) + rfm9x.send(encoded) + readings_sent += 1 + if last_check > 0 and last_check + 10.0 < time.time(): + last_check = time.time() + logging.info( + "Transmit rate: %f/s", + float(readings_sent) / float(last_check - start_time), ) - logging.debug("Transmitting %d bytes", len(encoded)) - rfm9x.send(encoded) - readings_sent += 1 - if last_check > 0 and last_check + 10.0 < time.time(): - last_check = time.time() - logging.info( - "Transmit rate: %f/s", - float(readings_sent) / float(last_check - start_time), - ) return readings_sent, last_check diff --git a/dashboard/dashboard/computed.go b/dashboard/dashboard/computed.go index 9d1d324..6f3dc7e 100644 --- a/dashboard/dashboard/computed.go +++ b/dashboard/dashboard/computed.go @@ -7,8 +7,8 @@ import ( func basePressure(stream FlightData) float64 { pressures := make([]float64, 0) for _, segment := range stream.AllSegments() { - if segment.Computed.NormalizedPressure > 0 { - pressures = append(pressures, segment.Computed.NormalizedPressure) + if segment.Computed.SmoothedPressure > 0 { + pressures = append(pressures, segment.Computed.SmoothedPressure) } if len(pressures) >= 10 { var sum float64 = 0 @@ -104,7 +104,53 @@ func dataRate(stream FlightData) float64 { for _, secondTotal := range totalsMap { total += secondTotal } - return total / float64(len(totalsMap)) + rate := total / float64(len(totalsMap)) + if math.IsNaN(rate) { + return 0 + } + return rate +} + +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 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 > 1 { + 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(avgVelocity) < 0.5 { + return ModeRecovery + } + return lastMode } func computeDataSegment(stream FlightData, raw RawDataSegment) (ComputedDataSegment, float64, Coordinate) { @@ -118,14 +164,41 @@ func computeDataSegment(stream FlightData, raw RawDataSegment) (ComputedDataSegm origin = raw.Coordinate } - return ComputedDataSegment{ - Altitude: altitude(bp, raw), - Velocity: velocity(stream, bp, raw), - Yaw: yaw(raw), - Pitch: pitch(raw), - NormalizedPressure: normalizedPressure(raw), - Bearing: bearing(origin, raw), - Distance: distance(origin, raw), - DataRate: dataRate(stream), - }, bp, origin + alt := altitude(bp, raw) + vel := velocity(stream, bp, raw) + press := normalizedPressure(raw) + + smoothedAlt := alt + smoothedVel := vel + smoothedVertAccel := 0.0 + smoothedPress := press + smoothedTemp := raw.Temperature + s := len(stream.AllSegments()) + if s > 0 { + alpha := 0.5 + smoothedAlt = smoothed(alpha, alt, stream.SmoothedAltitude()[s-1]) + smoothedVel = smoothed(alpha, vel, stream.SmoothedVelocity()[s-1]) + smoothedPress = smoothed(alpha, press, stream.SmoothedPressure()[s-1]) + smoothedTemp = smoothed(alpha, raw.Temperature, stream.SmoothedTemperature()[s-1]) + smoothedVertAccel = (smoothedVel - stream.SmoothedVelocity()[s-1]) / (raw.Timestamp - stream.Time()[s-1]) + } + + computed := ComputedDataSegment{ + Altitude: alt, + Velocity: vel, + Yaw: yaw(raw), + Pitch: pitch(raw), + Bearing: bearing(origin, raw), + Distance: distance(origin, raw), + DataRate: dataRate(stream), + SmoothedAltitude: smoothedAlt, + SmoothedVelocity: smoothedVel, + SmoothedPressure: smoothedPress, + SmoothedTemperature: smoothedTemp, + SmoothedVerticalAcceleration: smoothedVertAccel, + } + + computed.FlightMode = determineFlightMode(stream, raw, computed) + + return computed, bp, origin } diff --git a/dashboard/dashboard/computed_test.go b/dashboard/dashboard/computed_test.go index 45e265f..c9491e2 100644 --- a/dashboard/dashboard/computed_test.go +++ b/dashboard/dashboard/computed_test.go @@ -131,15 +131,15 @@ func TestComputeDataSegment(t *testing.T) { Segments: segments, OriginCoordinate: Coordinate{37, -76}, }, RawDataSegment{ - CameraProgress: 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, + 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) @@ -148,7 +148,6 @@ func TestComputeDataSegment(t *testing.T) { 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.NormalizedPressure, 0.0) assert.NotEqual(t, segment.Bearing, 0.0) assert.NotEqual(t, segment.Distance, 0.0) assert.NotEqual(t, segment.DataRate, 0.0) @@ -166,7 +165,6 @@ func makeDataSeries(bp float64) ([]DataSegment, float64) { Pressure: val * 100.0, }, ComputedDataSegment{ - NormalizedPressure: val, Altitude: altitude(bp, RawDataSegment{ Pressure: val * 100.0, }), diff --git a/dashboard/dashboard/consts.go b/dashboard/dashboard/consts.go index f44fdd2..0659916 100644 --- a/dashboard/dashboard/consts.go +++ b/dashboard/dashboard/consts.go @@ -19,3 +19,12 @@ const ( const ( PointsPerDataFrame = 2 ) + +const ( + ModePrelaunch = "P" + ModeAscentPowered = "AP" + ModeAscentUnpowered = "AU" + ModeDescentFreefall = "DF" + ModeDescentParachute = "DP" + ModeRecovery = "R" +) diff --git a/dashboard/dashboard/data_provider.go b/dashboard/dashboard/data_provider.go index b2eb85e..5e9beaf 100644 --- a/dashboard/dashboard/data_provider.go +++ b/dashboard/dashboard/data_provider.go @@ -32,7 +32,7 @@ func (f DataProviderFile) Stream() <-chan []byte { go func() { lastLine := 0 for { - time.Sleep(time.Second / 30) + time.Sleep(time.Second) if lastLine >= len(f.Bytes) { return } diff --git a/dashboard/dashboard/flight_data.go b/dashboard/dashboard/flight_data.go index 061688c..2ebfd3c 100644 --- a/dashboard/dashboard/flight_data.go +++ b/dashboard/dashboard/flight_data.go @@ -23,27 +23,27 @@ func (f *FlightDataConcrete) BasePressure() float64 { return f.Base } -func (f *FlightDataConcrete) Altitude() []float64 { +func (f *FlightDataConcrete) SmoothedAltitude() []float64 { return singleFlightDataElement(f, func(segment DataSegment) float64 { - return segment.Computed.Altitude + return segment.Computed.SmoothedAltitude }) } -func (f *FlightDataConcrete) Velocity() []float64 { +func (f *FlightDataConcrete) SmoothedVelocity() []float64 { return singleFlightDataElement(f, func(segment DataSegment) float64 { - return segment.Computed.Velocity + return segment.Computed.SmoothedVelocity }) } -func (f *FlightDataConcrete) Temperature() []float64 { +func (f *FlightDataConcrete) SmoothedTemperature() []float64 { return singleFlightDataElement(f, func(segment DataSegment) float64 { - return segment.Raw.Temperature + return segment.Computed.SmoothedTemperature }) } -func (f *FlightDataConcrete) Pressure() []float64 { +func (f *FlightDataConcrete) SmoothedPressure() []float64 { return singleFlightDataElement(f, func(segment DataSegment) float64 { - return segment.Computed.NormalizedPressure + return segment.Computed.SmoothedPressure }) } diff --git a/dashboard/dashboard/logger.go b/dashboard/dashboard/logger.go index f488f4b..3fa9b5a 100644 --- a/dashboard/dashboard/logger.go +++ b/dashboard/dashboard/logger.go @@ -1,41 +1,21 @@ package dashboard import ( + "encoding/json" "fmt" "os" "path" - "strings" "sync" "time" ) func dataSegmentToString(ds DataSegment) string { - parts := []string{ - fmt.Sprint(ds.Raw.CameraProgress), - fmt.Sprint(ds.Raw.Timestamp), - fmt.Sprint(ds.Raw.Pressure), - fmt.Sprint(ds.Raw.Temperature), - fmt.Sprint(ds.Raw.Acceleration.X), - fmt.Sprint(ds.Raw.Acceleration.Y), - fmt.Sprint(ds.Raw.Acceleration.Z), - fmt.Sprint(ds.Raw.Magnetic.X), - fmt.Sprint(ds.Raw.Magnetic.Y), - fmt.Sprint(ds.Raw.Magnetic.Z), - fmt.Sprint(ds.Raw.Coordinate.Lat), - fmt.Sprint(ds.Raw.Coordinate.Lon), - fmt.Sprint(ds.Raw.GPSInfo.Quality), - fmt.Sprint(ds.Raw.GPSInfo.Sats), - fmt.Sprint(ds.Raw.Rssi), - fmt.Sprint(ds.Computed.Altitude), - fmt.Sprint(ds.Computed.Velocity), - fmt.Sprint(ds.Computed.Yaw), - fmt.Sprint(ds.Computed.Pitch), - fmt.Sprint(ds.Computed.NormalizedPressure), - fmt.Sprint(ds.Computed.Bearing), - fmt.Sprint(ds.Computed.Distance), - fmt.Sprint(ds.Computed.DataRate), + bytes, err := json.Marshal(ds) + if err != nil { + return "" + } else { + return string(bytes) } - return fmt.Sprintln(strings.Join(parts, ",")) } func generateLogFilePath() (string, error) { @@ -60,10 +40,10 @@ func NewLogger() LoggerControl { panic(err) } file, err := os.Create(logPath) - defer file.Close() if err != nil { panic(err) } + defer file.Close() for { ds := <-logger.DataChannel _, err = file.WriteString(dataSegmentToString(ds)) diff --git a/dashboard/dashboard/render.go b/dashboard/dashboard/render.go index 742335f..1617c0b 100644 --- a/dashboard/dashboard/render.go +++ b/dashboard/dashboard/render.go @@ -1,134 +1,149 @@ package dashboard import ( + "embed" "fmt" + "io/fs" + "net/http" + "os" + "path" + "sync" "time" - ui "github.com/johnjones4/termui" - "github.com/johnjones4/termui/widgets" + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" + "github.com/gorilla/websocket" ) const SecondsWindow = 20 -func StartTextLogger(p DataProvider, ds FlightData, logger LoggerControl) error { - streamChannel := p.Stream() - for { - bytes := <-streamChannel - latestSegments, err := ds.IngestNewSegment(bytes) - if err != nil { - fmt.Println(err) - } else { - for _, seg := range latestSegments { - fmt.Println(seg) - } - } - } +//go:embed static/** +var static embed.FS + +type staticFS struct { + content embed.FS } -func StartDashboard(p DataProvider, ds FlightData, logger LoggerControl) error { +func (c staticFS) Open(name string) (fs.File, error) { + return c.content.Open(path.Join("static", name)) +} + +func StartTextLogger(p DataProvider, ds FlightData, logger LoggerControl) error { if err := ui.Init(); err != nil { return err } defer ui.Close() - altitude := widgets.NewPlot() - altitude.Data = make([][]float64, 1) - - velocity := widgets.NewPlot() - velocity.Data = make([][]float64, 1) - - rssi := widgets.NewPlot() - rssi.Data = make([][]float64, 1) - - temp := widgets.NewSparkline() - pressure := widgets.NewSparkline() - tempPress := widgets.NewSparklineGroup(temp, pressure) - tempPress.Title = "Temperature & Pressure" - - gpsQuality := widgets.NewSparkline() - gpsSats := widgets.NewSparkline() - gps := widgets.NewSparklineGroup(gpsQuality, gpsSats) - gps.Title = "GPS Info" - - bearingDistance := widgets.NewParagraph() - bearingDistance.Title = "Bearing & Distance" - - pitchYaw := widgets.NewParagraph() - pitchYaw.Title = "Pitch & Yaw" - - gauge := widgets.NewGauge() - - dataStats := widgets.NewParagraph() - dataStats.Title = "Signal Stats" + 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(1.0/2, - ui.NewCol(1.0/3, altitude), - ui.NewCol(1.0/3, velocity), - ui.NewCol(1.0/3, rssi), + ui.NewRow(0.8, + ui.NewCol(1.0, table), ), - ui.NewRow(5.0/16, - ui.NewCol(1.0/3, gps), - ui.NewCol(1.0/3, tempPress), - ui.NewCol(1.0/3, dataStats), - ), - ui.NewRow(3.0/16, - ui.NewCol(1.0/3, bearingDistance), - ui.NewCol(1.0/3, pitchYaw), - ui.NewCol(1.0/3, gauge), + ui.NewRow(0.2, + ui.NewCol(1.0, errorsui), ), ) uiEvents := ui.PollEvents() streamChannel := p.Stream() - ticker := time.NewTicker(time.Second).C - lastStreamEvent := time.Now() - lastEventAge := 0.0 - lastLatestSegment := DataSegment{} - - renderDashboard := func() { - if len(ds.AllSegments()) > 1 { - curtime := ds.Time() - - altitude.Data[0] = captureEndFrameOfData(curtime, ds.Altitude(), altitude.Inner.Dx()-10, SecondsWindow) - altitude.Title = fmt.Sprintf("Altitude (%.2f)", lastLatestSegment.Computed.Altitude) - - velocity.Data[0] = captureEndFrameOfData(curtime, ds.Velocity(), velocity.Inner.Dx()-10, SecondsWindow) - velocity.Title = fmt.Sprintf("Velocity (%.2f)", lastLatestSegment.Computed.Velocity) - - temp.Title = fmt.Sprintf("Temperature: %.2f°", lastLatestSegment.Raw.Temperature) - temp.Data = normalize(captureEndFrameOfData(curtime, ds.Temperature(), tempPress.Inner.Dx(), SecondsWindow)) - - pressure.Title = fmt.Sprintf("Pressure: %.2f mBar", lastLatestSegment.Computed.NormalizedPressure) - pressure.Data = normalize(captureEndFrameOfData(curtime, ds.Pressure(), tempPress.Inner.Dx(), SecondsWindow)) - - gpsQuality.Title = fmt.Sprintf("GPS Signal Quality: %.2f", lastLatestSegment.Raw.GPSInfo.Quality) - gpsQuality.Data = normalize(captureEndFrameOfData(curtime, ds.GpsQuality(), gps.Inner.Dx(), SecondsWindow)) - - gpsSats.Title = fmt.Sprintf("GPS Sats: %.0f", lastLatestSegment.Raw.GPSInfo.Sats) - gpsSats.Data = captureEndFrameOfData(curtime, ds.GpsSats(), gps.Inner.Dx(), SecondsWindow) - - bearingDistance.Text = fmt.Sprintf("Bearing: %.2f\nDistance: %.2f", lastLatestSegment.Computed.Bearing, lastLatestSegment.Computed.Distance) - - pitchYaw.Text = fmt.Sprintf("Pitch: %.2f\nYaw: %.2f", lastLatestSegment.Computed.Pitch, lastLatestSegment.Computed.Yaw) - - rssi.Data[0] = captureEndFrameOfData(curtime, ds.Rssi(), rssi.Inner.Dx()-10, SecondsWindow) - rssi.Title = fmt.Sprintf("RSSI (%d)", lastLatestSegment.Raw.Rssi) - gauge.Title = fmt.Sprintf("Mission Time: %s", timeString(lastLatestSegment.Raw.Timestamp)) - gauge.Percent = int(100 * lastLatestSegment.Raw.CameraProgress) + renderTable := func() { + data := ds.AllSegments() - receiving := lastEventAge < 5.0 - dataStats.Text = fmt.Sprintf("Data Points: %d\nData Rate: %.2f/s\nLast Event Age: %.2fs\nReceiving: %t", len(ds.AllSegments()), lastLatestSegment.Computed.DataRate, lastEventAge, receiving) + 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: @@ -138,19 +153,203 @@ func StartDashboard(p DataProvider, ds FlightData, logger LoggerControl) error { } case bytes := <-streamChannel: latestSegments, err := ds.IngestNewSegment(bytes) - if err == nil { - lastStreamEvent = time.Now() - if len(latestSegments) > 0 { - lastLatestSegment = latestSegments[len(latestSegments)-1] - for _, seg := range latestSegments { - logger.Log(seg) - } + if err != nil { + errorsList = append(errorsList, err) + renderErrors() + } else { + renderTable() + for _, seg := range latestSegments { + logger.Log(seg) } - renderDashboard() } - case <-ticker: - lastEventAge = float64(time.Since(lastStreamEvent)) / float64(time.Second) - renderDashboard() } } } + +func StartWeb(p DataProvider, ds 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 + mutex.Lock() + latestSegments, err := ds.IngestNewSegment(bytes) + mutex.Unlock() + if err != nil { + fmt.Println(err) + } else { + for _, seg := range latestSegments { + logger.Log(seg) + } + } + } + }() + return http.ListenAndServe(":8080", nil) +} + +// return nil +// if err := ui.Init(); err != nil { +// return err +// } +// defer ui.Close() + +// altitude := widgets.NewPlot() +// altitude.Data = make([][]float64, 1) + +// velocity := widgets.NewPlot() +// velocity.Data = make([][]float64, 1) + +// rssi := widgets.NewPlot() +// rssi.Data = make([][]float64, 1) + +// temp := widgets.NewSparkline() +// pressure := widgets.NewSparkline() +// tempPress := widgets.NewSparklineGroup(temp, pressure) +// tempPress.Title = "Temperature & Pressure" + +// gpsQuality := widgets.NewSparkline() +// gpsSats := widgets.NewSparkline() +// gps := widgets.NewSparklineGroup(gpsQuality, gpsSats) +// gps.Title = "GPS Info" + +// bearingDistance := widgets.NewParagraph() +// bearingDistance.Title = "Bearing & Distance" + +// pitchYaw := widgets.NewParagraph() +// pitchYaw.Title = "Pitch & Yaw" + +// gauge := widgets.NewGauge() + +// dataStats := widgets.NewParagraph() +// dataStats.Title = "Signal Stats" + +// grid := ui.NewGrid() +// termWidth, termHeight := ui.TerminalDimensions() +// grid.SetRect(0, 0, termWidth, termHeight) + +// grid.Set( +// ui.NewRow(1.0/2, +// ui.NewCol(1.0/3, altitude), +// ui.NewCol(1.0/3, velocity), +// ui.NewCol(1.0/3, rssi), +// ), +// ui.NewRow(5.0/16, +// ui.NewCol(1.0/3, gps), +// ui.NewCol(1.0/3, tempPress), +// ui.NewCol(1.0/3, dataStats), +// ), +// ui.NewRow(3.0/16, +// ui.NewCol(1.0/3, bearingDistance), +// ui.NewCol(1.0/3, pitchYaw), +// ui.NewCol(1.0/3, gauge), +// ), +// ) + +// uiEvents := ui.PollEvents() +// streamChannel := p.Stream() +// ticker := time.NewTicker(time.Second).C +// lastStreamEvent := time.Now() +// lastEventAge := 0.0 +// lastLatestSegment := DataSegment{} + +// renderDashboard := func() { +// if len(ds.AllSegments()) > 1 { +// curtime := ds.Time() + +// altitude.Data[0] = captureEndFrameOfData(curtime, ds.Altitude(), altitude.Inner.Dx()-10, SecondsWindow) +// altitude.Title = fmt.Sprintf("Altitude (%.2f)", lastLatestSegment.Computed.Altitude) + +// velocity.Data[0] = captureEndFrameOfData(curtime, ds.Velocity(), velocity.Inner.Dx()-10, SecondsWindow) +// velocity.Title = fmt.Sprintf("Velocity (%.2f)", lastLatestSegment.Computed.Velocity) + +// temp.Title = fmt.Sprintf("Temperature: %.2f°", lastLatestSegment.Raw.Temperature) +// temp.Data = normalize(captureEndFrameOfData(curtime, ds.Temperature(), tempPress.Inner.Dx(), SecondsWindow)) + +// pressure.Title = fmt.Sprintf("Pressure: %.2f mBar", lastLatestSegment.Computed.NormalizedPressure) +// pressure.Data = normalize(captureEndFrameOfData(curtime, ds.Pressure(), tempPress.Inner.Dx(), SecondsWindow)) + +// gpsQuality.Title = fmt.Sprintf("GPS Signal Quality: %.2f", lastLatestSegment.Raw.GPSInfo.Quality) +// gpsQuality.Data = normalize(captureEndFrameOfData(curtime, ds.GpsQuality(), gps.Inner.Dx(), SecondsWindow)) + +// gpsSats.Title = fmt.Sprintf("GPS Sats: %.0f", lastLatestSegment.Raw.GPSInfo.Sats) +// gpsSats.Data = captureEndFrameOfData(curtime, ds.GpsSats(), gps.Inner.Dx(), SecondsWindow) + +// bearingDistance.Text = fmt.Sprintf("Bearing: %.2f\nDistance: %.2f", lastLatestSegment.Computed.Bearing, lastLatestSegment.Computed.Distance) + +// pitchYaw.Text = fmt.Sprintf("Pitch: %.2f\nYaw: %.2f", lastLatestSegment.Computed.Pitch, lastLatestSegment.Computed.Yaw) + +// rssi.Data[0] = captureEndFrameOfData(curtime, ds.Rssi(), rssi.Inner.Dx()-10, SecondsWindow) +// rssi.Title = fmt.Sprintf("RSSI (%d)", lastLatestSegment.Raw.Rssi) + +// gauge.Title = fmt.Sprintf("Mission Time: %s", timeString(lastLatestSegment.Raw.Timestamp)) +// gauge.Percent = int(100 * lastLatestSegment.Raw.CameraProgress) + +// receiving := lastEventAge < 5.0 +// dataStats.Text = fmt.Sprintf("Data Points: %d\nData Rate: %.2f/s\nLast Event Age: %.2fs\nReceiving: %t", len(ds.AllSegments()), lastLatestSegment.Computed.DataRate, lastEventAge, receiving) + +// ui.Render(grid) +// } +// } + +// for { +// select { +// case e := <-uiEvents: +// switch e.ID { +// case "q", "": +// return nil +// } +// case bytes := <-streamChannel: +// latestSegments, err := ds.IngestNewSegment(bytes) +// if err == nil { +// lastStreamEvent = time.Now() +// if len(latestSegments) > 0 { +// lastLatestSegment = latestSegments[len(latestSegments)-1] +// for _, seg := range latestSegments { +// logger.Log(seg) +// } +// } +// renderDashboard() +// } +// case <-ticker: +// lastEventAge = float64(time.Since(lastStreamEvent)) / float64(time.Second) +// renderDashboard() +// } +// } diff --git a/dashboard/dashboard/static/index.html b/dashboard/dashboard/static/index.html new file mode 100644 index 0000000..4fbab85 --- /dev/null +++ b/dashboard/dashboard/static/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + +
+ + + + + + + + + + + + + diff --git a/dashboard/dashboard/static/js/attitudeWidget.js b/dashboard/dashboard/static/js/attitudeWidget.js new file mode 100644 index 0000000..ac8a525 --- /dev/null +++ b/dashboard/dashboard/static/js/attitudeWidget.js @@ -0,0 +1,18 @@ +class AttitudeWidget extends Widget { + update(data) { + const angle = this.extractor(data) + this.setDetails(angle.toFixed(2) + '°') + this.arrow.style.transform = `rotate(${angle}deg)` + } + + initDOM() { + const attitudeContainer = document.createElement('div') + attitudeContainer.className = 'attitude-container' + this.arrow = document.createElement('div') + this.arrow.className = 'attitude-arrow' + attitudeContainer.appendChild(this.arrow) + return attitudeContainer + } + + initContent() {} +} diff --git a/dashboard/dashboard/static/js/dashboard.js b/dashboard/dashboard/static/js/dashboard.js new file mode 100644 index 0000000..6ab88f4 --- /dev/null +++ b/dashboard/dashboard/static/js/dashboard.js @@ -0,0 +1,96 @@ +const signalTimeout = 5000 + +class Dashboard { + constructor(parent) { + this.parent = parent + this.children = [] + this.lastUpdate = null + this.timeout = null + this.data = [] + } + + attach() { + this.container = document.createElement('div') + this.container.className = 'dashboard' + this.parent.appendChild(this.container) + + this.children = [ + new MissionInfoWidget('Flight Info', this.container, makeInfoExtractor()), + new LineChartWidget('Altitude', 'm', this.container, makeXYExtractor('computed', 'smoothedAltitude')), + new LineChartWidget('Velocity', 'm/s', this.container, makeXYExtractor('computed', 'smoothedVelocity')), + new LineChartWidget('Temperature', '°C', this.container, makeXYExtractor('computed', 'smoothedTemperature')), + new LineChartWidget('Pressure', 'mBar', this.container, makeXYExtractor('computed', 'smoothedPressure')), + new AttitudeWidget('Pitch', this.container, makeSingleExtractor('computed', 'pitch')), + new AttitudeWidget('Yaw', this.container, makeSingleExtractor('computed', 'yaw')), + new LineChartWidget('RSSI', 'RSSI', this.container, makeXYExtractor('raw', 'rssi')), + new KVTableWidget('Signal Stats', ['Data Points', 'Data Rate', 'Last Event Age', 'Receiving', 'RSSI', 'GPS Num Stats', 'GPS Signal Quality'], this.container, (d) => this.signalStatsExtractor(d)), + new MapWidget('Location', this.container, makeCoordinateExtractor()), + ] + this.children.forEach(c => c.attach()) + } + + update(data) { + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + if (data !== null) { + this.data = data + this.container.classList.add('receiving') + } else { + this.container.classList.remove('receiving') + } + this.children.forEach(child => child.update(this.data)) + if (data !== null) { + this.lastUpdate = new Date() + } + this.timeout = setTimeout(() => this.update(null), signalTimeout) + } + + + signalStatsExtractor(data) { + if (data.length === 0) { + return [] + } + const lastEventAge = this.lastUpdate === null ? null : (new Date().getTime() - this.lastUpdate.getTime()) + + return [ + { + key: 'Data Points', + value: data.length, + normal: data.length > 0 + }, + { + key: 'Data Rate', + value: data[data.length - 1].computed.dataRate.toFixed(2) + '/s', + normal: data[data.length - 1].computed.dataRate > 1 + }, + { + key: 'Last Event Age', + value: lastEventAge === null ? 'Never' : lastEventAge + 's', + normal: lastEventAge !== null && lastEventAge < signalTimeout + }, + { + key: 'Receiving', + value: lastEventAge !== null && lastEventAge < signalTimeout ? 'Yes' : 'No', + normal: lastEventAge !== null && lastEventAge < signalTimeout + }, + { + key: 'RSSI', + value: data[data.length - 1].raw.rssi, + normal: data[data.length - 1].raw.rssi > -70 + }, + { + key: 'GPS Num Stats', + value: data[data.length - 1].raw.gpsInfo.sats, + normal: data[data.length - 1].raw.gpsInfo.sats > 0 + }, + { + key: 'GPS Signal Quality', + value: data[data.length - 1].raw.gpsInfo.quality, + normal: data[data.length - 1].raw.gpsInfo.quality > 0 + } + ] + } +} + diff --git a/dashboard/dashboard/static/js/kvTableWidget.js b/dashboard/dashboard/static/js/kvTableWidget.js new file mode 100644 index 0000000..3d14d69 --- /dev/null +++ b/dashboard/dashboard/static/js/kvTableWidget.js @@ -0,0 +1,54 @@ +class KVTableWidget extends Widget { + constructor(title, keys, parent, extractor) { + super(title, parent, extractor) + this.keys = keys + } + + update(data) { + const extractedData = this.extractor(data) + extractedData.forEach(({key, value, normal}) => { + if (this.valueTds[key]) { + this.valueTds[key].textContent = value + if (normal) { + this.valueTds[key].classList.add('normal') + } else { + this.valueTds[key].classList.remove('normal') + } + } + }) + } + + initDOM() { + const table = document.createElement('table') + table.className = 'kv-table' + const thead = document.createElement('thead') + table.appendChild(thead) + const tr = document.createElement('tr') + thead.appendChild(tr) + const headers = ['Attribute', 'Value'] + headers.forEach(h => { + const th = document.createElement('th') + th.textContent = h + tr.appendChild(th) + }) + const tbody = document.createElement('tbody') + table.appendChild(tbody) + this.valueTds = {} + this.keys.forEach(key => { + const tr = document.createElement('tr') + tbody.appendChild(tr) + const keyTd = document.createElement('td') + keyTd.textContent = key + tr.appendChild(keyTd) + const valueTd = document.createElement('td') + valueTd.className = 'kv-table-value' + tr.appendChild(valueTd) + this.valueTds[key] = valueTd + }) + return table + } + + initContent() { + + } +} diff --git a/dashboard/dashboard/static/js/lineChartWidget.js b/dashboard/dashboard/static/js/lineChartWidget.js new file mode 100644 index 0000000..6a2ba42 --- /dev/null +++ b/dashboard/dashboard/static/js/lineChartWidget.js @@ -0,0 +1,69 @@ +class LineChartWidget extends Widget { + constructor(title, units, parent, extractor) { + super(title, parent, extractor) + this.units = units + } + + update(data) { + const extractedData = this.extractor(data) + this.chart.data.datasets[0].data = extractedData + this.chart.update() + if (extractedData.length > 0) { + this.setDetails(`${extractedData[extractedData.length - 1].y.toFixed(2)} ${this.units}`) + } + } + + initDOM() { + this.canvas = document.createElement('canvas') + return this.canvas + } + + initContent() { + const ctx = this.canvas.getContext('2d') + this.chart = new Chart(ctx, { + type: 'scatter', + data: { + datasets: [{ + data: [], + }] + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 1, + showLine: true, + elements: { + point: { + radius: 0 + }, + line: { + borderColor: '#000', + borderWidth: 1, + } + }, + plugins: { + legend: { + display: false + } + }, + animation: { + duration: 0, + }, + scales: { + x: { + title: { + display: true, + text: 'Seconds' + } + }, + y: { + title: { + display: true, + text: this.units + } + } + } + } + }) + } +} diff --git a/dashboard/dashboard/static/js/main.js b/dashboard/dashboard/static/js/main.js new file mode 100644 index 0000000..742c91e --- /dev/null +++ b/dashboard/dashboard/static/js/main.js @@ -0,0 +1,10 @@ +(() => { + let data = [] + const dashboard = new Dashboard(document.getElementById('main')) + dashboard.attach() + const webSocket = new WebSocket(`ws://${window.location.host}/api/data`) + webSocket.onmessage = (e) => { + data = data.concat(JSON.parse(e.data)) + dashboard.update(data) + } +})() diff --git a/dashboard/dashboard/static/js/mapWidget.js b/dashboard/dashboard/static/js/mapWidget.js new file mode 100644 index 0000000..4644b0e --- /dev/null +++ b/dashboard/dashboard/static/js/mapWidget.js @@ -0,0 +1,31 @@ +class MapWidget extends Widget { + constructor(title, parent, extractor) { + super(title, parent, extractor) + } + + update(data) { + const mapData = this.extractor(data) + this.map.setView(mapData.coordinates, this.map.getZoom()) + this.marker.setLatLng(mapData.coordinates) + this.setDetails(`Bearing: ${mapData.bearing.toFixed(2)}°, Distance: ${mapData.distance.toFixed(2)}m`) + } + + initDOM() { + this.mapContainer = document.createElement('div') + this.mapContainer.className = 'map-container' + return this.mapContainer + } + + initContent() { + this.map = L.map(this.mapContainer).setView([0,0], 17) + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + this.marker = L.circle([0,0], { + color: 'red', + fillColor: '#f03', + fillOpacity: 0.5, + radius: 5 + }).addTo(this.map) + } +} diff --git a/dashboard/dashboard/static/js/missionInfoWidget.js b/dashboard/dashboard/static/js/missionInfoWidget.js new file mode 100644 index 0000000..a9c6a27 --- /dev/null +++ b/dashboard/dashboard/static/js/missionInfoWidget.js @@ -0,0 +1,36 @@ +const modeMap = { + 'P': 'Prelaunch', + 'AP': 'Powered Ascent', + 'AU': 'Unpowered Ascent', + 'DF': 'Freefall Descent', + 'DP': 'Parachute Descent', + 'R': 'Recovery' +} + +class MissionInfoWidget extends Widget { + update(data) { + const extractedData = this.extractor(data) + this.time.textContent = formatSeconds(extractedData.time) + this.mode.textContent = modeMap[extractedData.mode] + this.mode.className = ['mission-info-mode', 'mission-info-mode-' + extractedData.mode.toLowerCase()].join(' ') + } + + initDOM() { + const container = document.createElement('div') + container.className = 'mission-info-container' + + this.time = document.createElement('div') + this.time.className = 'mission-info-time' + container.appendChild(this.time) + + this.mode = document.createElement('div') + this.mode.className = 'mission-info-mode' + container.appendChild(this.mode) + + return container + } + + initContent() { + + } +} diff --git a/dashboard/dashboard/static/js/util.js b/dashboard/dashboard/static/js/util.js new file mode 100644 index 0000000..d24c6e5 --- /dev/null +++ b/dashboard/dashboard/static/js/util.js @@ -0,0 +1,39 @@ +function makeXYExtractor(propType, propName) { + return (data) => data.map(segment => ({ + x: segment.raw.timestamp, + y: segment[propType][propName] + })) +} + +function makeCoordinateExtractor() { + return (data) => ({ + coordinates: [data[data.length - 1].raw.coordinate.lat, data[data.length - 1].raw.coordinate.lon], + bearing: data[data.length - 1].computed.bearing, + distance: data[data.length - 1].computed.distance + }) +} + +function makeInfoExtractor() { + return (data) => ({ + pcnt: data[data.length - 1].raw.cameraProgress, + time: data[data.length - 1].raw.timestamp, + mode: data[data.length - 1].computed.flightMode + }) +} + +function makeSingleExtractor(propType, propName) { + return (data) => data[data.length - 1][propType][propName] +} + +function formatSeconds(time) { + const hrs = ~~(time / 3600) + const mins = ~~((time % 3600) / 60) + const secs = ~~time % 60 + let ret = '' + if (hrs > 0) { + ret += '' + hrs + ':' + (mins < 10 ? '0' : '') + } + ret += '' + mins + ':' + (secs < 10 ? '0' : '') + ret += '' + secs + return ret +} diff --git a/dashboard/dashboard/static/js/widget.js b/dashboard/dashboard/static/js/widget.js new file mode 100644 index 0000000..e9d9a3d --- /dev/null +++ b/dashboard/dashboard/static/js/widget.js @@ -0,0 +1,44 @@ +class Widget { + constructor(title, parent, extractor) { + this.title = title + this.parent = parent + this.extractor = extractor + } + + attach() { + const widget = document.createElement('div') + widget.className = 'widget' + + const header = document.createElement('div') + header.className = 'widget-header' + widget.appendChild(header) + + const title = document.createElement('div') + title.className = 'widget-title' + title.textContent = this.title + header.appendChild(title) + + this.details = document.createElement('div') + this.details.className = 'widget-details' + header.appendChild(this.details) + + const content = document.createElement('div') + content.className = 'widget-content' + content.appendChild(this.initDOM()) + widget.appendChild(content) + + this.parent.appendChild(widget) + + this.initContent() + } + + setDetails(details) { + this.details.textContent = details + } + + update(data) {} + + initDOM() {} + + initContent() {} +} diff --git a/dashboard/dashboard/static/style/style.css b/dashboard/dashboard/static/style/style.css new file mode 100644 index 0000000..07c9d12 --- /dev/null +++ b/dashboard/dashboard/static/style/style.css @@ -0,0 +1,162 @@ +body { + margin: 0; + padding: 0; +} + +.dashboard { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-gap: 10px; + font-family: 'Courier New', Courier, monospace; + border: solid 5px red; + padding: 10px; + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: scroll; +} + +.dashboard.receiving { + border-color: green; +} + +.widget { + border: solid 1px black; + display: flex; + justify-content: space-evenly; + flex-direction: column; +} + +.widget-header { + padding: 5px; + background: black; + color: white; + display: flex; + flex-direction: row; +} + +.widget-title { + font-weight: bold; +} + +.widget-details { + text-align: right; + flex-grow: 1; +} + +.widget-content { + padding: 5px; + flex-grow: 1; + position: relative; + align-items: center; + justify-content: center; + display: flex; + flex-direction: column; +} + +.widget-content table { + width: 100%; +} + +.widget-content table th { + width: 50%; +} + +.map-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.attitude-container { + position: absolute; + right: 10px; + left: 10px; + height: 0; + padding-bottom: 100%; + background: gray; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; +} + +.attitude-arrow { + width: 0; + height: 0; + border-left: 30px solid transparent; + border-right: 30px solid transparent; + border-bottom: 200px solid white; + margin-top: 100%; +} + +.mission-info-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.mission-info-time { + font-size: 5em; +} + +.mission-info-mode { + padding: 5px; + min-width: 75%; + text-align: center; + font-size: 1.5em; + color: black; +} + +.mission-info-mode-p { + background-color: gray; +} + +.mission-info-mode-ap { + background-color: green; +} + +.mission-info-mode-au { + background-color: yellow; +} + +.mission-info-mode-df { + background-color: red; +} + +.mission-info-mode-dp { + background-color: orange; +} + +.mission-info-mode-r { + background-color: blue; +} + +.kv-table td { + border: solid 1px gray; +} + +.kv-table thead { + background: black; + color: white; +} + +.kv-table-value { + text-align: center; + background: red; +} + +.kv-table-value.normal { + background: green; +} diff --git a/dashboard/dashboard/telemetryio.go b/dashboard/dashboard/telemetryio.go index 3b77953..c2d77c2 100644 --- a/dashboard/dashboard/telemetryio.go +++ b/dashboard/dashboard/telemetryio.go @@ -58,10 +58,10 @@ func bytesToDataSegment(stream FlightData, bytes []byte) ([]DataSegment, float64 for i := len(segments) - 1; i >= 0; i-- { offset := 1 + (i * 13) raw := RawDataSegment{ - CameraProgress: telemetryFloatFromByteIndex(telemetryBytes, 0), - Timestamp: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexTimestamp), - Pressure: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexPressure), - Temperature: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexTemperature), + WriteProgress: telemetryFloatFromByteIndex(telemetryBytes, 0), + Timestamp: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexTimestamp), + Pressure: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexPressure), + Temperature: telemetryFloatFromByteIndex(telemetryBytes, offset+IndexTemperature), Acceleration: XYZ{ telemetryFloatFromByteIndex(telemetryBytes, offset+IndexAccelerationX), telemetryFloatFromByteIndex(telemetryBytes, offset+IndexAccelerationY), diff --git a/dashboard/dashboard/types.go b/dashboard/dashboard/types.go index 04e741c..40348d7 100644 --- a/dashboard/dashboard/types.go +++ b/dashboard/dashboard/types.go @@ -5,48 +5,55 @@ import ( "sync" ) +type FlightMode string + type Coordinate struct { - Lat float64 - Lon float64 + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` } type GPSInfo struct { - Quality float64 - Sats float64 + Quality float64 `json:"quality"` + Sats float64 `json:"sats"` } type XYZ struct { - X float64 - Y float64 - Z float64 + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` } type RawDataSegment struct { - CameraProgress float64 - Timestamp float64 - Pressure float64 - Temperature float64 - Acceleration XYZ - Magnetic XYZ - Coordinate Coordinate - GPSInfo GPSInfo - Rssi int16 + 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 - Velocity float64 - Yaw float64 - Pitch float64 - NormalizedPressure float64 - Bearing float64 - Distance float64 - DataRate float64 + 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 - Computed ComputedDataSegment + Raw RawDataSegment `json:"raw"` + Computed ComputedDataSegment `json:"computed"` } type DataProvider interface { @@ -73,10 +80,10 @@ type FlightData interface { BasePressure() float64 Origin() Coordinate Time() []float64 - Altitude() []float64 - Velocity() []float64 - Temperature() []float64 - Pressure() []float64 + SmoothedAltitude() []float64 + SmoothedVelocity() []float64 + SmoothedTemperature() []float64 + SmoothedPressure() []float64 GpsQuality() []float64 GpsSats() []float64 Rssi() []float64 diff --git a/dashboard/dashboard/util.go b/dashboard/dashboard/util.go index 6cfd2eb..d7fcf10 100644 --- a/dashboard/dashboard/util.go +++ b/dashboard/dashboard/util.go @@ -1,20 +1,5 @@ package dashboard -import ( - "fmt" - "math" -) - -func timeString(seconds float64) string { - minutes := math.Floor(seconds / 60) - seconds = (seconds - minutes*60) - secondsStr := fmt.Sprintf("%.1f", seconds) - if seconds < 10 { - secondsStr = fmt.Sprintf("0%.1f", seconds) - } - return fmt.Sprintf("%.0f:%s", minutes, secondsStr) -} - func singleFlightDataElement(ds FlightData, accessor func(DataSegment) float64) []float64 { data := make([]float64, len(ds.AllSegments())) for i, segment := range ds.AllSegments() { @@ -23,53 +8,6 @@ func singleFlightDataElement(ds FlightData, accessor func(DataSegment) float64) return data } -func captureEndFrameOfData(time []float64, data []float64, length int, duration float64) []float64 { - if len(time) < 2 || length <= 0 { - return []float64{0, 0} - } - startIndex := 0 - endTime := time[len(time)-1] - for i := len(time) - 2; i >= 0 && startIndex == 0; i-- { - currentDuration := endTime - time[i] - if currentDuration >= duration { - startIndex = i - } - } - output := make([]float64, length) - maxLength := 0 - startTime := time[startIndex] - for i := startIndex; i < len(data); i++ { - elapsed := time[i] - startTime - pcnt := elapsed / duration - index := int(math.Floor(pcnt * float64(length))) - if index < len(output) { - output[index] = data[i] - maxLength = index + 1 - } - } - if maxLength < 2 { - return []float64{0, 0} - } - return output[:maxLength] -} - -func normalize(data []float64) []float64 { - maxValue := -1.0 * math.MaxFloat64 - minValue := math.MaxFloat64 - for _, datapoint := range data { - if datapoint > maxValue { - maxValue = datapoint - } - if datapoint < minValue { - minValue = datapoint - } - } - - valueRange := maxValue - minValue - normalized := make([]float64, len(data)) - for i, datapoint := range data { - normalized[i] = (datapoint - minValue) / valueRange - } - - return normalized +func smoothed(alpha float64, xt float64, stm1 float64) float64 { + return alpha*xt + (1-alpha)*stm1 } diff --git a/dashboard/generate_test_data/generate_test_data.go b/dashboard/generate_test_data/generate_test_data.go index 58b57f5..c1fcc0e 100644 --- a/dashboard/generate_test_data/generate_test_data.go +++ b/dashboard/generate_test_data/generate_test_data.go @@ -24,6 +24,10 @@ func main() { csvr := csv.NewReader(f) + var buf bytes.Buffer + records := 0 + skipped := 0 + pcnt := 0.0 for { row, err := csvr.Read() if err != nil { @@ -33,33 +37,47 @@ func main() { fmt.Println(err) return } - var buf bytes.Buffer - for _, el := range row { - p, err := strconv.ParseFloat(el, 64) - if err != nil { - fmt.Println(err) - return - } else { - err := binary.Write(&buf, binary.LittleEndian, p) + if skipped == 3 { + skipped = 0 + if records == 0 { + _ = binary.Write(&buf, binary.LittleEndian, pcnt) + pcnt += 0.0001 + } + for _, el := range row { + p, err := strconv.ParseFloat(el, 64) if err != nil { fmt.Println(err) return + } else { + err := binary.Write(&buf, binary.LittleEndian, p) + if err != nil { + fmt.Println(err) + return + } } } - } - sEnc := b64.StdEncoding.EncodeToString(buf.Bytes()) + records++ + if records == 2 { + sEnc := b64.StdEncoding.EncodeToString(buf.Bytes()) - var buf1 bytes.Buffer - rssi := int16(rand.Intn(100) * -1) - err = binary.Write(&buf1, binary.LittleEndian, rssi) - if err != nil { - fmt.Println(err) - return - } - sEnc1 := b64.StdEncoding.EncodeToString(buf1.Bytes()) + var buf1 bytes.Buffer + rssi := int16(rand.Intn(100) * -1) + err = binary.Write(&buf1, binary.LittleEndian, rssi) + if err != nil { + fmt.Println(err) + return + } + sEnc1 := b64.StdEncoding.EncodeToString(buf1.Bytes()) - line := []string{"T", sEnc, sEnc1} + line := []string{"T", sEnc, sEnc1} - fmt.Println(strings.Join(line[:], ",")) + fmt.Println(strings.Join(line[:], ",")) + + records = 0 + buf = *bytes.NewBuffer([]byte{}) + } + } else { + skipped++ + } } } diff --git a/dashboard/go.mod b/dashboard/go.mod index 2f30f00..e70e47d 100644 --- a/dashboard/go.mod +++ b/dashboard/go.mod @@ -3,6 +3,8 @@ module main go 1.16 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/termui v0.1.0 github.com/stretchr/testify v1.7.0 diff --git a/dashboard/go.sum b/dashboard/go.sum index 33604e1..16e2f62 100644 --- a/dashboard/go.sum +++ b/dashboard/go.sum @@ -1,5 +1,9 @@ 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/johnjones4/termui v0.1.0 h1:js88oav+7ePj5frAsaO2wJSsttvEctiw+w2WUlRRl7g= diff --git a/dashboard/main.go b/dashboard/main.go index bf0ba6a..3daf3fb 100644 --- a/dashboard/main.go +++ b/dashboard/main.go @@ -1,22 +1,32 @@ package main import ( + "flag" . "main/dashboard" - "os" "strings" ) +const ( + OUTPUT_TYPE_TEXT = "text" + OUTPUT_TYPE_WEB = "web" +) + func main() { - input := os.Args[1] + 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 strings.HasPrefix(input, "/dev/") { + if *input == "" { + flag.Usage() + return + } else if strings.HasPrefix(*input, "/dev/") { var providerSerial DataProviderSerial - providerSerial, err = NewDataProviderSerial(input, 9600) + providerSerial, err = NewDataProviderSerial(*input, 9600) provider = providerSerial - // defer providerSerial.Port.Close() } else { - provider, err = NewDataProviderFile(input) + provider, err = NewDataProviderFile(*input) } if err != nil { panic(err) @@ -24,8 +34,12 @@ func main() { df := NewFlightData() logger := NewLogger() defer logger.Kill() - err = StartDashboard(provider, &df, logger) - // err = StartTextLogger(provider, &df, logger) + 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) }