From 56f3286fd1b90c8ef439f3a697b9a0501d432aa5 Mon Sep 17 00:00:00 2001 From: David Beechey Date: Tue, 1 Oct 2024 10:42:30 +0100 Subject: [PATCH] TEL - Add Telemetry code from 2024 (HYPE-16) (#10) --- telemetry/.gitignore | 40 + telemetry/.prettierrc | 5 + telemetry/Dockerfile | 37 + telemetry/docker-compose.mqtt.yml | 19 + telemetry/docker-compose.yml | 58 + telemetry/entry.sh | 9 + telemetry/influxdb/scripts/setup.sh | 4 + telemetry/mosquitto/config/mosquitto.conf | 14 + telemetry/package.json | 30 + telemetry/packages/constants/.eslintrc.js | 9 + telemetry/packages/constants/package.json | 26 + .../packages/constants/src/faults/levels.ts | 7 + telemetry/packages/constants/src/index.ts | 19 + .../src/openmct/object-types/object-types.ts | 54 + .../packages/constants/src/pods/common.ts | 82 + .../packages/constants/src/pods/modes.ts | 27 + telemetry/packages/constants/src/pods/pods.ts | 879 + .../packages/constants/src/pods/states.ts | 55 + .../src/socket/getMeasurementRoomName.ts | 6 + .../packages/constants/src/socket/index.ts | 11 + telemetry/packages/constants/tsconfig.json | 12 + telemetry/packages/e2e-tests/.gitignore | 5 + telemetry/packages/e2e-tests/lib/mqtt.ts | 57 + telemetry/packages/e2e-tests/package.json | 19 + .../packages/e2e-tests/playwright.config.ts | 57 + .../packages/e2e-tests/tests/example.spec.ts | 23 + .../packages/e2e-tests/tests/test-todo.md | 101 + telemetry/packages/e2e-tests/tsconfig.json | 6 + telemetry/packages/eslint-config/README.md | 3 + telemetry/packages/eslint-config/basic.js | 28 + telemetry/packages/eslint-config/package.json | 19 + telemetry/packages/eslint-config/react.js | 23 + telemetry/packages/fake/.eslintrc.js | 9 + telemetry/packages/fake/README.md | 337 + telemetry/packages/fake/package.json | 24 + telemetry/packages/fake/src/base.ts | 67 + telemetry/packages/fake/src/config.ts | 99 + telemetry/packages/fake/src/index.ts | 32 + telemetry/packages/fake/src/sensorManager.ts | 141 + telemetry/packages/fake/src/sensors/index.ts | 32 + .../packages/fake/src/sensors/keyence.ts | 51 + .../packages/fake/src/sensors/levitation.ts | 150 + .../packages/fake/src/sensors/magnetism.ts | 42 + telemetry/packages/fake/src/sensors/motion.ts | 57 + .../packages/fake/src/sensors/pressure.ts | 91 + .../packages/fake/src/sensors/resistance.ts | 39 + .../packages/fake/src/sensors/temperature.ts | 47 + telemetry/packages/fake/src/types.ts | 23 + telemetry/packages/fake/src/utils.ts | 115 + telemetry/packages/fake/tsconfig.json | 13 + telemetry/packages/public-app/.env.docker | 2 + telemetry/packages/public-app/.env.example | 2 + telemetry/packages/public-app/.eslintrc.json | 8 + telemetry/packages/public-app/.gitignore | 35 + telemetry/packages/public-app/app/cards.tsx | 87 + telemetry/packages/public-app/app/favicon.ico | Bin 0 -> 3541 bytes telemetry/packages/public-app/app/globals.css | 16 + telemetry/packages/public-app/app/layout.tsx | 25 + .../public-app/app/loading-screen.tsx | 31 + telemetry/packages/public-app/app/page.tsx | 30 + .../packages/public-app/app/providers.tsx | 12 + .../components/displacement-chart.tsx | 68 + .../public-app/components/launch-time.tsx | 43 + .../components/levitation-height.tsx | 99 + .../public-app/components/social-icons.tsx | 54 + .../public-app/components/theme-switch.tsx | 22 + .../public-app/components/velocity-graph.tsx | 68 + telemetry/packages/public-app/helpers.ts | 73 + telemetry/packages/public-app/next.config.js | 16 + telemetry/packages/public-app/package.json | 45 + .../packages/public-app/postcss.config.js | 6 + .../packages/public-app/public/hyped-dark.png | Bin 0 -> 29377 bytes .../public-app/public/hyped-light.png | Bin 0 -> 29564 bytes .../packages/public-app/public/hyped.svg | 47 + .../packages/public-app/tailwind.config.js | 142 + telemetry/packages/public-app/tsconfig.json | 20 + telemetry/packages/server/.env.docker | 6 + telemetry/packages/server/.env.example | 6 + telemetry/packages/server/.eslintrc.js | 9 + telemetry/packages/server/README.md | 73 + telemetry/packages/server/nest-cli.json | 8 + telemetry/packages/server/package.json | 54 + .../packages/server/src/app.controller.ts | 12 + telemetry/packages/server/src/app.module.ts | 34 + telemetry/packages/server/src/app.service.ts | 8 + telemetry/packages/server/src/main.ts | 18 + .../src/modules/common/types/InfluxRow.ts | 5 + .../modules/common/utils/toUnixTimestamp.ts | 3 + .../common/utils/zodEnumFromObjKeys.ts | 8 + .../controls/PodControls.controller.ts | 20 + .../modules/controls/PodControls.module.ts | 9 + .../modules/controls/PodControls.service.ts | 48 + .../server/src/modules/core/config.ts | 20 + .../src/modules/influx/Influx.module.ts | 8 + .../src/modules/influx/Influx.service.ts | 78 + .../influx/errors/InfluxServiceError.ts | 6 + .../src/modules/live-logs/LiveLogs.gateway.ts | 22 + .../modules/live-logs/LiveLogsTransport.ts | 33 + .../src/modules/logger/Logger.decorator.ts | 4 + .../src/modules/logger/Logger.module.ts | 72 + .../modules/measurement/Measurement.module.ts | 12 + .../measurement/Measurement.service.ts | 98 + .../measurement/MeasurementReading.types.ts | 48 + .../MeasurementReadingValidationError.ts | 6 + .../utils/doesMeasurementBreachLimits.ts | 27 + .../modules/mqtt/client/MqttClientModule.ts | 13 + .../mqtt/ingestion/MqttIngestion.module.ts | 10 + .../mqtt/ingestion/MqttIngestion.service.ts | 75 + .../ingestion/errors/MqttIngestionError.ts | 6 + .../src/modules/openmct/OpenMCT.module.ts | 13 + .../openmct/data/OpenMCTData.module.ts | 13 + .../HistoricalTelemetryData.controller.ts | 22 + .../HistoricalTelemetryData.service.ts | 58 + .../realtime/RealtimeTelemetryData.gateway.ts | 65 + .../dictionary/Dictionary.controller.ts | 29 + .../openmct/dictionary/Dictionary.service.ts | 52 + .../utils/mapMeasurementToOpenMct.ts | 39 + .../openmct/faults/Fault.controller.ts | 37 + .../modules/openmct/faults/Fault.module.ts | 19 + .../modules/openmct/faults/Fault.service.ts | 262 + .../HistoricalFaultData.controller.ts | 30 + .../historical/HistoricalFaultData.service.ts | 75 + .../realtime/RealtimeFaultData.gateway.ts | 50 + .../faults/utils/convertToOpenMctFault.ts | 39 + .../object-types/ObjectTypes.controller.ts | 12 + .../object-types/ObjectTypes.service.ts | 14 + .../public-data/PublicData.controller.ts | 175 + .../modules/public-data/PublicData.module.ts | 12 + .../modules/public-data/PublicData.service.ts | 123 + .../remote-logs/RemoteLogs.controller.ts | 23 + .../modules/remote-logs/RemoteLogs.module.ts | 9 + .../modules/remote-logs/RemoteLogs.service.ts | 34 + .../server/src/modules/state/State.module.ts | 10 + .../server/src/modules/state/State.service.ts | 64 + .../src/modules/state/StateUpdate.types.ts | 11 + .../MeasurementReadingValidationError.ts | 6 + .../src/modules/state/utils/isValidState.ts | 5 + .../modules/warnings/Warnings.controller.ts | 12 + .../src/modules/warnings/Warnings.module.ts | 9 + .../src/modules/warnings/Warnings.service.ts | 17 + .../packages/server/test/app.e2e-spec.ts | 24 + telemetry/packages/server/test/jest-e2e.json | 9 + telemetry/packages/server/tsconfig.json | 22 + telemetry/packages/tsconfig/base.json | 27 + telemetry/packages/tsconfig/package.json | 5 + telemetry/packages/types/.eslintrc.js | 9 + telemetry/packages/types/package.json | 17 + telemetry/packages/types/src/index.ts | 25 + .../src/openmct/openmct-dictionary.types.ts | 45 + .../types/src/openmct/openmct-fault.types.ts | 37 + .../src/openmct/openmct-object-types.types.ts | 8 + .../packages/types/src/pods/pods.types.ts | 49 + .../packages/types/src/server/responses.ts | 39 + .../packages/types/src/utils/Unpacked.ts | 1 + telemetry/packages/types/tsconfig.json | 9 + telemetry/packages/ui/.env.docker | 9 + telemetry/packages/ui/.env.example | 9 + telemetry/packages/ui/.eslintrc.cjs | 10 + telemetry/packages/ui/.gitignore | 24 + telemetry/packages/ui/app/App.tsx | 43 + .../packages/ui/app/components/error.tsx | 95 + .../app/components/shared/latency-chart.tsx | 45 + .../ui/app/components/shared/logo.tsx | 30 + .../ui/app/components/shared/pod-state.tsx | 60 + .../shared/set-levitation-height.tsx | 44 + .../ui/app/components/sidebar/index.tsx | 81 + .../ui/app/components/sidebar/latency.tsx | 51 + .../sidebar/pod-connection-status.tsx | 87 + .../app/components/sidebar/pod-controls.tsx | 92 + .../app/components/sidebar/pod-selector.tsx | 61 + .../ui/app/components/ui/alert-dialog.tsx | 145 + .../packages/ui/app/components/ui/button.tsx | 57 + .../packages/ui/app/components/ui/card.tsx | 86 + .../ui/app/components/ui/carousel.tsx | 262 + .../packages/ui/app/components/ui/dialog.tsx | 123 + .../ui/app/components/ui/dropdown-menu.tsx | 200 + .../packages/ui/app/components/ui/form.tsx | 179 + .../packages/ui/app/components/ui/input.tsx | 25 + .../packages/ui/app/components/ui/label.tsx | 26 + .../ui/app/components/ui/resizable.tsx | 44 + .../packages/ui/app/components/ui/select.tsx | 165 + .../packages/ui/app/components/ui/switch.tsx | 29 + .../ui/app/components/ui/textarea.tsx | 24 + .../packages/ui/app/components/ui/tooltip.tsx | 38 + .../connection-statuses.tsx | 65 + .../mqtt-connection-status.tsx | 85 + .../pod-connection-statuses.tsx | 69 + .../server-connection-status.tsx | 113 + .../views/debug-view/full-controls.tsx | 216 + .../app/components/views/debug-view/index.tsx | 75 + .../views/debug-view/mqtt-sender.tsx | 198 + .../views/debug-view/pod-state-updater.tsx | 65 + .../ui/app/components/views/log-viewer.tsx | 184 + .../ui/app/components/views/openmct.tsx | 10 + .../components/views/state-machine/edges.ts | 217 + .../components/views/state-machine/index.tsx | 564 + .../views/state-machine/nodes/active-node.tsx | 38 + .../state-machine/nodes/failure-node.tsx | 38 + .../views/state-machine/nodes/index.ts | 4 + .../state-machine/nodes/neutral-node.tsx | 35 + .../state-machine/nodes/passive-node.tsx | 37 + .../views/state-machine/nodes/styles.ts | 2 + .../components/views/state-machine/styles.css | 7 + .../components/views/state-machine/types.ts | 33 + .../components/views/state-machine/utils.ts | 29 + telemetry/packages/ui/app/config.ts | 52 + telemetry/packages/ui/app/context/errors.tsx | 103 + .../packages/ui/app/context/live-logs.tsx | 109 + telemetry/packages/ui/app/context/mqtt.tsx | 183 + telemetry/packages/ui/app/context/pods.tsx | 349 + telemetry/packages/ui/app/globals.css | 59 + .../packages/ui/app/hooks/useKeyPress.ts | 53 + telemetry/packages/ui/app/index.html | 12 + telemetry/packages/ui/app/lib/controls.ts | 35 + telemetry/packages/ui/app/lib/logger.ts | 28 + telemetry/packages/ui/app/lib/utils.ts | 15 + telemetry/packages/ui/app/main.tsx | 26 + telemetry/packages/ui/app/providers.tsx | 20 + .../ui/app/types/MQTTConnectionStatus.ts | 11 + .../ui/app/types/PodConnectionStatus.ts | 9 + telemetry/packages/ui/app/types/mqtt.ts | 9 + telemetry/packages/ui/app/views.tsx | 44 + telemetry/packages/ui/app/vite-env.d.ts | 1 + telemetry/packages/ui/components.json | 17 + telemetry/packages/ui/index.html | 34 + telemetry/packages/ui/mqtt.d.ts | 4 + telemetry/packages/ui/openmct/core/config.ts | 2 + telemetry/packages/ui/openmct/core/http.ts | 20 + telemetry/packages/ui/openmct/index.html | 17 + telemetry/packages/ui/openmct/main.ts | 79 + .../packages/ui/openmct/plugins/conductor.ts | 112 + .../openmct/plugins/data/object-types-data.ts | 10 + .../ui/openmct/plugins/data/pods-data.ts | 28 + .../ui/openmct/plugins/dictionary-plugin.ts | 129 + .../plugins/faults-plugin/constants.ts | 5 + .../plugins/faults-plugin/faults-plugin.ts | 94 + .../historical-faults-provider.ts | 27 + .../faults-plugin/realtime-faults-provider.ts | 60 + .../plugins/historical-telemetry-plugin.ts | 37 + .../ui/openmct/plugins/limit-plugin.ts | 146 + .../plugins/realtime-telemetry-plugin.ts | 73 + .../plugins/utils/convertNamespaceToPodId.ts | 9 + .../ui/openmct/types/AugmentedDomainObject.ts | 7 + telemetry/packages/ui/openmct/types/Datum.ts | 6 + .../ui/openmct/types/MeasurementReading.ts | 6 + .../ui/openmct/types/ObjectIdentifier.ts | 4 + .../types/RealtimeFaultsListenerCallback.ts | 1 + .../RealtimeTelemetryListenerCallback.ts | 5 + .../packages/ui/openmct/types/Unpacked.ts | 1 + telemetry/packages/ui/package.json | 71 + telemetry/packages/ui/postcss.config.cjs | 6 + telemetry/packages/ui/public/hyped.svg | 47 + telemetry/packages/ui/public/images/pod.png | Bin 0 -> 329584 bytes .../public/mct-dashboards/local-storage.json | 3580 ++++ .../ui/public/openmct/data/dashboards.json | 1602 ++ telemetry/packages/ui/tailwind.config.cjs | 204 + telemetry/packages/ui/tsconfig.json | 23 + telemetry/packages/ui/vite.config.ts | 43 + telemetry/patches/@nestjs__common@9.4.2.patch | 18 + telemetry/patches/nest-mqtt@0.2.0.patch | 15 + telemetry/patches/openmct@3.2.0.patch | 37 + telemetry/pnpm-lock.yaml | 14730 ++++++++++++++++ telemetry/pnpm-workspace.yaml | 2 + telemetry/telemetry.sh | 83 + telemetry/turbo.json | 37 + 265 files changed, 33365 insertions(+) create mode 100644 telemetry/.gitignore create mode 100644 telemetry/.prettierrc create mode 100644 telemetry/Dockerfile create mode 100644 telemetry/docker-compose.mqtt.yml create mode 100644 telemetry/docker-compose.yml create mode 100755 telemetry/entry.sh create mode 100755 telemetry/influxdb/scripts/setup.sh create mode 100644 telemetry/mosquitto/config/mosquitto.conf create mode 100644 telemetry/package.json create mode 100644 telemetry/packages/constants/.eslintrc.js create mode 100644 telemetry/packages/constants/package.json create mode 100644 telemetry/packages/constants/src/faults/levels.ts create mode 100644 telemetry/packages/constants/src/index.ts create mode 100644 telemetry/packages/constants/src/openmct/object-types/object-types.ts create mode 100644 telemetry/packages/constants/src/pods/common.ts create mode 100644 telemetry/packages/constants/src/pods/modes.ts create mode 100644 telemetry/packages/constants/src/pods/pods.ts create mode 100644 telemetry/packages/constants/src/pods/states.ts create mode 100644 telemetry/packages/constants/src/socket/getMeasurementRoomName.ts create mode 100644 telemetry/packages/constants/src/socket/index.ts create mode 100644 telemetry/packages/constants/tsconfig.json create mode 100644 telemetry/packages/e2e-tests/.gitignore create mode 100644 telemetry/packages/e2e-tests/lib/mqtt.ts create mode 100644 telemetry/packages/e2e-tests/package.json create mode 100644 telemetry/packages/e2e-tests/playwright.config.ts create mode 100644 telemetry/packages/e2e-tests/tests/example.spec.ts create mode 100644 telemetry/packages/e2e-tests/tests/test-todo.md create mode 100644 telemetry/packages/e2e-tests/tsconfig.json create mode 100644 telemetry/packages/eslint-config/README.md create mode 100644 telemetry/packages/eslint-config/basic.js create mode 100644 telemetry/packages/eslint-config/package.json create mode 100644 telemetry/packages/eslint-config/react.js create mode 100644 telemetry/packages/fake/.eslintrc.js create mode 100644 telemetry/packages/fake/README.md create mode 100644 telemetry/packages/fake/package.json create mode 100644 telemetry/packages/fake/src/base.ts create mode 100644 telemetry/packages/fake/src/config.ts create mode 100644 telemetry/packages/fake/src/index.ts create mode 100644 telemetry/packages/fake/src/sensorManager.ts create mode 100644 telemetry/packages/fake/src/sensors/index.ts create mode 100644 telemetry/packages/fake/src/sensors/keyence.ts create mode 100644 telemetry/packages/fake/src/sensors/levitation.ts create mode 100644 telemetry/packages/fake/src/sensors/magnetism.ts create mode 100644 telemetry/packages/fake/src/sensors/motion.ts create mode 100644 telemetry/packages/fake/src/sensors/pressure.ts create mode 100644 telemetry/packages/fake/src/sensors/resistance.ts create mode 100644 telemetry/packages/fake/src/sensors/temperature.ts create mode 100644 telemetry/packages/fake/src/types.ts create mode 100644 telemetry/packages/fake/src/utils.ts create mode 100644 telemetry/packages/fake/tsconfig.json create mode 100644 telemetry/packages/public-app/.env.docker create mode 100644 telemetry/packages/public-app/.env.example create mode 100644 telemetry/packages/public-app/.eslintrc.json create mode 100644 telemetry/packages/public-app/.gitignore create mode 100644 telemetry/packages/public-app/app/cards.tsx create mode 100644 telemetry/packages/public-app/app/favicon.ico create mode 100644 telemetry/packages/public-app/app/globals.css create mode 100644 telemetry/packages/public-app/app/layout.tsx create mode 100644 telemetry/packages/public-app/app/loading-screen.tsx create mode 100644 telemetry/packages/public-app/app/page.tsx create mode 100644 telemetry/packages/public-app/app/providers.tsx create mode 100644 telemetry/packages/public-app/components/displacement-chart.tsx create mode 100644 telemetry/packages/public-app/components/launch-time.tsx create mode 100644 telemetry/packages/public-app/components/levitation-height.tsx create mode 100644 telemetry/packages/public-app/components/social-icons.tsx create mode 100644 telemetry/packages/public-app/components/theme-switch.tsx create mode 100644 telemetry/packages/public-app/components/velocity-graph.tsx create mode 100644 telemetry/packages/public-app/helpers.ts create mode 100644 telemetry/packages/public-app/next.config.js create mode 100644 telemetry/packages/public-app/package.json create mode 100644 telemetry/packages/public-app/postcss.config.js create mode 100644 telemetry/packages/public-app/public/hyped-dark.png create mode 100644 telemetry/packages/public-app/public/hyped-light.png create mode 100644 telemetry/packages/public-app/public/hyped.svg create mode 100644 telemetry/packages/public-app/tailwind.config.js create mode 100644 telemetry/packages/public-app/tsconfig.json create mode 100644 telemetry/packages/server/.env.docker create mode 100644 telemetry/packages/server/.env.example create mode 100644 telemetry/packages/server/.eslintrc.js create mode 100644 telemetry/packages/server/README.md create mode 100644 telemetry/packages/server/nest-cli.json create mode 100644 telemetry/packages/server/package.json create mode 100644 telemetry/packages/server/src/app.controller.ts create mode 100644 telemetry/packages/server/src/app.module.ts create mode 100644 telemetry/packages/server/src/app.service.ts create mode 100644 telemetry/packages/server/src/main.ts create mode 100644 telemetry/packages/server/src/modules/common/types/InfluxRow.ts create mode 100644 telemetry/packages/server/src/modules/common/utils/toUnixTimestamp.ts create mode 100644 telemetry/packages/server/src/modules/common/utils/zodEnumFromObjKeys.ts create mode 100644 telemetry/packages/server/src/modules/controls/PodControls.controller.ts create mode 100644 telemetry/packages/server/src/modules/controls/PodControls.module.ts create mode 100644 telemetry/packages/server/src/modules/controls/PodControls.service.ts create mode 100644 telemetry/packages/server/src/modules/core/config.ts create mode 100644 telemetry/packages/server/src/modules/influx/Influx.module.ts create mode 100644 telemetry/packages/server/src/modules/influx/Influx.service.ts create mode 100644 telemetry/packages/server/src/modules/influx/errors/InfluxServiceError.ts create mode 100644 telemetry/packages/server/src/modules/live-logs/LiveLogs.gateway.ts create mode 100644 telemetry/packages/server/src/modules/live-logs/LiveLogsTransport.ts create mode 100644 telemetry/packages/server/src/modules/logger/Logger.decorator.ts create mode 100644 telemetry/packages/server/src/modules/logger/Logger.module.ts create mode 100644 telemetry/packages/server/src/modules/measurement/Measurement.module.ts create mode 100644 telemetry/packages/server/src/modules/measurement/Measurement.service.ts create mode 100644 telemetry/packages/server/src/modules/measurement/MeasurementReading.types.ts create mode 100644 telemetry/packages/server/src/modules/measurement/errors/MeasurementReadingValidationError.ts create mode 100644 telemetry/packages/server/src/modules/measurement/utils/doesMeasurementBreachLimits.ts create mode 100644 telemetry/packages/server/src/modules/mqtt/client/MqttClientModule.ts create mode 100644 telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.module.ts create mode 100644 telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.service.ts create mode 100644 telemetry/packages/server/src/modules/mqtt/ingestion/errors/MqttIngestionError.ts create mode 100644 telemetry/packages/server/src/modules/openmct/OpenMCT.module.ts create mode 100644 telemetry/packages/server/src/modules/openmct/data/OpenMCTData.module.ts create mode 100644 telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.controller.ts create mode 100644 telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.service.ts create mode 100644 telemetry/packages/server/src/modules/openmct/data/realtime/RealtimeTelemetryData.gateway.ts create mode 100644 telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.controller.ts create mode 100644 telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.service.ts create mode 100644 telemetry/packages/server/src/modules/openmct/dictionary/utils/mapMeasurementToOpenMct.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/Fault.controller.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/Fault.module.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/Fault.service.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.controller.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.service.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/data/realtime/RealtimeFaultData.gateway.ts create mode 100644 telemetry/packages/server/src/modules/openmct/faults/utils/convertToOpenMctFault.ts create mode 100644 telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.controller.ts create mode 100644 telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.service.ts create mode 100644 telemetry/packages/server/src/modules/public-data/PublicData.controller.ts create mode 100644 telemetry/packages/server/src/modules/public-data/PublicData.module.ts create mode 100644 telemetry/packages/server/src/modules/public-data/PublicData.service.ts create mode 100644 telemetry/packages/server/src/modules/remote-logs/RemoteLogs.controller.ts create mode 100644 telemetry/packages/server/src/modules/remote-logs/RemoteLogs.module.ts create mode 100644 telemetry/packages/server/src/modules/remote-logs/RemoteLogs.service.ts create mode 100644 telemetry/packages/server/src/modules/state/State.module.ts create mode 100644 telemetry/packages/server/src/modules/state/State.service.ts create mode 100644 telemetry/packages/server/src/modules/state/StateUpdate.types.ts create mode 100644 telemetry/packages/server/src/modules/state/errors/MeasurementReadingValidationError.ts create mode 100644 telemetry/packages/server/src/modules/state/utils/isValidState.ts create mode 100644 telemetry/packages/server/src/modules/warnings/Warnings.controller.ts create mode 100644 telemetry/packages/server/src/modules/warnings/Warnings.module.ts create mode 100644 telemetry/packages/server/src/modules/warnings/Warnings.service.ts create mode 100644 telemetry/packages/server/test/app.e2e-spec.ts create mode 100644 telemetry/packages/server/test/jest-e2e.json create mode 100644 telemetry/packages/server/tsconfig.json create mode 100644 telemetry/packages/tsconfig/base.json create mode 100644 telemetry/packages/tsconfig/package.json create mode 100644 telemetry/packages/types/.eslintrc.js create mode 100644 telemetry/packages/types/package.json create mode 100644 telemetry/packages/types/src/index.ts create mode 100644 telemetry/packages/types/src/openmct/openmct-dictionary.types.ts create mode 100644 telemetry/packages/types/src/openmct/openmct-fault.types.ts create mode 100644 telemetry/packages/types/src/openmct/openmct-object-types.types.ts create mode 100644 telemetry/packages/types/src/pods/pods.types.ts create mode 100644 telemetry/packages/types/src/server/responses.ts create mode 100644 telemetry/packages/types/src/utils/Unpacked.ts create mode 100644 telemetry/packages/types/tsconfig.json create mode 100644 telemetry/packages/ui/.env.docker create mode 100644 telemetry/packages/ui/.env.example create mode 100644 telemetry/packages/ui/.eslintrc.cjs create mode 100644 telemetry/packages/ui/.gitignore create mode 100644 telemetry/packages/ui/app/App.tsx create mode 100644 telemetry/packages/ui/app/components/error.tsx create mode 100644 telemetry/packages/ui/app/components/shared/latency-chart.tsx create mode 100644 telemetry/packages/ui/app/components/shared/logo.tsx create mode 100644 telemetry/packages/ui/app/components/shared/pod-state.tsx create mode 100644 telemetry/packages/ui/app/components/shared/set-levitation-height.tsx create mode 100644 telemetry/packages/ui/app/components/sidebar/index.tsx create mode 100644 telemetry/packages/ui/app/components/sidebar/latency.tsx create mode 100644 telemetry/packages/ui/app/components/sidebar/pod-connection-status.tsx create mode 100644 telemetry/packages/ui/app/components/sidebar/pod-controls.tsx create mode 100644 telemetry/packages/ui/app/components/sidebar/pod-selector.tsx create mode 100644 telemetry/packages/ui/app/components/ui/alert-dialog.tsx create mode 100644 telemetry/packages/ui/app/components/ui/button.tsx create mode 100644 telemetry/packages/ui/app/components/ui/card.tsx create mode 100644 telemetry/packages/ui/app/components/ui/carousel.tsx create mode 100644 telemetry/packages/ui/app/components/ui/dialog.tsx create mode 100644 telemetry/packages/ui/app/components/ui/dropdown-menu.tsx create mode 100644 telemetry/packages/ui/app/components/ui/form.tsx create mode 100644 telemetry/packages/ui/app/components/ui/input.tsx create mode 100644 telemetry/packages/ui/app/components/ui/label.tsx create mode 100644 telemetry/packages/ui/app/components/ui/resizable.tsx create mode 100644 telemetry/packages/ui/app/components/ui/select.tsx create mode 100644 telemetry/packages/ui/app/components/ui/switch.tsx create mode 100644 telemetry/packages/ui/app/components/ui/textarea.tsx create mode 100644 telemetry/packages/ui/app/components/ui/tooltip.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/connection-statuses/connection-statuses.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/connection-statuses/mqtt-connection-status.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/connection-statuses/pod-connection-statuses.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/connection-statuses/server-connection-status.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/full-controls.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/index.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/mqtt-sender.tsx create mode 100644 telemetry/packages/ui/app/components/views/debug-view/pod-state-updater.tsx create mode 100644 telemetry/packages/ui/app/components/views/log-viewer.tsx create mode 100644 telemetry/packages/ui/app/components/views/openmct.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/edges.ts create mode 100644 telemetry/packages/ui/app/components/views/state-machine/index.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/active-node.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/failure-node.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/index.ts create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/neutral-node.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/passive-node.tsx create mode 100644 telemetry/packages/ui/app/components/views/state-machine/nodes/styles.ts create mode 100644 telemetry/packages/ui/app/components/views/state-machine/styles.css create mode 100644 telemetry/packages/ui/app/components/views/state-machine/types.ts create mode 100644 telemetry/packages/ui/app/components/views/state-machine/utils.ts create mode 100644 telemetry/packages/ui/app/config.ts create mode 100644 telemetry/packages/ui/app/context/errors.tsx create mode 100644 telemetry/packages/ui/app/context/live-logs.tsx create mode 100644 telemetry/packages/ui/app/context/mqtt.tsx create mode 100644 telemetry/packages/ui/app/context/pods.tsx create mode 100644 telemetry/packages/ui/app/globals.css create mode 100644 telemetry/packages/ui/app/hooks/useKeyPress.ts create mode 100644 telemetry/packages/ui/app/index.html create mode 100644 telemetry/packages/ui/app/lib/controls.ts create mode 100644 telemetry/packages/ui/app/lib/logger.ts create mode 100644 telemetry/packages/ui/app/lib/utils.ts create mode 100644 telemetry/packages/ui/app/main.tsx create mode 100644 telemetry/packages/ui/app/providers.tsx create mode 100644 telemetry/packages/ui/app/types/MQTTConnectionStatus.ts create mode 100644 telemetry/packages/ui/app/types/PodConnectionStatus.ts create mode 100644 telemetry/packages/ui/app/types/mqtt.ts create mode 100644 telemetry/packages/ui/app/views.tsx create mode 100644 telemetry/packages/ui/app/vite-env.d.ts create mode 100644 telemetry/packages/ui/components.json create mode 100644 telemetry/packages/ui/index.html create mode 100644 telemetry/packages/ui/mqtt.d.ts create mode 100644 telemetry/packages/ui/openmct/core/config.ts create mode 100644 telemetry/packages/ui/openmct/core/http.ts create mode 100644 telemetry/packages/ui/openmct/index.html create mode 100644 telemetry/packages/ui/openmct/main.ts create mode 100644 telemetry/packages/ui/openmct/plugins/conductor.ts create mode 100644 telemetry/packages/ui/openmct/plugins/data/object-types-data.ts create mode 100644 telemetry/packages/ui/openmct/plugins/data/pods-data.ts create mode 100644 telemetry/packages/ui/openmct/plugins/dictionary-plugin.ts create mode 100644 telemetry/packages/ui/openmct/plugins/faults-plugin/constants.ts create mode 100644 telemetry/packages/ui/openmct/plugins/faults-plugin/faults-plugin.ts create mode 100644 telemetry/packages/ui/openmct/plugins/faults-plugin/historical-faults-provider.ts create mode 100644 telemetry/packages/ui/openmct/plugins/faults-plugin/realtime-faults-provider.ts create mode 100644 telemetry/packages/ui/openmct/plugins/historical-telemetry-plugin.ts create mode 100644 telemetry/packages/ui/openmct/plugins/limit-plugin.ts create mode 100644 telemetry/packages/ui/openmct/plugins/realtime-telemetry-plugin.ts create mode 100644 telemetry/packages/ui/openmct/plugins/utils/convertNamespaceToPodId.ts create mode 100644 telemetry/packages/ui/openmct/types/AugmentedDomainObject.ts create mode 100644 telemetry/packages/ui/openmct/types/Datum.ts create mode 100644 telemetry/packages/ui/openmct/types/MeasurementReading.ts create mode 100644 telemetry/packages/ui/openmct/types/ObjectIdentifier.ts create mode 100644 telemetry/packages/ui/openmct/types/RealtimeFaultsListenerCallback.ts create mode 100644 telemetry/packages/ui/openmct/types/RealtimeTelemetryListenerCallback.ts create mode 100644 telemetry/packages/ui/openmct/types/Unpacked.ts create mode 100644 telemetry/packages/ui/package.json create mode 100644 telemetry/packages/ui/postcss.config.cjs create mode 100644 telemetry/packages/ui/public/hyped.svg create mode 100644 telemetry/packages/ui/public/images/pod.png create mode 100644 telemetry/packages/ui/public/mct-dashboards/local-storage.json create mode 100644 telemetry/packages/ui/public/openmct/data/dashboards.json create mode 100644 telemetry/packages/ui/tailwind.config.cjs create mode 100644 telemetry/packages/ui/tsconfig.json create mode 100644 telemetry/packages/ui/vite.config.ts create mode 100644 telemetry/patches/@nestjs__common@9.4.2.patch create mode 100644 telemetry/patches/nest-mqtt@0.2.0.patch create mode 100644 telemetry/patches/openmct@3.2.0.patch create mode 100644 telemetry/pnpm-lock.yaml create mode 100644 telemetry/pnpm-workspace.yaml create mode 100755 telemetry/telemetry.sh create mode 100644 telemetry/turbo.json diff --git a/telemetry/.gitignore b/telemetry/.gitignore new file mode 100644 index 0000000..e4839c1 --- /dev/null +++ b/telemetry/.gitignore @@ -0,0 +1,40 @@ +# compiled output +dist +node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +.turbo + +# .env files +.env + +# influx +influxdb/config +influxdb/data + +# mosquitto persistence & logs +mosquitto/data +mosquitto/log \ No newline at end of file diff --git a/telemetry/.prettierrc b/telemetry/.prettierrc new file mode 100644 index 0000000..6de9cff --- /dev/null +++ b/telemetry/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/telemetry/Dockerfile b/telemetry/Dockerfile new file mode 100644 index 0000000..1b39c6e --- /dev/null +++ b/telemetry/Dockerfile @@ -0,0 +1,37 @@ +FROM node:18.17.0 + +# Create app directory +WORKDIR /usr/src/app + +# Install pnpm +RUN npm install -g pnpm + +# Install dependencies +COPY package.json ./ +COPY packages/constants/package.json ./packages/constants/ +COPY packages/server/package.json ./packages/server/ +COPY packages/types/package.json ./packages/types/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/public-app/package.json ./packages/public-app/ +COPY packages/fake/package.json ./packages/fake/ +COPY packages/eslint-config/package.json ./packages/eslint-config/ +COPY packages/tsconfig/package.json ./packages/tsconfig/ +COPY packages/e2e-tests/package.json ./packages/e2e-tests/ +COPY pnpm-lock.yaml ./ +COPY pnpm-workspace.yaml ./ +COPY patches ./patches +COPY turbo.json ./ +RUN pnpm install --frozen-lockfile +RUN pnpm --filter=e2e-tests exec playwright install --with-deps + +# Expose ports +EXPOSE 5173 +EXPOSE 3000 +EXPOSE 3001 +EXPOSE 8086 +EXPOSE 9323 + +# Entrypoint +COPY entry.sh ./ + +ENTRYPOINT [ "/usr/src/app/entry.sh" ] diff --git a/telemetry/docker-compose.mqtt.yml b/telemetry/docker-compose.mqtt.yml new file mode 100644 index 0000000..9f179c2 --- /dev/null +++ b/telemetry/docker-compose.mqtt.yml @@ -0,0 +1,19 @@ +version: '3' + +services: + mosquitto: + container_name: mosquitto + image: eclipse-mosquitto:2.0.18 + ports: + - 1883:1883 + - 8080:8080 + volumes: + - ./mosquitto/config:/mosquitto/config + - ./mosquitto/data:/mosquitto/data + - ./mosquitto/log:/mosquitto/log + restart: always + networks: + - telemetry + +volumes: + mosquitto: diff --git a/telemetry/docker-compose.yml b/telemetry/docker-compose.yml new file mode 100644 index 0000000..713c31c --- /dev/null +++ b/telemetry/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3' + +services: + telemetry: + container_name: telemetry + build: + context: . + dockerfile: Dockerfile + ports: + - 5173:5173 + - 3000:3000 + - 3001:3001 + - 9323:9323 + environment: + - PNPM_SCRIPT + - IS_DOCKER=true + - E2E_TEST_MQTT_BROKER=mqtt://mosquitto:1883 + - PWTEST_SKIP_TEST_OUTPUT=1 + volumes: + - ./:/usr/src/app + # Exclude node_modules from being mounted + - /usr/src/app/node_modules + - /usr/src/app/packages/constants/node_modules + - /usr/src/app/packages/server/node_modules + - /usr/src/app/packages/types/node_modules + - /usr/src/app/packages/ui/node_modules + - /usr/src/app/packages/public-app/node_modules + - /usr/src/app/packages/public-app/.next + - /usr/src/app/packages/fake/node_modules + - /usr/src/app/packages/eslint-config/node_modules + - /usr/src/app/packages/e2e-tests/node_modules + networks: + - telemetry + influxdb: + container_name: influxdb + image: influxdb:2.7 + ports: + - 8086:8086 + volumes: + - ./influxdb/data:/var/lib/influxdb2 + - ./influxdb/config:/etc/influxdb2 + - ./influxdb/scripts:/docker-entrypoint-initdb.d + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=hyped + - DOCKER_INFLUXDB_INIT_PASSWORD=edinburgh + - DOCKER_INFLUXDB_INIT_ORG=hyped + - DOCKER_INFLUXDB_INIT_BUCKET=telemetry + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=edinburgh + networks: + - telemetry + +volumes: + influxdb: + +networks: + telemetry: + driver: bridge diff --git a/telemetry/entry.sh b/telemetry/entry.sh new file mode 100755 index 0000000..d6f5c52 --- /dev/null +++ b/telemetry/entry.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e # Exit with nonzero exit code if anything fails + +# Use docker env files +cp /usr/src/app/packages/server/.env.docker /usr/src/app/packages/server/.env + +cp /usr/src/app/packages/ui/.env.docker /usr/src/app/packages/ui/.env + +pnpm run $PNPM_SCRIPT diff --git a/telemetry/influxdb/scripts/setup.sh b/telemetry/influxdb/scripts/setup.sh new file mode 100755 index 0000000..b386ae3 --- /dev/null +++ b/telemetry/influxdb/scripts/setup.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +set -e +influx bucket create -n faults \ No newline at end of file diff --git a/telemetry/mosquitto/config/mosquitto.conf b/telemetry/mosquitto/config/mosquitto.conf new file mode 100644 index 0000000..0014bf6 --- /dev/null +++ b/telemetry/mosquitto/config/mosquitto.conf @@ -0,0 +1,14 @@ +# mqtt protocol +listener 1883 +protocol mqtt + +# websockets protocol (for GUI) +listener 8080 +protocol websockets + +allow_anonymous true + +# persistence & logs +persistence true +persistence_location /mosquitto/data/ +log_dest file /mosquitto/log/mosquitto.log \ No newline at end of file diff --git a/telemetry/package.json b/telemetry/package.json new file mode 100644 index 0000000..a465122 --- /dev/null +++ b/telemetry/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hyped/telemetry", + "private": true, + "scripts": { + "preinstall": "npx only-allow pnpm", + "start": "turbo run start", + "dev": "turbo run dev", + "dev:test": "turbo run dev:test", + "ci": "turbo run build lint e2e:test", + "lint": "turbo run lint --parallel", + "lint:fix": "turbo run lint:fix --parallel", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "e2e:test": "turbo run e2e:test", + "format:check": "prettier --check \"**/*.{ts,tsx,md}\"", + "build": "turbo run build" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "prettier": "3.2.4", + "turbo": "^1.11.3", + "typescript": "^5.3.3" + }, + "pnpm": { + "patchedDependencies": { + "@nestjs/common@9.4.2": "patches/@nestjs__common@9.4.2.patch", + "nest-mqtt@0.2.0": "patches/nest-mqtt@0.2.0.patch", + "openmct@3.2.0": "patches/openmct@3.2.0.patch" + } + } +} diff --git a/telemetry/packages/constants/.eslintrc.js b/telemetry/packages/constants/.eslintrc.js new file mode 100644 index 0000000..acab2de --- /dev/null +++ b/telemetry/packages/constants/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@hyped/eslint-config/basic.js'], + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +}; diff --git a/telemetry/packages/constants/package.json b/telemetry/packages/constants/package.json new file mode 100644 index 0000000..f77f170 --- /dev/null +++ b/telemetry/packages/constants/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hyped/telemetry-constants", + "private": true, + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "dist/telemetry-constants.esm.js", + "files": [ + "dist", + "src" + ], + "scripts": { + "start": "tsdx watch", + "build": "tsdx build", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0 --report-unused-disable-directives", + "lint:fix": "eslint \"src/**/*.ts\" --fix" + }, + "devDependencies": { + "@hyped/telemetry-types": "workspace:*", + "@hyped/eslint-config": "workspace:*", + "@hyped/tsconfig": "workspace:*", + "tsdx": "^0.14.1", + "tslib": "^2.5.3", + "typescript": "^5.3.3" + } +} diff --git a/telemetry/packages/constants/src/faults/levels.ts b/telemetry/packages/constants/src/faults/levels.ts new file mode 100644 index 0000000..a957e60 --- /dev/null +++ b/telemetry/packages/constants/src/faults/levels.ts @@ -0,0 +1,7 @@ +export const FAULT_LEVEL = { + WATCH: 'WATCH', + WARNING: 'WARNING', + CRITICAL: 'CRITICAL', +} as const; + +export type FaultLevel = (typeof FAULT_LEVEL)[keyof typeof FAULT_LEVEL]; diff --git a/telemetry/packages/constants/src/index.ts b/telemetry/packages/constants/src/index.ts new file mode 100644 index 0000000..1886413 --- /dev/null +++ b/telemetry/packages/constants/src/index.ts @@ -0,0 +1,19 @@ +export { pods, POD_IDS } from './pods/pods'; +export type { PodId, Pods } from './pods/pods'; +export { + ALL_POD_STATES, + PASSIVE_STATES, + ACTIVE_STATES, + NULL_STATES, + FAILURE_STATES, + getStateType, +} from './pods/states'; +export type { PodStateType, PodStateCategoryType } from './pods/states'; +export { MODES, MODE_EXCLUDED_STATES } from './pods/modes'; +export type { ModeType } from './pods/modes'; +export { openMctObjectTypes } from './openmct/object-types/object-types'; + +export * as socket from './socket'; + +export { FAULT_LEVEL } from './faults/levels'; +export type { FaultLevel } from './faults/levels'; diff --git a/telemetry/packages/constants/src/openmct/object-types/object-types.ts b/telemetry/packages/constants/src/openmct/object-types/object-types.ts new file mode 100644 index 0000000..c8e17ec --- /dev/null +++ b/telemetry/packages/constants/src/openmct/object-types/object-types.ts @@ -0,0 +1,54 @@ +import type { OpenMctObjectTypes } from '@hyped/telemetry-types'; + +export const openMctObjectTypes: OpenMctObjectTypes = [ + { + id: 'temperature', + name: 'Temperature', + icon: 'icon-telemetry', + }, + { + id: 'thermistor', + name: 'Thermistor', + icon: 'icon-telemetry', + }, + { + id: 'acceleration', + name: 'Acceleration', + icon: 'icon-telemetry', + }, + { + id: 'pressure', + name: 'Pressure', + icon: 'icon-telemetry', + }, + { + id: 'hall_effect', + name: 'Hall Effect', + icon: 'icon-telemetry', + }, + { + id: 'displacement', + name: 'Displacement', + icon: 'icon-telemetry', + }, + { + id: 'velocity', + name: 'Velocity', + icon: 'icon-telemetry', + }, + { + id: 'status', + name: 'status', + icon: 'icon-telemetry', + }, + { + id: 'keyence', + name: 'Keyence', + icon: 'icon-telemetry', + }, + { + id: 'brake_feedback', + name: 'Brake Feedback', + icon: 'icon-telemetry', + }, +]; diff --git a/telemetry/packages/constants/src/pods/common.ts b/telemetry/packages/constants/src/pods/common.ts new file mode 100644 index 0000000..08410eb --- /dev/null +++ b/telemetry/packages/constants/src/pods/common.ts @@ -0,0 +1,82 @@ +export const accelerometerCommon = { + format: 'float', + type: 'motion', + unit: 'm/s²', + limits: { + critical: { + low: -150, + high: 150, + }, + }, + rms_noise: 16.25 * 10 ** -3, // RMS rms_noise [mg] at ±15g range (~ ±150m/s^2) + sampling_time: 500, +} as const; + +// datasheet: https://www.st.com/en/mems-and-sensors/stts22h.html#st_description_sec-nav-tab +export const thermistorCommon = { + format: 'float', + type: 'temperature', + unit: '°C', + limits: { + critical: { + low: -40, + high: 125, + }, + warning: { + low: 20, + high: 100, + }, + }, + rms_noise: 0.05, // RMS rms_noise + sampling_time: 500, // test value. Datasheet specifies clock frequency range as (10 - 400 kHz) +} as const; + +export const pressureCommon = { + format: 'float', + type: 'pressure', + unit: 'bar', + rms_noise: 1 * 10 ** -3, // placeholder estimate of 1 mbar, to be confirmed with datasheet when chosen sensor confirmed + sampling_time: 500, +} as const; + +export const hallEffectCommon = { + format: 'float', + type: 'magnetism', + unit: 'A', + limits: { + critical: { + low: 0, + high: 500, + }, + }, + rms_noise: 0.5, // placeholder guesstimate, waiting on datasheet + sampling_time: 500, +} as const; + +export const keyenceCommon = { + format: 'integer', + type: 'keyence', + unit: 'number of stripes', + limits: { + critical: { + low: 0, + high: 16, + }, + }, + rms_noise: 0, + sampling_time: 500, +} as const; + +export const levitationHeightCommon = { + format: 'float', + type: 'levitation', + unit: 'mm', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 0, // placeholder + sampling_time: 500, // placeholder +} as const; diff --git a/telemetry/packages/constants/src/pods/modes.ts b/telemetry/packages/constants/src/pods/modes.ts new file mode 100644 index 0000000..2c263ed --- /dev/null +++ b/telemetry/packages/constants/src/pods/modes.ts @@ -0,0 +1,27 @@ +import { ACTIVE_STATES, PodStateType } from './states'; + +export const MODES = { + ALL_SYSTEMS_ON: 'ALL_SYSTEMS_ON', + LEVITATION_ONLY: 'LEVITATION_ONLY', + LIM_ONLY: 'LIM_ONLY', +}; + +export type ModeType = keyof typeof MODES; + +type ModeStates = Record; + +export const MODE_EXCLUDED_STATES: ModeStates = { + ALL_SYSTEMS_ON: [], + LEVITATION_ONLY: [ + ACTIVE_STATES.READY_FOR_LAUNCH, + ACTIVE_STATES.ACCELERATE, + ACTIVE_STATES.LIM_BRAKE, + ACTIVE_STATES.FRICTION_BRAKE, + ACTIVE_STATES.BATTERY_RECHARGE, + ], + LIM_ONLY: [ + ACTIVE_STATES.READY_FOR_LEVITATION, + ACTIVE_STATES.BEGIN_LEVITATION, + ACTIVE_STATES.STOP_LEVITATION, + ], +}; diff --git a/telemetry/packages/constants/src/pods/pods.ts b/telemetry/packages/constants/src/pods/pods.ts new file mode 100644 index 0000000..4231ec5 --- /dev/null +++ b/telemetry/packages/constants/src/pods/pods.ts @@ -0,0 +1,879 @@ +import { Pod } from '@hyped/telemetry-types'; +import { + accelerometerCommon, + hallEffectCommon, + keyenceCommon, + pressureCommon, + thermistorCommon, + levitationHeightCommon, +} from './common'; + +export const POD_IDS = ['pod_1', 'pod_2024'] as const; +export type PodId = (typeof POD_IDS)[number]; +export type Pods = Record; + +export const pods: Pods = { + pod_1: { + id: 'pod_1', + name: 'Pod Ness', + operationMode: 'ALL_SYSTEMS_ON', + measurements: { + // ************************************ ACCELEROMETERS ************************************ // + accelerometer_1: { + name: 'Accelerometer 1', + key: 'accelerometer_1', + ...accelerometerCommon, + }, + accelerometer_2: { + name: 'Accelerometer 2', + key: 'accelerometer_2', + ...accelerometerCommon, + }, + accelerometer_3: { + name: 'Accelerometer 3', + key: 'accelerometer_3', + ...accelerometerCommon, + }, + accelerometer_4: { + name: 'Accelerometer 4', + key: 'accelerometer_4', + ...accelerometerCommon, + }, + accelerometer_avg: { + name: 'Accelerometer Average', + key: 'accelerometer_avg', + ...accelerometerCommon, + }, + + // ************************************ NAVIGATION ************************************ // + displacement: { + name: 'Displacement', + key: 'displacement', + format: 'float', + type: 'motion', + unit: 'm', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 0, + sampling_time: accelerometerCommon.sampling_time, + }, + velocity: { + name: 'Velocity', + key: 'velocity', + format: 'float', + type: 'motion', + unit: 'm/s', + limits: { + critical: { + low: 0, + high: 50, + }, + }, + rms_noise: accelerometerCommon.rms_noise, + sampling_time: accelerometerCommon.sampling_time, + }, + acceleration: { + name: 'Acceleration', + key: 'acceleration', + format: 'float', + type: 'motion', + unit: 'm/s²', + limits: { + critical: { + low: 0, + high: 5, + }, + }, + rms_noise: accelerometerCommon.rms_noise, + sampling_time: accelerometerCommon.sampling_time, + }, + + // ************************************ PRESSURE ************************************ // + pressure_back_pull: { + name: 'Pressure – Back Pull', + key: 'pressure_back_pull', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_front_pull: { + name: 'Pressure – Front Pull', + key: 'pressure_front_pull', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_front_push: { + name: 'Pressure – Front Push', + key: 'pressure_front_push', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_back_push: { + name: 'Pressure – Back Push', + key: 'pressure_back_push', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_brakes_reservoir: { + name: 'Pressure – Brakes Reservoir', + key: 'pressure_brakes_reservoir', + ...pressureCommon, + limits: { + critical: { + low: 3, + high: 7.4, + }, + warning: { + low: 3.5, + high: 6.9, + }, + }, + }, + pressure_active_suspension_reservoir: { + name: 'Pressure – Active Suspension Reservoir', + key: 'pressure_active_suspension_reservoir', + ...pressureCommon, + limits: { + critical: { + low: 3, + high: 7.4, + }, + warning: { + low: 3.5, + high: 6.9, + }, + }, + }, + pressure_front_brake: { + name: 'Pressure – Front Brake', + key: 'pressure_front_brake', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 4.2, + }, + warning: { + low: -0.19, + high: 4, + }, + }, + }, + pressure_back_brake: { + name: 'Pressure – Back Brake', + key: 'pressure_back_brake', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 4.2, + }, + warning: { + low: -0.19, + high: 4, + }, + }, + }, + + // ************************************ THERMISTORS ************************************ // + thermistor_1: { + name: 'Thermistor 1', + key: 'thermistor_1', + ...thermistorCommon, + }, + thermistor_2: { + name: 'Thermistor 2', + key: 'thermistor_2', + ...thermistorCommon, + }, + thermistor_3: { + name: 'Thermistor 3', + key: 'thermistor_3', + ...thermistorCommon, + }, + thermistor_4: { + name: 'Thermistor 4', + key: 'thermistor_4', + ...thermistorCommon, + }, + thermistor_5: { + name: 'Thermistor 5', + key: 'thermistor_5', + ...thermistorCommon, + }, + thermistor_6: { + name: 'Thermistor 6', + key: 'thermistor_6', + ...thermistorCommon, + }, + thermistor_7: { + name: 'Thermistor 7', + key: 'thermistor_7', + ...thermistorCommon, + }, + thermistor_8: { + name: 'Thermistor 8', + key: 'thermistor_8', + ...thermistorCommon, + }, + thermistor_9: { + name: 'Thermistor 9', + key: 'thermistor_9', + ...thermistorCommon, + }, + thermistor_10: { + name: 'Thermistor 10', + key: 'thermistor_10', + ...thermistorCommon, + }, + thermistor_11: { + name: 'Thermistor 11', + key: 'thermistor_11', + ...thermistorCommon, + }, + thermistor_12: { + name: 'Thermistor 12', + key: 'thermistor_12', + ...thermistorCommon, + }, + // thermistor_13: { + // name: 'Thermistor 13', + // key: 'thermistor_13', + // ...thermistorCommon, + // }, + // thermistor_14: { + // name: 'Thermistor 14', + // key: 'thermistor_14', + // ...thermistorCommon, + // }, + // thermistor_15: { + // name: 'Thermistor 15', + // key: 'thermistor_15', + // ...thermistorCommon, + // }, + // thermistor_16: { + // name: 'Thermistor 16', + // key: 'thermistor_16', + // ...thermistorCommon, + // }, + + // ************************************ HALL EFFECTS ************************************ // + hall_effect_1: { + name: 'Hall Effect 1', + key: 'hall_effect_1', + ...hallEffectCommon, + }, + hall_effect_2: { + name: 'Hall Effect 2', + key: 'hall_effect_2', + ...hallEffectCommon, + }, + + // ************************************ STATUS ************************************ // + brake_clamp_status: { + name: 'Brake Clamp Status', + key: 'brake_clamp_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'CLAMPED', + }, + { + value: 0, + string: 'UNCLAMPED', + }, + ], + }, + pod_raised_status: { + name: 'Pod Raised Status', + key: 'pod_raised_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'RAISED', + }, + { + value: 0, + string: 'LOWERED', + }, + ], + }, + + battery_status: { + name: 'Battery Status', + key: 'battery_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'HEALTHY', + }, + { + value: 0, + string: 'UNHEALTHY', + }, + ], + }, + + motor_controller_status: { + name: 'Motor Controller Status', + key: 'motor_controller_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'HEALTHY', + }, + { + value: 0, + string: 'UNHEALTHY', + }, + ], + }, + + high_power_status: { + name: 'High Power Status', + key: 'high_power_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'ACTIVE', + }, + { + value: 0, + string: 'OFF', + }, + ], + }, + + // ************************************ KEYENCE ************************************ // + keyence_1: { + name: 'Keyence 1', + key: 'keyence_1', + ...keyenceCommon, + }, + keyence_2: { + name: 'Keyence 2', + key: 'keyence_2', + ...keyenceCommon, + }, + + // ************************************ POWER ************************************ // + power_line_resistance: { + name: 'Power Line Resistance', + key: 'power_line_resistance', + format: 'integer', + type: 'resistance', + unit: 'kΩ', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 0.1, + sampling_time: 500, + }, + }, + }, + + pod_2024: { + id: 'pod_2024', + name: 'Poddington', + operationMode: 'LEVITATION_ONLY', + measurements: { + // ************************************ ACCELEROMETERS ************************************ // + accelerometer_1: { + name: 'Accelerometer 1', + key: 'accelerometer_1', + ...accelerometerCommon, + }, + accelerometer_2: { + name: 'Accelerometer 2', + key: 'accelerometer_2', + ...accelerometerCommon, + }, + accelerometer_3: { + name: 'Accelerometer 3', + key: 'accelerometer_3', + ...accelerometerCommon, + }, + accelerometer_4: { + name: 'Accelerometer 4', + key: 'accelerometer_4', + ...accelerometerCommon, + }, + accelerometer_avg: { + name: 'Accelerometer Average', + key: 'accelerometer_avg', + ...accelerometerCommon, + }, + + // ************************************ NAVIGATION ************************************ // + displacement: { + name: 'Displacement', + key: 'displacement', + format: 'float', + type: 'motion', + unit: 'm', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 0, + sampling_time: accelerometerCommon.sampling_time, + }, + velocity: { + name: 'Velocity', + key: 'velocity', + format: 'float', + type: 'motion', + unit: 'm/s', + limits: { + critical: { + low: 0, + high: 50, + }, + }, + rms_noise: 0, + sampling_time: accelerometerCommon.sampling_time, + }, + acceleration: { + name: 'Acceleration', + key: 'acceleration', + format: 'float', + type: 'motion', + unit: 'm/s²', + limits: { + critical: { + low: 0, + high: 5, + }, + }, + rms_noise: accelerometerCommon.rms_noise, + sampling_time: accelerometerCommon.sampling_time, + }, + + // ************************************ PRESSURE ************************************ // + pressure_back_pull: { + name: 'Pressure – Back Pull', + key: 'pressure_back_pull', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_front_pull: { + name: 'Pressure – Front Pull', + key: 'pressure_front_pull', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_front_push: { + name: 'Pressure – Front Push', + key: 'pressure_front_push', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_back_push: { + name: 'Pressure – Back Push', + key: 'pressure_back_push', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 5.5, + }, + warning: { + low: -0.19, + high: 5.2, + }, + }, + }, + pressure_brakes_reservoir: { + name: 'Pressure – Brakes Reservoir', + key: 'pressure_brakes_reservoir', + ...pressureCommon, + limits: { + critical: { + low: 3, + high: 7.4, + }, + warning: { + low: 3.5, + high: 6.9, + }, + }, + }, + pressure_active_suspension_reservoir: { + name: 'Pressure – Active Suspension Reservoir', + key: 'pressure_active_suspension_reservoir', + ...pressureCommon, + limits: { + critical: { + low: 3, + high: 7.4, + }, + warning: { + low: 3.5, + high: 6.9, + }, + }, + }, + pressure_front_brake: { + name: 'Pressure – Front Brake', + key: 'pressure_front_brake', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 4.2, + }, + warning: { + low: -0.19, + high: 4, + }, + }, + }, + pressure_back_brake: { + name: 'Pressure – Back Brake', + key: 'pressure_back_brake', + ...pressureCommon, + limits: { + critical: { + low: -0.2, + high: 4.2, + }, + warning: { + low: -0.19, + high: 4, + }, + }, + }, + + // ************************************ THERMISTORS ************************************ // + thermistor_1: { + name: 'Thermistor 1', + key: 'thermistor_1', + ...thermistorCommon, + }, + thermistor_2: { + name: 'Thermistor 2', + key: 'thermistor_2', + ...thermistorCommon, + }, + thermistor_3: { + name: 'Thermistor 3', + key: 'thermistor_3', + ...thermistorCommon, + }, + thermistor_4: { + name: 'Thermistor 4', + key: 'thermistor_4', + ...thermistorCommon, + }, + thermistor_5: { + name: 'Thermistor 5', + key: 'thermistor_5', + ...thermistorCommon, + }, + thermistor_6: { + name: 'Thermistor 6', + key: 'thermistor_6', + ...thermistorCommon, + }, + thermistor_7: { + name: 'Thermistor 7', + key: 'thermistor_7', + ...thermistorCommon, + }, + thermistor_8: { + name: 'Thermistor 8', + key: 'thermistor_8', + ...thermistorCommon, + }, + thermistor_9: { + name: 'Thermistor 9', + key: 'thermistor_9', + ...thermistorCommon, + }, + thermistor_10: { + name: 'Thermistor 10', + key: 'thermistor_10', + ...thermistorCommon, + }, + thermistor_11: { + name: 'Thermistor 11', + key: 'thermistor_11', + ...thermistorCommon, + }, + thermistor_12: { + name: 'Thermistor 12', + key: 'thermistor_12', + ...thermistorCommon, + }, + + // ************************************ HALL EFFECTS ************************************ // + hall_effect_1: { + name: 'Hall Effect 1', + key: 'hall_effect_1', + ...hallEffectCommon, + }, + hall_effect_2: { + name: 'Hall Effect 2', + key: 'hall_effect_2', + ...hallEffectCommon, + }, + + // ************************************ STATUS ************************************ // + brake_clamp_status: { + name: 'Brake Clamp Status', + key: 'brake_clamp_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'CLAMPED', + }, + { + value: 0, + string: 'UNCLAMPED', + }, + ], + }, + pod_raised_status: { + name: 'Pod Raised Status', + key: 'pod_raised_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'RAISED', + }, + { + value: 0, + string: 'LOWERED', + }, + ], + }, + + battery_status: { + name: 'Battery Status', + key: 'battery_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'HEALTHY', + }, + { + value: 0, + string: 'UNHEALTHY', + }, + ], + }, + + motor_controller_status: { + name: 'Motor Controller Status', + key: 'motor_controller_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'HEALTHY', + }, + { + value: 0, + string: 'UNHEALTHY', + }, + ], + }, + + high_power_status: { + name: 'High Power Status', + key: 'high_power_status', + format: 'enum', + type: 'status', + unit: 'state', + enumerations: [ + { + value: 1, + string: 'ACTIVE', + }, + { + value: 0, + string: 'OFF', + }, + ], + }, + + // ************************************ KEYENCE ************************************ // + keyence_1: { + name: 'Keyence 1', + key: 'keyence_1', + ...keyenceCommon, + }, + keyence_2: { + name: 'Keyence 2', + key: 'keyence_2', + ...keyenceCommon, + }, + + // ************************************ POWER ************************************ // + power_line_resistance: { + name: 'Power Line Resistance', + key: 'power_line_resistance', + format: 'integer', + type: 'resistance', + unit: 'kΩ', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 0.1, + sampling_time: 500, + }, + + // ************************************ LEVITATION ************************************ // + levitation_height_1: { + name: 'Levitation Height 1', + key: 'levitation_height_1', + ...levitationHeightCommon, + }, + levitation_height_2: { + name: 'Levitation Height 2', + key: 'levitation_height_2', + ...levitationHeightCommon, + }, + levitation_height_3: { + name: 'Levitation Height 3', + key: 'levitation_height_3', + ...levitationHeightCommon, + }, + levitation_height_4: { + name: 'Levitation Height 4', + key: 'levitation_height_4', + ...levitationHeightCommon, + }, + levitation_height_lateral_1: { + name: 'Levitation Height Lateral 1', + key: 'levitation_height_lateral_1', + format: 'float', + type: 'levitation', + unit: 'mm', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 2, // from Time-of-Flight datasheet + sampling_time: 500, + }, + levitation_height_lateral_2: { + name: 'Levitation Height 2', + key: 'levitation_height_lateral_2', + format: 'float', + type: 'levitation', + unit: 'mm', + limits: { + critical: { + low: 0, + high: 100, + }, + }, + rms_noise: 2, // from Time-of-Flight datasheet + sampling_time: 500, + }, + }, + }, +}; diff --git a/telemetry/packages/constants/src/pods/states.ts b/telemetry/packages/constants/src/pods/states.ts new file mode 100644 index 0000000..0f22174 --- /dev/null +++ b/telemetry/packages/constants/src/pods/states.ts @@ -0,0 +1,55 @@ +export type PodStateType = keyof typeof ALL_POD_STATES; + +export const FAILURE_STATES = { + FAILURE_BRAKING: 'FAILURE_BRAKING', +} as const; + +export const PASSIVE_STATES = { + IDLE: 'IDLE', + CALIBRATE: 'CALIBRATE', + SAFE: 'SAFE', +} as const; + +export const ACTIVE_STATES = { + PRECHARGE: 'PRECHARGE', + READY_FOR_LEVITATION: 'READY_FOR_LEVITATION', + BEGIN_LEVITATION: 'BEGIN_LEVITATION', + READY_FOR_LAUNCH: 'READY_FOR_LAUNCH', + ACCELERATE: 'ACCELERATE', + LIM_BRAKE: 'LIM_BRAKE', + FRICTION_BRAKE: 'FRICTION_BRAKE', + STOP_LEVITATION: 'STOP_LEVITATION', + STOPPED: 'STOPPED', + BATTERY_RECHARGE: 'BATTERY_RECHARGE', + CAPACITOR_DISCHARGE: 'CAPACITOR_DISCHARGE', +} as const; + +export const NULL_STATES = { + UNKNOWN: 'UNKNOWN', +} as const; + +export const ALL_POD_STATES = { + ...FAILURE_STATES, + ...PASSIVE_STATES, + ...ACTIVE_STATES, + ...NULL_STATES, +}; + +export const ALL_POD_STATE_TYPES = [ + 'FAILURE', + 'PASSIVE', + 'ACTIVE', + 'NULL', +] as const; + +export type PodStateCategoryType = (typeof ALL_POD_STATE_TYPES)[number]; + +export const getStateType = ( + state: string, +): (typeof ALL_POD_STATE_TYPES)[number] => { + if (FAILURE_STATES[state as keyof typeof FAILURE_STATES]) return 'FAILURE'; + if (PASSIVE_STATES[state as keyof typeof PASSIVE_STATES]) return 'PASSIVE'; + if (ACTIVE_STATES[state as keyof typeof ACTIVE_STATES]) return 'ACTIVE'; + if (NULL_STATES[state as keyof typeof NULL_STATES]) return 'NULL'; + throw new Error(`Unknown state: ${state}`); +}; diff --git a/telemetry/packages/constants/src/socket/getMeasurementRoomName.ts b/telemetry/packages/constants/src/socket/getMeasurementRoomName.ts new file mode 100644 index 0000000..0cd4f06 --- /dev/null +++ b/telemetry/packages/constants/src/socket/getMeasurementRoomName.ts @@ -0,0 +1,6 @@ +export function getMeasurementRoomName( + podId: string, + measurementKey: string, +): string { + return `${podId}/measurement/${measurementKey}`; +} diff --git a/telemetry/packages/constants/src/socket/index.ts b/telemetry/packages/constants/src/socket/index.ts new file mode 100644 index 0000000..397f46b --- /dev/null +++ b/telemetry/packages/constants/src/socket/index.ts @@ -0,0 +1,11 @@ +export const MEASUREMENT_EVENT = 'Measurement'; +export const FAULT_EVENT = 'Fault'; + +export const EVENTS = { + SUBSCRIBE_TO_MEASUREMENT: 'SubscribeToMeasurement', + UNSUBSCRIBE_FROM_MEASUREMENT: 'UnsubscribeFromMeasurement', + SUBSCRIBE_TO_FAULTS: 'SubscribeToFaults', + UNSUBSCRIBE_FROM_FAULTS: 'UnsubscribeFromFaults', +}; + +export { getMeasurementRoomName } from './getMeasurementRoomName'; diff --git a/telemetry/packages/constants/tsconfig.json b/telemetry/packages/constants/tsconfig.json new file mode 100644 index 0000000..310dbb9 --- /dev/null +++ b/telemetry/packages/constants/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext"], + "importHelpers": true, + "sourceMap": true, + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/telemetry/packages/e2e-tests/.gitignore b/telemetry/packages/e2e-tests/.gitignore new file mode 100644 index 0000000..68c5d18 --- /dev/null +++ b/telemetry/packages/e2e-tests/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/telemetry/packages/e2e-tests/lib/mqtt.ts b/telemetry/packages/e2e-tests/lib/mqtt.ts new file mode 100644 index 0000000..8f0af88 --- /dev/null +++ b/telemetry/packages/e2e-tests/lib/mqtt.ts @@ -0,0 +1,57 @@ +import mqtt from 'mqtt'; + +export const client = mqtt.connect( + process.env.E2E_TEST_MQTT_BROKER || 'mqtt://localhost:1883', +); + +type MqttMessageValidation = (receivedTopic: string, message: Buffer) => void; + +/** + * Validates a message received on an MQTT topic. + * @param topic The topic the message will be send on + * @param validate A validation function which will be called with the received topic and message values + * @param timeout Time to wait (in ms) before giving up + */ +export async function validateMqttMessage( + trigger: () => void, + validate: MqttMessageValidation, + timeout = 1000, +): Promise { + const receivedMessages: { topic: string; message: Buffer }[] = []; + + return new Promise(async (resolve, reject) => { + const client = mqtt.connect( + process.env.E2E_TEST_MQTT_BROKER || 'mqtt://localhost:1883', + ); + + client.on('connect', async () => { + await client.subscribeAsync('#'); + + // Handle incoming messages + client.on('message', (receivedTopic, message) => { + receivedMessages.push({ topic: receivedTopic, message }); + }); + + trigger(); + + // Check that the message is in the received messages + const interval = setInterval(() => { + for (const receivedMessage of receivedMessages) { + try { + validate(receivedMessage.topic, receivedMessage.message); + clearInterval(interval); + client.end(); + resolve(); + } catch (e) { + // Ignore errors + } + } + }, 100); + }); + + // Timeout if the message is not received + setTimeout(() => { + reject(new Error(`Timeout waiting for message.`)); + }, timeout); + }); +} diff --git a/telemetry/packages/e2e-tests/package.json b/telemetry/packages/e2e-tests/package.json new file mode 100644 index 0000000..61f48fe --- /dev/null +++ b/telemetry/packages/e2e-tests/package.json @@ -0,0 +1,19 @@ +{ + "name": "@hyped/e2e-tests", + "version": "1.0.0", + "description": "End-to-end tests for HYPED Telemetry", + "main": "index.js", + "scripts": { + "e2e:test": "playwright test" + }, + "dependencies": { + "@hyped/telemetry-server": "workspace:*", + "@hyped/telemetry-ui": "workspace:*", + "mqtt": "^5.5.0" + }, + "devDependencies": { + "@hyped/tsconfig": "workspace:*", + "@playwright/test": "^1.42.1", + "@types/node": "^20.11.28" + } +} diff --git a/telemetry/packages/e2e-tests/playwright.config.ts b/telemetry/packages/e2e-tests/playwright.config.ts new file mode 100644 index 0000000..92dbb44 --- /dev/null +++ b/telemetry/packages/e2e-tests/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'cd ../../ && pnpm dev:test', // run from the root of the monorepo + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/telemetry/packages/e2e-tests/tests/example.spec.ts b/telemetry/packages/e2e-tests/tests/example.spec.ts new file mode 100644 index 0000000..5225218 --- /dev/null +++ b/telemetry/packages/e2e-tests/tests/example.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { client, validateMqttMessage } from '../lib/mqtt'; + +test('has title', async ({ page }) => { + await page.goto('http://localhost:5173'); + + await expect(page).toHaveTitle('HYPED24 | Telemetry'); +}); + +test('example mqtt test', async () => { + await validateMqttMessage( + // Pass in the function which will trigger the MQTT message to be sent. + // For example, this could be pushing a button on the GUI. + () => { + client.publish('hello', 'world'); + }, + // The validation function. Here you can validate that the topic and message body received is as expected. + (topic, message) => { + expect(topic).toBe('hello'); + expect(message.toString()).toBe('world'); + }, + ); +}); diff --git a/telemetry/packages/e2e-tests/tests/test-todo.md b/telemetry/packages/e2e-tests/tests/test-todo.md new file mode 100644 index 0000000..6b411d0 --- /dev/null +++ b/telemetry/packages/e2e-tests/tests/test-todo.md @@ -0,0 +1,101 @@ +# End to End Testing Briefing + +Full list: [Issue #102](https://github.com/Hyp-ed/hyped-2024/issues/102) + +
    +
  1. All inputs on the sidebar, including setting levitation height, are visible with correct labels and the correct MQTT state transition message is emitted (for the GO button, make sure that the enable toggle works) + +
    + Issues +
      +
    • Setting Levitation Height
    • +
    • Correct Labels Visible
    • +
    • MQTT State Transition Message Emitted (Enable Toggle should work for 'GO' button)
    • +
    +
    + +
  2. +
    + +
  3. Switching between pods changes the selected pod for certain elements +
    + Issues +
      +
    • Setting Levitation Height
    • +
    • Correct Labels Visible
    • +
    • MQTT State Transition Message Emitted (Enable Toggle should work for 'GO' button)
    • +
    +
    +
  4. +
    + +
  5. All view options are listed, visible and render the correct component +
    + Issues +
      +
    • Setting Levitation Height
    • +
    • Correct Labels Visible
    • +
    • MQTT State Transition Message Emitted (Enable Toggle should work for 'GO' button)
    • +
    +
    +
  6. +
    + +
  7. State machine diagram renders with the expected nodes visible +
    + Issues +
      +
    +
    +
  8. +
    + +
  9. Open MCT plots measurements on graphs correctly (historical test) +
    + Issues +
      +
    +
    +
  10. +
    + +
  11. Open MCT plots live measurements from MQTT messages (realtime test) +
    + Issues +
      +
    +
    +
  12. +
    + +
  13. Open MCT logs a fault when a measurement limit is exceeded (check warning and critical limits) +
    + Issues +
      +
    +
    +
  14. +
    +
+ +More to come... + + + + diff --git a/telemetry/packages/e2e-tests/tsconfig.json b/telemetry/packages/e2e-tests/tsconfig.json new file mode 100644 index 0000000..956c40f --- /dev/null +++ b/telemetry/packages/e2e-tests/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "jsx": "react", + } +} diff --git a/telemetry/packages/eslint-config/README.md b/telemetry/packages/eslint-config/README.md new file mode 100644 index 0000000..2d34cea --- /dev/null +++ b/telemetry/packages/eslint-config/README.md @@ -0,0 +1,3 @@ +# HYPED ESLint Config + +This package contains the ESLint configuration used by HYPED. diff --git a/telemetry/packages/eslint-config/basic.js b/telemetry/packages/eslint-config/basic.js new file mode 100644 index 0000000..28caa68 --- /dev/null +++ b/telemetry/packages/eslint-config/basic.js @@ -0,0 +1,28 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: [ + 'eslint:recommended', + // This isn't working properly. Getting an error that I can't figure out. + // 'eslint-config-turbo', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + ], + parser: '@typescript-eslint/parser', + plugins: ['only-warn', '@typescript-eslint'], + ignorePatterns: [ + '**/dist/**/*', + '**/node_modules/**/*', + '.eslintrc.js', + '.eslintrc.cjs', + ], + rules: { + 'no-console': 'error', + // We can tighten up the below rules later. They're not worth the effort at the moment. + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + }, + root: true, +}; diff --git a/telemetry/packages/eslint-config/package.json b/telemetry/packages/eslint-config/package.json new file mode 100644 index 0000000..6521db1 --- /dev/null +++ b/telemetry/packages/eslint-config/package.json @@ -0,0 +1,19 @@ +{ + "name": "@hyped/eslint-config", + "version": "0.0.0", + "private": true, + "files": [ + "basic.js", + "react.js" + ], + "dependencies": { + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "^1.12.5", + "eslint-plugin-only-warn": "^1.1.0", + "eslint-plugin-react-hooks": "^4.6.0", + "typescript": "^5.4.2" + } +} diff --git a/telemetry/packages/eslint-config/react.js b/telemetry/packages/eslint-config/react.js new file mode 100644 index 0000000..9d5c2b4 --- /dev/null +++ b/telemetry/packages/eslint-config/react.js @@ -0,0 +1,23 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: [ + './basic.js', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + plugins: ['react', 'react-hooks'], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/jsx-uses-react': 'off', + }, +}; diff --git a/telemetry/packages/fake/.eslintrc.js b/telemetry/packages/fake/.eslintrc.js new file mode 100644 index 0000000..acab2de --- /dev/null +++ b/telemetry/packages/fake/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@hyped/eslint-config/basic.js'], + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +}; diff --git a/telemetry/packages/fake/README.md b/telemetry/packages/fake/README.md new file mode 100644 index 0000000..b2298ca --- /dev/null +++ b/telemetry/packages/fake/README.md @@ -0,0 +1,337 @@ +# Telemetry - Fake Data Generation + +# Instructions of Use + +To begin the data simulation program, navigate to the `../server/fake/` directory. Run the command `> node main.js`. +Optional flags: + +
    +
  • --runtime [number]: specifies the simulation time (in seconds) with the following number argument. If left undefined, the default runtime is 30s, with the option to exit the process by running ^C anytime.
  • +
  • --random: sets random flag to true, invoking the randomising data generation logic for the full simulation. Primarily for debugging and front-end testing purposes.
  • +
  • --specific: Allows the choice to only simulate specific sensors. The permissible sensor types currently include: +
      +
    • motion
    • +
    • pressure
    • +
    • temperature
    • +
    • keyence
    • +
    • resistance
    • +
    • magnetism
    • +
    • levitation
    • +
    + Ensure that they are spelt correctly, as any typos will be rejected by the program and won't be simulated.
  • +
      + +
      + +### Purpose + +To iteratively generate transient and physically reasonable series of data points for the readings generated by all numerical-based sensors on the pod. Currently this does not cover the enumerated measurements, i.e. on/off status readings. This is effectively a +simulation of the expected sensor readings during a live run. Data will be uploaded live to the GUI through the mqtt server, and assessed +for error handling and other metrics. + +I.e. all of the thermnistors have the exact same proerty values. In actuality, their readings will differ as they placed in different locations across the pod. However, the data structure does not hold this information and therefore they are equivalent, so only one generic 'thermistor' object will be studied. + +In contrast, the pressure gauges have descriptive names which allow one to infer their likely behaviour during operation (front/back-push refers to gauging pressure upon acceleration, which would see an absolute increase in gauge pressure on both ends of the pod due to the greater stagnation pressure and the nose of the pod and pressure wake field behind). Similarly, the pressure gauges of the reservoirs can be reasonably assumed to not vary significantly, but as temperature rises the pressure will increase slightly due to Amonton's Law. + +
      + +### Methodology + +The program will record and store all values calculated by each sensor's methods defined in `sensors.ts` over a user-specified timeframe, with each sensor set to a specific sampling time interval. `main.ts` filters and constructs an array of relevant and unique sensors from the Pod Ness sensor object structure. A new interface is created called `Sensor Data`. It extends `RangeMeasurement` - adding the properties of `currentValue` and `movingAvg`, and encapsulates the measurement object with the sensor's name as a `Record`, reflecting the object shape of the pod's sensor objects. + +A Singleton class was created in its dedicated file, `data-manager.ts`. When the program is run, a single instance of this class is created. The instance holds the current set of data, and at each iteration is updated before the current data is pushed into data storage arrays for all sensors. Once created, this instance and its data property is accessible from both the main file, and the pod behavious class file, which holds all the methods written to generate each sensor's next value. + +In the main file, the program's main function, `generateDataSeries`, runs a loop iterating through the time period defined. For each sensor type, it calls the relevant static method in the `Behaviour` class. Some sensors can be categorised, e.g. the navigation sensors. The values for displacement, velocity and acceleration are interdependet. As the pod's velocity is increased, its displacement and acceleration can be calculated, given the acceleration constraints provided by its critical limits nested object. + +A logistic function was chosen as the appropriate function for pod velocity to follow. This allows us to minimse the time to reach maximum speed by adjusting the peak acceleration (dv/dt) to reach but not exceed 5m/s^2 at the velocity-time inflection point. + +Similar functions have been or are in the process of being created for the rest of the sensors. Complexity varies, and some require a lot more guesswork. + +`generateDataSeries` takes in a boolean parameter `random`, set to false by default. If the user sets this to true, iteration will be completely random, selecting sensors' new values as a random number between their critical limits. + +
      +

      Project Structure

      +data-gen/ +| - main.ts // runs the program +| - index.ts // handles imports and exports +| - src/ +| | - sensors.ts // self-contained file with classes describing sensor functionality +| | - sensorData.csv // setup file defining sensor input params with read/write permissions +| - utils/ +| | - data-manager.ts // Singleton class structure used to track, update, upload and store data in real-time +| | - config.ts // sets up the program and all objects & variables used within +| | - helpers.ts // contains generic functions used throughout the project +| - tests/ // yet to be implemented +| - tsconfig.json // generic settings for ../server/ directory +| - README.md +
      + + +### Types + +New types are defined for this program: + +
        +
      • LiveMeasurement: extends RangeMeasurement, adding the properties of currentValue and timestep, allowing the adapted use of prewritten data structures.
      • +
      • SensorData: { [x: string]: LiveMeasurement }. This takes the sensor data and puts them back in the standard Record< string, Measurement> format.
      • +
      • StoredData: { [key: string]: [(number | string)][] }. This object has the sensors/measurements as keys, and at each sensors' timestep an array containing the time stamp and current value are pushed into the sensor's array. While data points will be uploaded in real-time, they are stored for recording and analysis purposes.
      • +
      • InitialState: [key: string]: { dt: number; initialVal: number; }. This interface defines the object which holds the user-specified properties. Again, the key is the sensor name. The user can read and write to a CSV file to specify any or all of the sensors' initial values and the time interval between their generated readings.
      • +
      + + + +### Config + +This setup stage imports nested `measurements` object from pods.ts data. The `rangeFilter` function filters this data into an array of only the measurement/sensor objects of type `RangeMeasurement`, and removes duplicate redundancy, then converts it back into an object of similar format to `measurements`. This makes the code readable and familiar. This new object is exported by default. + +The array manipulation removes those without a `limits` property (i.e. of type `EnumMeasurement`), and converts all duplicates into equivalent strings to facilitate filtering e.g. 'thermistor_1', 'thermistor_2' -> both become 'thermistor'. Upon the ultimate conversion back into an object (the code uses `Object.fromEntries( Object.entries(measurements) )`), only one 'thermistor' entry is retained due to the JS Object prototype's inherent unique key characteristics. + +Then the `readData` helper function is called. This function reads the CSV data, which consists of `[sensor/quantity, time interval, initial value]`. It returns a Promise, which resolves if the resulting object (of type `InitialState`) is not empty. The result is chained to a `then` block, which requires that `unqSensorObj[sensor].currentValue=response[sensor].initialVal`, ensuring the CSV data has the correct sensor names and number of rows. + + + + + +### Index + +Gathers and exports all exports from relevant project files for ease of access + + + +### Sensors + +Self-contained module containing classes for the different sensor/measurement categories (Navigation, Pressure etc.). Each subclass inherits from the parent Sensor class, reducing repetitive code. The amount of different classes is as small as possible, each combined into groups of similar functionality. + +Classes are instantiated once each in `main.ts`. The Sensor class is constructed with a single `LiveMeasurement` sensor object, and its properties are set as `protected readonly`, allowing sub-classes to access them but placing a data barrier from external functions to access or modify these values. + +The `_currentValue` variable is mutable, so that only a single instance per class is needed, updating its current value. + +Not all sensors/measurements require their own class instance. For instance, in the navigation category only velocity (or the accelerometer reading) needs to be recorded in the class instance. From this value and given a reasonable function of time, the other navigation variables can be calculated accordingly at each time step using basic kinematics and calculus of limts. Additionally, many other measurements have a dependency on pod velocity, generally increasing in proportion to the speed. Temperature is another measurement which dictates certain others, like reservoir pressure for example. + +Read more about sensors here. + + + +### Data Manager + + + +## Main + +Main functional file. After importing all necessary objects, classes and functions, it runs the `GenerateDataSeries` iterative function, updating the `dataControl` instance at each iteration. + +Imports the adapted `rangeSensors` object from `config.ts` as well as the Sensor class and its sub-classes from `sensors.ts`, which each consist of similar functionality shared by a category of sensors such as Navigation, Pressure etc. + +Runs the main loop with user-defined parameters, with the actual functionality and data management in the other files. + + + +## To-do list + +
        +
      1. Complete config.ts
      2. +
      3. Read the sensor specs for more info to use to estimate functionality specifics, estimated noise reduction quality, etc. to make the data generation more reflective of reality. Add more sensor properties if appropriate.
      4. +
      5. Add noise and sensor type properties existing pod data with any new changes from the sensor spec sheet
      6. +
      7. Add general functionality in Utilities
      8. +
          +
        • Write an async function for user to read sensor parameters CSV
        • +
        • Write an async function for user to modify CSV sensor parameters
        • +
        • Write function to optimise data generation complexity for any given set of user-defined sensor reading time steps
        • +
        • Broadcast data live to an animated GUI graph
        • +
        • Write a function to generate noise
        • +
        • Write an exponential moving average method with parameters alpha and window (amount of recent data points to average)
        • +
        + +
      9. sensors.ts +
          +
        • Create logical functions for next data points for all sensor groups
        • +
        • Define sensor reading hierarchy
        • +
        +
      10. +
      11. dataControl.ts +
          +
        • Create data storage functionality and object interface
        • +
        • Change data access methods to get and set
        • +
        • Upload data values to server within the updateData method
        • +
        • Combine randomise and default generateData methods into one with conditional logic
        • +
        • Add functionality for cases of different timesteps for different sensors which may depend on each other
        • +
        +
      12. +
      13. main.ts +
          +
        • Provide user freedom to modify parameters with CLI input:
        • +
            +
          • Data generation type: random/logical
          • +
          • Sensor-specific time intervals at which readings are generated
          • +
          • Total runtime for the data generation loop
          • +
          + +
        • Instantiate sensor classses and finish loop functionality
        • +
            +
          • Plan the logic conceptually
          • +
          • Program it into the loop
          • +
          + +
        +
      14. Create logical and modular file structure (separation of concerns)
      15. +
      16. Refactor: +
          +
        • Minimise code and amount of classes as much as possible
        • +
        • Review file structure and ensure it is logical, readable and non-repetitive
        • +
        +
      17. Remove comments
      18. +
    + + + + +
    + +## Sensors Structure + +Every single sensor is "reliant" upon velocity or temperature readings for fake data generation +Temperature is a function of velocity. Thus every single sensor needs velocity's currentValue to update. + +Some have dependencies on both such as pressure, but as vel and temp. are interdependent it is akin to just +being dependent on velocity. I.e. if, say, brakes pressure reservoir=K _ temp, and temp=M _ velocity, +then pressure=K _ M _ velocity + +Therefore every sensor needs velocity at that instant. So velocity must be calculated at the lowest timestep of +any sensor, even if the accelerometer does not update as frequently. Otherwise, we could estimate the velocity +value through linear interpolation. + +In terms of classes: Sensor parents all. Navigation returns the values of displacement, velocity and acceleration from the accelerometer sensor. This fake data needs some reasonable function to follow, so a logistic curve for velocity was chosen. The derivative of this function is the acceleration. We can ensure it peaks at just below maxAcceleration. But even though the accelerometer is the sensor, if we have a related function for velocity then it's equivalent to having a velocity sensor instead. So it makes sense to use the velocity logistic curve as the governing variable. Acceleration will be its discrete derivative (vel - prevVel) / dt. Displacement is simply disp=prevVel _ currentTime + 1/2 a _ currentTime\*\*2. + +Some sensors like Hall Effect don't even care about temperature. They are solely simple functions of velocity. + +So we calculate velocity at every timestep. + +Classes & Subclasses + +
      +
    • Sensor
    • +
        +
      • Navigation
      • +
      + +So let's say the user wants to get all variables. He sets the same timestep of 500ms for all of them.\ + +
      + +#### Features to be added + +
        +
      • Prompt user to edit time steps for each sensors' readings as desired:
      • +
          +
        1. Print all sensors along with their default time step
        2. +
        3. > Edit timesteps? [y/n]
        4. +
        5. If 'y', cycle through each sensor and ask: >[sensor name]: enter preferred timestep in ms (e.g. 500) or press Enter to move to next sensor
        6. +
        7. Update initial conditions object with any altered timesteps
        8. +
        +
      • Prompt user to edit initial conditions by uploading a csv file or changing specific values through command prompt
      • +
          + + + + + +## Next Steps + +
            +
          1. Code in the noise. Shouldn't be difficult, just use a function based on Math.random and weight the amplitude of the noise to some extent. Decide whether how 'noisy' data is might be dependent upon certain vasriables such as speed. I'm not sure so ask GPT or research this.
          2. +
          3. Create moving average function with ```window``` parameter set to 5 as default. Look into exponential moving average too. The average value will be used to determine whether a reading is out of bounds or it's just the noise.
          4. +
          5. Find out how the noise levels compare from different sensors e.g. thermistors, pressure gauges, digital sensors, navigation etc.
          6. +
          7. Create a simple function for reservoir pressure, it will not vary by much, but increase with temperature slightly. Reading will have some noise.
          8. +
          9. Write functions for the other pressures. Push=acceleration, so front pressure goes up and back goes down (both go further away from atmospheric, their absolute gauge pressure increases). And pull=decelleration, so the opposite. Also double check with David that you're interpreting the pressure variable terms correctly.
          10. +
          11. I am assuming that the accelerometer(s) are all supposed to measure the absolute pod acceleration with respect to a stationay observer, and that's how the navigation parameters are found. Except this assumption seems wrong, as the accelerometer has a range of -150 to 150 m/s^2 while acceleration can only go up to 5 m/s^2. Is this perhaps referring to the sensors' physical limits of its capability to read acceleration, while the pod itself is not built to exceed 5m/s^2? In other words, the accelerometer will be limited to the 0-5 range, it's just not "critical" for the sensor in terms of safety, it's critical for the pod's safety. Perhaps. But another uncertainty is that the pod's acceleration can be easily determined by it's speed, which - I assume - we are controlling. Update: wrong, we are not controlling speed. We just switch on the power and track it using the accelerometer. The sensor's operational range is +-150. Above 150 it won't read the acceleration accurately. The pod cannot exceed 5. We have one sensor to measure navigation quantities, and that's the accelerometer. It has an operating range of -150 to 150. This specific pod prototype, Pod Ness, has an operating acceleration range of 0 to 5 (presumably this means -5 to 5). We generate fake data for the accelerometer sensor, and analyse it with the view of keeping pod acceleration below 5, and we also calculate other navigation quantities starting from acceleration.
          12. +
          +
          + +
            + Thursday notes from discussion with David + discussion with Damen about the public app, React and Three.js
            +
          1. Seperate all sensor data into its own file. This sensor data structure will be like the one in pods, but reduced to the data-gen relevant sensors, and with added/removed properties. Outside of this file, no other functional part of the program will be able to modify or access the file. The data file will be imported. Its properties are constant (so no 'currentVal' or 'movingAvg'). They are properties inherent to the sensor, like with the pod object. A new property David would like is a sampling rate property. This will define the delta T for that sensor's data generation, giving us a lot more freedom and making the code run a lot quicker as right now, there's only one global delta T variable so if we needed say 0.05s, all variables would be measured twenty times per second which would be unnecessary and slow. Of course, the value of this property will be changed and modified by our team, but not during runtime. This is a fixed object which is exporting its data for the data generation function to run. Another object or array will store the transient values, which we will also send live to the server as they are calculated and a graph can be animated in real time.
          2. +
          3. One issue I foresee is that if different sensors have different delta Ts, it will make the main loop more complicated. Say the thermistor takes a reading every 0.2s. The accelerometer every 0.5s. So we'd run the loop ever 0.2, while checking if the time is also a multiple of 0.5 (and all the other sensors' times). To mitigate this slightly, I will add functionality to the ```specific``` parameter, so we can view a select few variables in one run, or all of them if we want to.
          4. +
          5. Different sensors will have a new property which defines its time interval (dt) and perhaps its degree of noise (as higher quality sensors would have less noise due to better electronic circuits)
          6. + Separate the sensor file into a new file with the sensor object and its properties relevant to the data generation +
          7. The fake data generation program will be placed into its own directory
          8. +
          9. It's only for internal use, but there are restrictions/rules we need to follow from EHW committee
          10. +
          11. Add a method to the data manager class to upload the data at each step of the iteration to the server/mqtt so we can view it live (graph animation)
          12. +
          13. We don't have complete or current data on all the sensors we're using and we'll need to ask electronics team to fill in a spreadsheet with the data for range limits, (critical and warning and expected/nominal levels)
          14. +
          15. Run the logical data gen methods past the electronics/other team to see if they agree it makes sense
          16. +
          + + diff --git a/telemetry/packages/fake/package.json b/telemetry/packages/fake/package.json new file mode 100644 index 0000000..85263ad --- /dev/null +++ b/telemetry/packages/fake/package.json @@ -0,0 +1,24 @@ +{ + "name": "@hyped/telemetry-fake", + "private": true, + "version": "0.0.1", + "scripts": { + "dev:test": "node dist/index.js", + "build": "tsdx build", + "build:fake": "tsdx build", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0 --report-unused-disable-directives", + "lint:fix": "eslint \"src/**/*.ts\" --fix" + }, + "dependencies": { + "@hyped/telemetry-constants": "workspace:*", + "mqtt": "^5.3.6", + "tslib": "^2.5.3" + }, + "devDependencies": { + "@hyped/eslint-config": "workspace:*", + "@hyped/telemetry-types": "workspace:*", + "@hyped/tsconfig": "workspace:*", + "tsdx": "^0.14.1", + "typescript": "^5.3.3" + } +} diff --git a/telemetry/packages/fake/src/base.ts b/telemetry/packages/fake/src/base.ts new file mode 100644 index 0000000..e4f7b4d --- /dev/null +++ b/telemetry/packages/fake/src/base.ts @@ -0,0 +1,67 @@ +import { Limits } from '@hyped/telemetry-types'; +import { LiveReading, Readings } from './types'; +import { Utilities } from './utils'; + +export abstract class Sensor { + // Define static objects, updated each timestep // + // Records the actual time each sensor should be sampled next + // This object refers to sensors' sampling times to monitor the next time for each sensors' reading (in real time) + public static nextSamplingTimes: Record; + // Records whether each sensor has been sampled at the current time with a boolean flag for each + public static isSampled: Record = {}; + // Stores most recent sensor readings for all sensors, accessible by all sensors + // Null is used to indicate that the sensor has not been sampled at the current time + public static lastReadings: Record = {}; + + // Sensor properties + readonly type: string; // sensor type (same as the name of the object in sensorData) + readonly format: 'float' | 'integer'; // for random ternary logic (keyence is integer, rest are float) + readonly limits: Limits; + readonly rms_noise: number; + readonly delta_t: number; + + // Variable sensor data + protected time: number; // current time in seconds + + // Extract relevant properties from sensor data entries + constructor({ + type, + format, + limits, + rms_noise, + sampling_time, + readings, + }: LiveReading) { + Object.assign(this, { + type, + format, + limits, + rms_noise, + }); + this.delta_t = sampling_time / 1000; // convert ms to s + this.time = 0; + // Add initial sensor values to global readings object + Sensor.lastReadings[this.type] = readings; + } + + /** + * Main data gen method shared by all sensors + * Returns the Readings object, filtered into the values to be published with MQTT + * For motion, only acceleration, velocitity and displacement are uploaded + * Accelerometers are used to estimate readings, then these values are + * propagated to the three variables above + * @param t time in seconds + */ + abstract getData(t: number): Readings; + + getRandomData(readings: Readings): Readings { + for (const unit in readings) { + readings[unit] = Utilities.getRandomValue( + readings[unit], + this.rms_noise, + this.format, + ); + } + return readings; + } +} diff --git a/telemetry/packages/fake/src/config.ts b/telemetry/packages/fake/src/config.ts new file mode 100644 index 0000000..47ca762 --- /dev/null +++ b/telemetry/packages/fake/src/config.ts @@ -0,0 +1,99 @@ +import { pods } from '@hyped/telemetry-constants'; +import { Pod, RangeMeasurement } from '@hyped/telemetry-types'; +import { LiveReading, SensorData } from './types'; + +type podID = keyof typeof pods; + +/** + * Extracts and categorises relevant sensor data + */ +const filterMeasurements = (id: podID) => { + const pod = Object.values(pods).find((pod: Pod) => pod.id === id) as Pod; + const filteredData = {} as Record; + // + Object.entries(pod.measurements).forEach(([key, meas]) => { + if (meas.format !== 'enum') { + filteredData[key] = meas; + filteredData[key].name = key.replace(/_[^_]*\d$/, ''); + } + }); + return filteredData; +}; + +export const measurements = filterMeasurements('pod_2024'); + +/** + * Gets an arbitrary initial value for each reading + * Testing functionTo be replaced with user defined params fetched from GUI + * @param data a key - value item from the measurements object + * @returns initial value for a given sensor/measurement + */ +const getInitialValue = (data: RangeMeasurement): number => { + // Define initial conditions + const initialVals: Record = { + accelerometer: 0, + acceleration: 0, + displacement: 0, + // velocity: measurements.velocity.limits.critical.high * 0.1, // initial velocity > 0 for continuity of logistic function + velocity: 0.3, // m/s (this aligns closely with logistic curve y-intercept) + pressure: data.name.endsWith('reservoir') ? 5 : 1, + thermistor: 25, + keyence: 0, + hall_effect: 0, + levitation_height: 0, + power_line_resistance: 10, + }; + + // Set initial value based on sensor types defined above + if (Object.prototype.hasOwnProperty.call(initialVals, data.name)) { + return initialVals[data.name]; + } else if (data.name.startsWith('pressure')) { + // Pressure gauges are subdivided into push, pull, brake and reservoir with different initial values + return initialVals.pressure; + } else { + // If the sensor is not recognised, return a random value within the critical limits + const { low, high } = data.limits.critical; + return Math.floor(Math.random() * (high - low)) + low; + } +}; + +/** + * Create new object to store existing and additional sensor parameters + * Groups sensors by data source by replacing sensor type with group's type + */ +export const sensorData: SensorData = Object.fromEntries( + Object.values(measurements) + .reduce( + (acc, sensor): any => { + if (!acc.seen) acc.seen = new Set(); + // Check if the sensor key has already been processed + if (!acc.seen.has(sensor.type)) { + acc.seen.add(sensor.type); + // Add one key value pair for each sensor type + // Each type holds all data on its constituent sensors + acc.entries.push([sensor.type, sensor]); + } + return acc; + }, + { seen: new Set(), entries: [] as [string, RangeMeasurement][] }, + ) + .entries // Set new readings property to the sensors' initial conditions + .map(([name, data]: [string, RangeMeasurement]) => [ + name, + { + ...data, + // Create object with a key-value pair for each measurement of a given sensor type + readings: Object.fromEntries( + Object.keys(measurements) + .filter( + (name) => + !name.endsWith('avg') && measurements[name].type == data.type, + ) + .map((el) => [el, getInitialValue(measurements[el])]), + ), + } as LiveReading, + ]), +); + +// Parameter storing distance of track, once finsih point is reached program will end +export const trackLength = measurements.displacement.limits.critical.high; diff --git a/telemetry/packages/fake/src/index.ts b/telemetry/packages/fake/src/index.ts new file mode 100644 index 0000000..1f1689e --- /dev/null +++ b/telemetry/packages/fake/src/index.ts @@ -0,0 +1,32 @@ +/** + * Main file which initialises the generation of the data series and uploads to GUI in real time. + * @param runTime (CLI) simulation time in ms (not real time, based on sensor timesteps) + * @param random (CLI) option to simulate random data - later to be replaced with a config object + * which allows user to randomise select sensor readings. Default is false + * @param specific (CLI) an array of specific sensor readings to simulate. Default is false + * i.e. simulate all sensors + */ +import { SensorManager } from './sensorManager'; +import { sensorData } from './config'; + +const args = process.argv.slice(2); +const shouldRandomise = args.includes('--random') ? true : false; + +// Filter for user-defined specific sensors, otherwise simulate all +Object.keys(sensorData).filter((sensor) => args.includes(sensor)); + +// Ensure input sensor options are valid and format them appropriately +const sensorsToRun = args.includes('--specific') + ? args + .slice(args.indexOf('--specific') + 1) + .map((s: string) => s.toLowerCase()) + .filter((s: string) => + Object.prototype.hasOwnProperty.call(sensorData, s), + ) + : Object.keys(sensorData); + +// Instantiate sensor manager +const sensorMgmt = new SensorManager(sensorsToRun); + +// Simulates sensor readings and uploads to the server +sensorMgmt.generateData(shouldRandomise); diff --git a/telemetry/packages/fake/src/sensorManager.ts b/telemetry/packages/fake/src/sensorManager.ts new file mode 100644 index 0000000..8257c2a --- /dev/null +++ b/telemetry/packages/fake/src/sensorManager.ts @@ -0,0 +1,141 @@ +import MQTT from 'mqtt'; +import { sensors, SensorInstance } from './sensors/index'; +import { Readings } from './types'; +import { sensorData, trackLength } from './config'; +import { Sensor } from './base'; +import { Utilities as utils } from './utils'; + +export class SensorManager { + // Create array to store sensor instances + private sensors: SensorInstance<(typeof sensors)[keyof typeof sensors]>[] = + []; + // Record the sampling intervals for each sensor + private samplingTimes: Record = {}; + // Global clock for simulation runtime + private globalTime = 0; + // Mqtt client + private client: MQTT.MqttClient; + + /** + * The sensors form a hierarchical dependency tree + * At the top level is Motion, which relies only on time + * All other sensor data relies on motion readings, either + * directly or as a grandchild of the motion class + * Key = sensor + * Value = parent class + */ + private dependencies: Record = { + motion: null, + keyence: 'motion', + temperature: 'motion', + pressure: 'temperature', + resistance: 'temperature', + magnetism: 'motion', + levitation: 'magnetism', + }; + + /** + * Sensor manager singleton class + * Controls and connects all sensor classes + * @param sensorsToRun user-defined array of sensor names to be run in the current simulation + */ + constructor(private sensorsToRun: string[]) { + // Create sensor instances + this.instantiateSensors(this.sensorsToRun); + // Record fixed sampling time periods + this.sensorsToRun.forEach((s: string) => { + this.samplingTimes[s] = sensorData[s].sampling_time; + }); + // Initialize MQTT connection + this.client = MQTT.connect('MQTT://mosquitto:1883'); + } + + /** + * Runs transient data generation + * Updates global variables each iteration + * End program once runTime has been reached + * @param random boolean input set by user, allows for completely random data + */ + public generateData(random = false): void { + // Calculate base sampling interval using lowest common divisor of all sensors' sampling periods + const interval = utils.gcd(Object.values(this.samplingTimes)); + + const simulationInterval = setInterval(() => { + // Reset all 'sampled' flags to false + this.resetSampledState(); + this.sensors.forEach((sensor) => { + // Generate data if current time corresponds to sensor's sampling time + if ((this.globalTime / 1000) % sensor.delta_t == 0) { + // Get the sensors' output data + const readings: Readings = !random + ? sensor.getData(this.globalTime / 1000) // convert time to seconds for calculations + : sensor.getRandomData(Sensor.lastReadings[sensor.type]); + // Store latest readings and set sensors' sampled state to true + Sensor.lastReadings[sensor.type] = readings; + Sensor.isSampled[sensor.type] = true; + + // Publish sensor readings under the topic of + // each and for each measurement key to the data broker + Object.entries(readings).forEach(([measurement, value]) => { + this.publishData(measurement, value.toString()); + }); + } + }); + + // Implement exit condition + if (Sensor.lastReadings.motion.displacement >= trackLength) { + clearInterval(simulationInterval); + this.generateData(random); + } + + this.globalTime += interval; + }, interval); + } + + /** + * Instantiate sensors and their required superclasses and store instances in array + */ + private instantiateSensors(sensorsToRun: string[]): void { + // Record sensors names to be added to instances array + const activeSensors: Set = new Set(); + // Function to activate all sensors required + const getActiveSensors = (name: string): void => { + if (this.dependencies[name] == null) { + activeSensors.add(name); + } else { + getActiveSensors(this.dependencies[name] as string); + activeSensors.add(name); + } + return; + }; + // Populate set + sensorsToRun.forEach((s) => getActiveSensors(s)); + // Define sensor instances + // Correct sorting is automatic as recursion forces all parent class sensors to be + // added before their inheriting classes + activeSensors.forEach((s) => + this.sensors.push(new sensors[s](sensorData[s])), + ); + } + + /** + * Reset all sensors' isSampled flags to false on each iteration + */ + private resetSampledState(): void { + Object.keys(Sensor.isSampled).forEach( + (sensor) => (Sensor.isSampled[sensor] = false), + ); + } + + /** + * Uploads data through MQTT broker to the frontend + * The properties of the sensors' readings objects are the keys which are appended to the topic path, i.e. ...measurements/[key] + * So simply append the key and publish the value as the payload + * Subscribed clients extract values using payload[measurementKey] + */ + private publishData(measurement: string, reading: string): void { + this.client.publish(`hyped/pod_2024/measurement/${measurement}`, reading, { + qos: 1, + }); + } +} diff --git a/telemetry/packages/fake/src/sensors/index.ts b/telemetry/packages/fake/src/sensors/index.ts new file mode 100644 index 0000000..c81b647 --- /dev/null +++ b/telemetry/packages/fake/src/sensors/index.ts @@ -0,0 +1,32 @@ +// Individual sensor classes +import { Motion } from './motion'; +import { Keyence } from './keyence'; +import { Pressure } from './pressure'; +import { Temperature } from './temperature'; +import { Resistance } from './resistance'; +import { Magnetism } from './magnetism'; +import { Levitation } from './levitation'; + +type SensorType = + | typeof Motion + | typeof Keyence + | typeof Pressure + | typeof Temperature + | typeof Resistance + | typeof Magnetism + | typeof Levitation; + +// Instance type for sensor classes +export type SensorInstance any> = + InstanceType; + +// Export object containing all sensor classes +export const sensors = { + motion: Motion, + keyence: Keyence, + temperature: Temperature, + resistance: Resistance, + pressure: Pressure, + magnetism: Magnetism, + levitation: Levitation, +} as Record; diff --git a/telemetry/packages/fake/src/sensors/keyence.ts b/telemetry/packages/fake/src/sensors/keyence.ts new file mode 100644 index 0000000..f28f0a6 --- /dev/null +++ b/telemetry/packages/fake/src/sensors/keyence.ts @@ -0,0 +1,51 @@ +import { Motion } from './motion'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { trackLength } from '../config'; + +/** + * Integer value in range [0, 16], which directly corresponds to the track distance. + * Live data takes the form of a staircase with varying step width + * Instead of sensor noise there is measurement tolerance + */ +export class Keyence extends Motion { + private podLength = 2.5; + + constructor(data: LiveReading) { + super(data); + } + + getData(t: number): Readings { + // Keyence sensors are evenly distributed along the pod + // Displacement is measured at the nose of the pod + const sensorRegion = + this.podLength / (Object.keys(Sensor.lastReadings.keyence).length - 1); + const noPoles = this.limits.critical.high; + + if (!Sensor.isSampled['motion']) { + this.displacement = super.getData(t).displacement; + Sensor.isSampled['motion'] = true; + } else { + this.displacement = Sensor.lastReadings.motion.displacement; + } + + this.displacement += this.addTolerance(); + + return Object.fromEntries( + Object.keys(Sensor.lastReadings.keyence).map((key, i) => { + const relDisp = + this.displacement - sensorRegion * i >= 0 + ? this.displacement - sensorRegion * i + : 0; // assert value is positive + return [key, Math.floor(relDisp * (noPoles / trackLength))]; + }), + ); + } + + /** + * Keyence sensor has single-digit millimetre tolerance + */ + addTolerance() { + return Math.random() * 0.01 * (Math.random() >= 0.5 ? 1 : -1); + } +} diff --git a/telemetry/packages/fake/src/sensors/levitation.ts b/telemetry/packages/fake/src/sensors/levitation.ts new file mode 100644 index 0000000..2a7c6aa --- /dev/null +++ b/telemetry/packages/fake/src/sensors/levitation.ts @@ -0,0 +1,150 @@ +import { Magnetism } from './magnetism'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { Utilities } from '../utils'; + +export class Levitation extends Magnetism { + private timeActive: number; // dynamic time variable + private timeOffset: number; // time at which lev. is activated + private isActive = false; + + private prevVals: number[]; // store recent values to identify reaching steady state + private setpoint = 50; // mm + private inSteadyState = true; + private sse = 0.02; // steady state error (±1% of setpoint) + + private readonly logRiseParams = { + t_0: 1.9, // inflection point + growth: 2.5, // growth rate + t_f: 2.225, // time when logistic function switches to sinusoidal exp. decay + l_peak: 60, // peak amplitude + }; + private readonly oscParams = { + freq: 4, // rad/s + phase: 3.7, // phase angle (rad) + decay: 0.2, // decay rate + amp: this.logRiseParams.l_peak - this.setpoint, // oscillation amplitude + }; + private readonly logFallParams = { + t_0: 2.5, // land on track within 5s + growth: 1.5, // decline smoother than rise + }; + + constructor(data: LiveReading) { + super(data); + // 10 values ensures minimal error for small sampling times + // and sufficient reaction time for large sampling times + this.prevVals = Array(10).fill(0); + } + + // Reset time at rising and falling stages + initiate(t: number) { + this.timeOffset = t; + this.timeActive = 0; + this.isActive = !this.isActive; + this.inSteadyState = false; + } + + getData(t: number): Readings { + // Start relative timekeeping when EM powers on + if (this.isFieldOn() && !this.isActive) this.initiate(t); + + // Keep at zero while on track + // ToF sensor noise negated/ignored as value is fixed and known + if (!this.isActive && this.inSteadyState) + return Sensor.lastReadings.levitation; + + // Code below reachable after EM field first turns on + + // Update levitating time + this.timeActive = t - this.timeOffset; + + // Rising stage + if (this.isActive) { + if (this.timeActive <= this.logRiseParams.t_f) { + this.prevVals.push( + Utilities.logistic( + this.timeActive, + this.logRiseParams.l_peak, + this.logRiseParams.growth, + this.logRiseParams.t_0, + ), + ); + this.prevVals.shift(); + } + + // Oscillation to steady state + else if ( + !this.inSteadyState && + this.timeActive > this.logRiseParams.t_f + ) { + this.prevVals.push( + this.setpoint + + Utilities.oscillateDecay( + this.timeActive - this.logRiseParams.t_f, + this.oscParams.freq, + this.oscParams.phase, + this.oscParams.decay, + this.oscParams.amp, + ), + ); + this.prevVals.shift(); + + this.inSteadyState = this.prevVals.every( + (l) => Math.abs(this.setpoint - l) < (this.sse / 2) * this.setpoint, + ); + } + + // If at steady state, fix value to avoid unneeded computation + else { + this.prevVals.push(this.setpoint); + this.prevVals.shift(); + } + + // Monitor EM field during steady state + if (this.isFieldOn()) return this.updateReadings(this.prevVals); + // Reset time for decline stage + else this.initiate(t); + } + + // Code below reachable once pod decline begins + + // Once pod slows down, begin gradual decline in levitation + if (!this.inSteadyState) { + this.prevVals.push( + this.setpoint * + (1 - + Utilities.logistic( + this.timeActive, + 1, + this.logFallParams.growth, + this.logFallParams.t_0, + )), + ); + + this.inSteadyState = this.prevVals.every( + (l) => Math.abs(l) < (this.sse / 2) * this.setpoint, + ); + + return this.updateReadings(this.prevVals); + } else { + // Once touched down, reset sensor readings to zero + return this.updateReadings([], true); + } + } + + private updateReadings(prevVals: number[], onTrack = false): Readings { + return Object.fromEntries( + Object.keys(Sensor.lastReadings.levitation).map((key) => { + return [ + key, + Utilities.round2DP( + prevVals.slice(-1)[0] + + // Add noise if levitating, otherwise value is fixed so noise is negated + (onTrack ? 0 : Utilities.gaussianRandom(this.rms_noise)), + ), + ]; + }), + ); + } +} diff --git a/telemetry/packages/fake/src/sensors/magnetism.ts b/telemetry/packages/fake/src/sensors/magnetism.ts new file mode 100644 index 0000000..8b1dc8f --- /dev/null +++ b/telemetry/packages/fake/src/sensors/magnetism.ts @@ -0,0 +1,42 @@ +import { Motion } from './motion'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { Utilities as utils } from '../utils'; + +export class Magnetism extends Motion { + protected magSetpoint = 250; // A + + constructor(data: LiveReading) { + super(data); + } + + getData(t: number): Readings { + if (!Sensor.isSampled['motion']) { + this.velocity = super.getData(t).velocity; + Sensor.isSampled['motion'] = true; + } else { + this.velocity = Sensor.lastReadings.motion.velocity; + } + + return Object.fromEntries( + Object.keys(Sensor.lastReadings.magnetism).map((key) => { + // binary on or off - instantaneous step change + return [ + key, + (this.velocity >= this.liftoffSpeed ? this.magSetpoint : 0) + + utils.gaussianRandom(this.rms_noise), + ]; + }), + ); + } + + /** + * For subclasses to check if EM field is powered on + * EM field only switched on when corresponding velocity is detected + * Hence use of last known velocity value instead of generating a new one + */ + protected isFieldOn(): boolean { + this.velocity = Sensor.lastReadings.motion.velocity; + return this.velocity >= this.liftoffSpeed; + } +} diff --git a/telemetry/packages/fake/src/sensors/motion.ts b/telemetry/packages/fake/src/sensors/motion.ts new file mode 100644 index 0000000..e4d437b --- /dev/null +++ b/telemetry/packages/fake/src/sensors/motion.ts @@ -0,0 +1,57 @@ +import { Sensor } from '../base'; +import { measurements } from '../config'; +import { Utilities } from '../utils'; +import { LiveReading, Readings } from '../types'; + +export class Motion extends Sensor { + protected displacement: number; + protected velocity: number; + protected acceleration: number; + // Velocity threshold at which levitation is activated + protected liftoffSpeed = 5; + + private logParams = { + growth: 0.4, + // Ensures acceleration peaks at its limiting operating value + t_0: 12.5, + // Max vel. set to 95% of upper limit giving a small margin for noise fluctuations + stState: 0.95 * measurements.velocity.limits.critical.high, + }; + + constructor(accelerometer: LiveReading) { + super(accelerometer); + const { displacement, velocity, acceleration } = Sensor.lastReadings.motion; + Object.assign(this, { displacement, velocity, acceleration }); + } + + getData(t: number): Readings { + const velocityEstimate = Utilities.logistic( + t, + this.logParams.stState, + this.logParams.growth, + this.logParams.t_0, + ); + + // Use estimate to calculate accelerometer reading + let accelerometerReading = + (velocityEstimate - this.velocity) / this.delta_t; + // Assert reading is not above critical limit + accelerometerReading = + accelerometerReading >= this.limits.critical.high + ? this.limits.critical.high + : accelerometerReading; + accelerometerReading += Utilities.gaussianRandom(this.rms_noise); + + // Use trapezoidal integration to find velocity, displacement + const avgAcceleration = (accelerometerReading + this.acceleration) / 2; + this.velocity += avgAcceleration * this.delta_t; + this.displacement += this.velocity * this.delta_t; + this.acceleration = accelerometerReading; + + return { + acceleration: Utilities.round2DP(this.acceleration), + velocity: Utilities.round2DP(this.velocity), + displacement: Utilities.round2DP(this.displacement), + }; + } +} diff --git a/telemetry/packages/fake/src/sensors/pressure.ts b/telemetry/packages/fake/src/sensors/pressure.ts new file mode 100644 index 0000000..eef97c3 --- /dev/null +++ b/telemetry/packages/fake/src/sensors/pressure.ts @@ -0,0 +1,91 @@ +import { Temperature } from './temperature'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { Utilities as utils } from '../utils'; + +export class Pressure extends Temperature { + private pResBrakes0: number; + private pResSusp0: number; + + private airProps = { + rho: 1.225, // air density (kg/m3) + atm: 101325, // atmospheric pressure (Pa) + }; + private coefficients = { + lossFactor: 0.05, // 5% internal pressure losses + stagnation: 0.5, + wake: 0.25, + brakingFactor: 0.05, // to calculate pressure increase due to braking force + }; + + constructor(data: LiveReading) { + super(data); + // this.prevVals = Array(10).fill(0); + this.pResBrakes0 = data.readings['pressure_brakes_reservoir']; + this.pResSusp0 = data.readings['pressure_active_suspension_reservoir']; + } + + getData(): Readings { + const newData = { ...Sensor.lastReadings.pressure }; + // Pneumatic pressure gauges + newData['pressure_front_pull'] = this.bernoulli('stagnation'); + newData['pressure_front_push'] = + (1 - this.coefficients.lossFactor) * newData['pressure_front_pull']; + newData['pressure_back_pull'] = this.bernoulli('wake'); + newData['pressure_back_push'] = + (1 - this.coefficients.lossFactor) * newData['pressure_back_pull']; + + // Reservoir pressure + newData['pressure_brakes_reservoir'] = this.idealGasLaw('brakes'); + newData['pressure_active_suspension_reservoir'] = + this.idealGasLaw('suspension'); + + // Brake pressure + newData['pressure_front_brake'] = this.brakePressure(); + newData['pressure_back_brake'] = newData['pressure_front_brake']; + + return Object.fromEntries( + Object.entries(newData).map(([key, value]) => { + return [ + key, + utils.round2DP( + (value + utils.gaussianRandom(this.rms_noise)) * 10 ** -5, // convert back to bar + ), + ]; + }), + ); + } + + private bernoulli(loc: 'stagnation' | 'wake'): number { + const { rho, atm } = this.airProps; + return atm + this.coefficients[loc] * (rho * Math.pow(this.velocity, 2)); + } + + private idealGasLaw(loc: 'brakes' | 'suspension'): number { + this.temp += + this.acceleration < 0 && loc == 'brakes' + ? Math.abs(this.acceleration) * this.coefficients.brakingFactor + : 0; + return loc == 'brakes' + ? this.pResBrakes0 * (this.cToK(this.temp) / this.cToK(this.temp0)) + : this.pResSusp0 * (this.cToK(this.temp) / this.cToK(this.temp0)); + } + + private brakePressure(): number { + const { atm } = this.airProps; + const p = Sensor.lastReadings.pressure['pressure_front_brake']; + if (this.acceleration > 0 && p > atm) { + return p - 100; // arbitrary value for pressure drop + } else if (this.acceleration > 0) { + return atm; + } else { + this.temp += + Math.abs(this.acceleration) * 5 * this.coefficients.brakingFactor; + return atm * (this.cToK(this.temp) / this.cToK(this.temp0)); + } + } + + private cToK(temp: number): number { + return temp + 273.15; + } +} diff --git a/telemetry/packages/fake/src/sensors/resistance.ts b/telemetry/packages/fake/src/sensors/resistance.ts new file mode 100644 index 0000000..40a09b2 --- /dev/null +++ b/telemetry/packages/fake/src/sensors/resistance.ts @@ -0,0 +1,39 @@ +import { Temperature } from './temperature'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { Utilities as utils } from '../utils'; + +export class Resistance extends Temperature { + private alpha = 5 * 10 ** -3; // Temperature coefficient of resistance (steel) + private r0: number; // Initial value + + constructor(data: LiveReading) { + super(data); + // Set reference value and convert to ohms for higher precision output values + this.r0 = Sensor.lastReadings.resistance.power_line_resistance * 10 ** 3; + } + + /** + * Resistance can be assumed constant, seeing little variation with temperature + * change. + * This data verifies the power line's continual safety by checking resistance + * is as expected during operation. + */ + getData(): Readings { + if (!Sensor.isSampled['temperature']) { + this.temp = utils.average(Object.values(super.getData())); + Sensor.isSampled['temperature'] = true; + } + + const readings = Object.keys(Sensor.lastReadings.resistance).map((key) => { + // R = R0 * (1 + α(T - T0)) + const r = this.r0 * (1 + this.alpha * (this.temp - this.temp0)); + return [ + key, + utils.round2DP((r + utils.gaussianRandom(this.rms_noise)) * 0.001), + ]; + }); + + return Object.fromEntries(readings); + } +} diff --git a/telemetry/packages/fake/src/sensors/temperature.ts b/telemetry/packages/fake/src/sensors/temperature.ts new file mode 100644 index 0000000..8d1ecb4 --- /dev/null +++ b/telemetry/packages/fake/src/sensors/temperature.ts @@ -0,0 +1,47 @@ +import { Motion } from './motion'; +import { Sensor } from '../base'; +import { LiveReading, Readings } from '../types'; +import { Utilities } from '../utils'; + +export class Temperature extends Motion { + protected temp: number; + protected temp0: number; + + // Arbitrary coefficients for estimating temperature changes + private params = { + drag: 0.1, + friction: 0.3, + heatGen: 0.5, + }; + + constructor(data: LiveReading) { + super(data); + // Initial temp used for reference by subclass(es) + this.temp0 = Utilities.average(Object.values(data.readings)); + this.temp = this.temp0; + } + + getData(): Readings { + this.temp += // Air drag and internal heat generation + Math.pow(this.velocity, 3) * this.params.drag + + this.velocity * this.params.heatGen; + this.temp += // On the track, temperature increases with work done + this.velocity < this.liftoffSpeed + ? Math.pow(this.displacement, 2) * this.params.friction + : Math.pow(this.displacement, 2) * + (this.liftoffSpeed / this.velocity) * + this.displacement * + this.params.friction; + + return Object.fromEntries( + Object.keys(Sensor.lastReadings.temperature).map((key) => { + return [ + key, + Utilities.round2DP( + this.temp + Utilities.gaussianRandom(this.rms_noise), + ), + ]; + }), + ); + } +} diff --git a/telemetry/packages/fake/src/types.ts b/telemetry/packages/fake/src/types.ts new file mode 100644 index 0000000..62485a8 --- /dev/null +++ b/telemetry/packages/fake/src/types.ts @@ -0,0 +1,23 @@ +import type { RangeMeasurement } from '@hyped/telemetry-types'; +/** + * Unique variable readinfgs each sensor provides + * E.g. accelerometers generate values for acceleration, displacement and velocity + */ +export type LiveReading = RangeMeasurement & { readings: Readings }; + +export type SensorData = Record; + +/** + * Sensor property containing values for each of its measured quantities + */ +export type Readings = { + [measurement: string]: number; +}; + +/** + * Return type for sensor class instantiation + */ +export type BaseSensor = { + getData: (t: number) => Readings; + getRandomData: (prevValue: number, readings: Readings) => Readings; +}; diff --git a/telemetry/packages/fake/src/utils.ts b/telemetry/packages/fake/src/utils.ts new file mode 100644 index 0000000..8a37d10 --- /dev/null +++ b/telemetry/packages/fake/src/utils.ts @@ -0,0 +1,115 @@ +export class Utilities { + /** + * Greatest common divisor + */ + public static gcd(nums: number[]): number { + nums = nums.sort((a, b) => b - a); + return nums.reduce((acc, c) => { + return c === 0 ? acc : Utilities.gcd([c, acc % c]); + }); + } + + /** + * Simple floating point rounding method + * @param num + * @returns + */ + public static round2DP(num: number): number { + return parseFloat(num.toFixed(2)); + } + + /** + * Generates random noise value from a Gaussian distribution + * @param mean self-explanatory + * @param std_dev sensor's RMS noise value, used as the standard deviation + * @returns random number defined by the normal distribution of stdDev = RMS noise + */ + public static gaussianRandom(std_dev: number, mean = 0): number { + // Using the Box-Muller transform to generate random values from a normal distribution + const u1 = Math.random(); + const u2 = Math.random(); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + + return parseFloat((z * std_dev + mean).toFixed(2)); + } + + /** + * Generates random value from random distribution defined by provided range + * 99% probability of random value falling within critical limits + * This results in a z-score of 2.576 for confidence level of 99% + */ + public static getRandomValue( + prevValue: number, + rms_noise: number, + format: 'float' | 'integer', + ): number { + return format == 'float' + ? parseFloat(this.gaussianRandom(rms_noise, prevValue).toFixed(2)) + : parseInt(this.gaussianRandom(rms_noise, prevValue).toFixed(2)); + } + + /** + * Simple arithmetic mean + * @param values numerical sample + * @returns mean value + */ + public static average(values: number[]): number { + return values.reduce((acc, c) => acc + c) / values.length; + } + + /** + * Logistic function used as an analytical basis for dynamic variables which change over time + * @param t - current time + * @param peak - asymptotic maximum value + * @param k - exponential growth factor + * @param t0 - time of curve inflection (df²/dt² = 0) + * @returns f(t) - current reading according to idealised model + */ + public static logistic( + t: number, + peak: number, + k: number, // exponential growth rate factor + t0: number, // time at which second derivative reaches a stationary point + ): number { + return parseFloat((peak / (1 + Math.exp(-k * (t - t0)))).toFixed(2)); + } + + /** + * Sinusoidal damped oscillation + * @param t - current time + * @param freq - angular frequency + * @param phase - phase shift (angle) + * @param decay - exponential decay factor + * @param amp - oscillation peak amplitude + * @returns f(t) + */ + public static oscillateDecay( + t: number, + freq: number, + phase: number, + decay: number, + amp: number, + ): number { + return parseFloat( + (amp * Math.exp(-decay * t) * Math.cos(freq * t + phase)).toFixed(2), + ); + } + + /** + * Gets the exponential average of a recent set of values + * @param vals previous values (and chosen length of array) + * @param alpha weighting factor + * @returns exponentially weighted average + */ + public expMovingAvg(vals: number[], alpha: number): number | undefined { + if (alpha <= 0 || alpha > 1 || !vals.length) { + return; + } + let sum = 0; + vals.forEach((v, i) => { + const weight = Math.pow(alpha, vals.length - 1 - i); + sum += v * weight; + }); + return sum / (1 - Math.pow(alpha, vals.length)); + } +} diff --git a/telemetry/packages/fake/tsconfig.json b/telemetry/packages/fake/tsconfig.json new file mode 100644 index 0000000..469e315 --- /dev/null +++ b/telemetry/packages/fake/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext"], + "importHelpers": true, + "sourceMap": true, + "rootDir": "./src", + "strictPropertyInitialization": false, + }, + "include": ["src", "src/config.ts"], + "exclude": ["node_modules"], +} diff --git a/telemetry/packages/public-app/.env.docker b/telemetry/packages/public-app/.env.docker new file mode 100644 index 0000000..5cbd012 --- /dev/null +++ b/telemetry/packages/public-app/.env.docker @@ -0,0 +1,2 @@ +NEXT_PUBLIC_TELEMETRY_SERVER="http://localhost:3000" +NEXT_PUBLIC_POD_ID="pod_2024" \ No newline at end of file diff --git a/telemetry/packages/public-app/.env.example b/telemetry/packages/public-app/.env.example new file mode 100644 index 0000000..5cbd012 --- /dev/null +++ b/telemetry/packages/public-app/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_TELEMETRY_SERVER="http://localhost:3000" +NEXT_PUBLIC_POD_ID="pod_2024" \ No newline at end of file diff --git a/telemetry/packages/public-app/.eslintrc.json b/telemetry/packages/public-app/.eslintrc.json new file mode 100644 index 0000000..85e2f35 --- /dev/null +++ b/telemetry/packages/public-app/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "root": true, + "extends": ["next/core-web-vitals", "@hyped/eslint-config/react.js"], + "parserOptions": { + "project": true, + "tsconfigRootDir": "__dirname" + } +} diff --git a/telemetry/packages/public-app/.gitignore b/telemetry/packages/public-app/.gitignore new file mode 100644 index 0000000..8f322f0 --- /dev/null +++ b/telemetry/packages/public-app/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/telemetry/packages/public-app/app/cards.tsx b/telemetry/packages/public-app/app/cards.tsx new file mode 100644 index 0000000..eaa2f02 --- /dev/null +++ b/telemetry/packages/public-app/app/cards.tsx @@ -0,0 +1,87 @@ +import { Card, Title, Text, Grid } from '@tremor/react'; +import { VelocityGraph } from '@/components/velocity-graph'; +import { useState } from 'react'; +import { DisplacementChart } from '@/components/displacement-chart'; +import Image from 'next/image'; +import { LaunchTime } from '@/components/launch-time'; +import LevitationHeight from '@/components/levitation-height'; +import { SocialIcons } from '@/components/social-icons'; +import ThemeSwitch from '@/components/theme-switch'; + +/** + * The cards that are displayed on the dashboard. + */ +const CARDS = { + VELOCITY: , + ACCELERATION: , + LEVITATION: , +}; + +type Card = keyof typeof CARDS; + +export default function Cards() { + const [selected, setSelected] = useState('VELOCITY'); + + const selectedCardComponent = CARDS[selected]; + const otherCards = (Object.keys(CARDS) as Card[]).filter( + (c) => c !== selected, + ); + + return ( +
          +
          + +
          +
          + Dashboard + Telemetric data stream from on device sensors. +
          + + {selectedCardComponent} + + {otherCards.map((c) => ( + + ))} + +
          +
          + Theme: +
          + + +
          +
          + ); +} + +/** + * The HYPED logo image - changes depending on the theme. + * @returns The HYPED logo as an image. + */ +const HypedImage = () => { + const common = { + alt: 'HYPED Logo, with a red E resembling 3 stacked hyperloop pods', + width: 200, + height: 50, + }; + + return ( + <> + {common.alt} + {common.alt} + + ); +}; diff --git a/telemetry/packages/public-app/app/favicon.ico b/telemetry/packages/public-app/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8dcf22985cfc8b25c3418189b452f1241798a57f GIT binary patch literal 3541 zcmb7GYgAKL8jV^?i>1*jiV(O&l7K}K##U6yMGc4)d8j;89*R!I$A}VMRxT)7NqvA( z#HffY>pPGHihuzrGdfBD!D$&6OTp@l7hQs&1|^Lk%sv6swb~yY7q0c4bIh$qAqm941A zMx?Y6s(1o5PoT86S5XdXTM;~{Y=v-A2tPb~oG(NON{G!OTY(arHUc#+@@8TNI3d0q5H)ZZ@&O9Ga1MvNTwfysxaADfN-#Qan4n6aN(OqghCWzDm(<4?91IXF(4 z<~-fy%b9MozVe_w=gyli_VQlfv(WczzeWB5fkBIxEDa6`4O_N+MR-JH)XG)StJnPX zo3%0P)^GURrr6DKlK6zgq~xvNZcAZO)3)zO-?{6%-5JvF_w3!bKQrrq?BJp7!#PLf zM~@xPJ&|`Z|I`ns&zwDX{=!8?L7`HmE-L=9%KSrX?<52up6=3&&R7te*dJ1!!euU z>oqTU>)pQ1dnTTmx$M&Q+XwQnfLJUEx3K?zH6v}Kr-0aq! z`n#7w7Cx0STXcl0Ig4eSj<1gbhI0tpYXPU9QxYcy!e=0Sa1K`!k7$Y6j5bn0Nhr5^ zY3o_s@ZFA+B1+O>ifbX*crw=@s|)~@PY-c5*0|Et4<7}LcGLQUTun=xSk)&Cqf_sE zVvsqd)#e1|Cmmq4IX|&==QpR!rCGC;lD4KAkExX8o9PQ~b!vqv#!7;OXmW&NzMILRREeM$sbO0(@LwK?2-m0>h{_fz<;)=GC8)yXG%X=sq0jP{KmJ^m=b`?2Emd?;D%1eLHqO^rqEu#l*ToDhS|J)CAk-9Xbs$1g8~ z{%K&-aU9;la55i-uiuC)xCGIx3dK6mZQVfgO?;d0zDa3FaVdSepw+zvT!WgtvG zG;NuO%P|JoMt4ktezfAsK6vRzr@3d4yt87}8y_4w!#Ezz9}5G`oycG1=IQ zk^t*pWn7aiOo4Ln{21FR#&OGG@HvQW)Pw%>L4H*$fh!yB;Y|t!6XF1A{VYa%mSixr z;I=b-l=dQ#LLFaTg&LS1iD<^Q&%q9<3UvhZwAMo$YruOUk1#wh4F{L_eT>#0E*Fr- zYV3EUBkS%2^o4u~5gq7fpmJZ+=6%?$+rVl0;+YG zTv;z;wD<5P=8}r5Kug&m@kncI%NV2(jY?DKB0cmC$7AGy7H{Dq^f6pr0}2fo$vxNL ze2O8+#S;S|7PX%`4thVSB+9#C+~?Cz|I{-Y_L7|44E=LRTU^xf!^mT?A*N0mL1ycQ zQWTjp8J49hIwxZSTqf*5Ch#>X=nI3ap1eD96js|1@rU6bt X&#!vRhc9-JhsxJGz)LlM)z1F`?H+kD literal 0 HcmV?d00001 diff --git a/telemetry/packages/public-app/app/globals.css b/telemetry/packages/public-app/app/globals.css new file mode 100644 index 0000000..7fc43a1 --- /dev/null +++ b/telemetry/packages/public-app/app/globals.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.fade-in-image { + animation: fadeIn 6s; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/telemetry/packages/public-app/app/layout.tsx b/telemetry/packages/public-app/app/layout.tsx new file mode 100644 index 0000000..290172b --- /dev/null +++ b/telemetry/packages/public-app/app/layout.tsx @@ -0,0 +1,25 @@ +import './globals.css'; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import Providers from './providers'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'HYPED | Public App', + description: 'Public app for HYPED Telemetry', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/telemetry/packages/public-app/app/loading-screen.tsx b/telemetry/packages/public-app/app/loading-screen.tsx new file mode 100644 index 0000000..38381ac --- /dev/null +++ b/telemetry/packages/public-app/app/loading-screen.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import '../app/globals.css'; +import Image from 'next/image'; + +/** + * Loading screen statuses. Used to simulate a loading process. + */ +export const STATUSES = { + AUTHENTICATING: 'authenticating', + PROCESSING: 'processing', + GRANTING_ACCESS: 'granting-access', + DONE: 'done', +}; +export type Status = (typeof STATUSES)[keyof typeof STATUSES]; + +export default function LoadingScreen({ status }: { status: Status }) { + const text: Record = { + [STATUSES.AUTHENTICATING]: 'Authenticating...', + [STATUSES.PROCESSING]: 'Performing Security Checks...', + [STATUSES.GRANTING_ACCESS]: 'Granting access...', + }; + + return ( +
          + hyped logo +
          +

          {text[status]}

          +
          +
          + ); +} diff --git a/telemetry/packages/public-app/app/page.tsx b/telemetry/packages/public-app/app/page.tsx new file mode 100644 index 0000000..661ea6c --- /dev/null +++ b/telemetry/packages/public-app/app/page.tsx @@ -0,0 +1,30 @@ +'use client'; + +import LoadingScreen from '@/app/loading-screen'; +import { useEffect, useState } from 'react'; +import Cards from './cards'; +import { STATUSES, Status } from './loading-screen'; + +export default function Home() { + const [status, setStatus] = useState(STATUSES.AUTHENTICATING); + + // Simulate a loading process + // Not the best way to do this but it'll do + useEffect(() => { + setTimeout(() => { + setStatus(STATUSES.PROCESSING); + }, 1500); + setTimeout(() => { + setStatus(STATUSES.GRANTING_ACCESS); + }, 3000); + setTimeout(() => { + setStatus(STATUSES.DONE); + }, 4500); + }, []); + + return ( +
          + {status !== STATUSES.DONE ? : } +
          + ); +} diff --git a/telemetry/packages/public-app/app/providers.tsx b/telemetry/packages/public-app/app/providers.tsx new file mode 100644 index 0000000..acdfd66 --- /dev/null +++ b/telemetry/packages/public-app/app/providers.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Create a client +const queryClient = new QueryClient(); + +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/telemetry/packages/public-app/components/displacement-chart.tsx b/telemetry/packages/public-app/components/displacement-chart.tsx new file mode 100644 index 0000000..77c60af --- /dev/null +++ b/telemetry/packages/public-app/components/displacement-chart.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { Card, LineChart, Metric, Text } from '@tremor/react'; +import { useQuery } from 'react-query'; +import format from 'date-fns/format'; +import { getDisplacement } from '@/helpers'; +import { HistoricalValueResponse } from '@hyped/telemetry-types'; + +/** + * The displacement chart. Fetches displacement data from the server. + * @returns The displacement chart. + */ +export const DisplacementChart = () => { + const { data, isLoading, error } = useQuery( + 'displacement', + async () => await getDisplacement(), + { + refetchInterval: 1000, + }, + ); + + if (isLoading) + return ( + + Displacement + Loading... + + ); + if (error) + return ( + + Displacement + Error fetching displacement data + + ); + + const displacementData = formatData(data); + + return ( + + Displacement + + + ); +}; + +/** + * Formats the data to be displayed on the chart. + * @param data The data to format. + * @returns The formatted data. + */ +const formatData = (data: HistoricalValueResponse | undefined) => + data + ? data.map((d) => { + const time = new Date(d.timestamp); + return { + time: format(time, 'HH:mm:ss'), + displacement: d.value, + }; + }) + : []; diff --git a/telemetry/packages/public-app/components/launch-time.tsx b/telemetry/packages/public-app/components/launch-time.tsx new file mode 100644 index 0000000..670db0d --- /dev/null +++ b/telemetry/packages/public-app/components/launch-time.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Card, Metric, Text } from '@tremor/react'; +import { BadgeDelta } from '@tremor/react'; +import { useQuery } from 'react-query'; +import { getLaunchTime } from '@/helpers'; + +/** + * The launch time card. Fetches the launch time from the server. + * @returns The launch time card. + */ +export function LaunchTime() { + const { data } = useQuery('launch-time', async () => await getLaunchTime(), { + refetchInterval: 900, + }); + + // Calculate the time since launch + const launchTime = data?.launchTime || -1; + const currentTimeInSeconds = Date.now(); + const timeSinceLaunch = + launchTime > 0 ? (currentTimeInSeconds - launchTime) / 1000 : -1; + + return ( + +
          + Time since launch + + {timeSinceLaunch > -1 + ? `${Math.floor(timeSinceLaunch / 60)}m ${Math.floor(timeSinceLaunch % 60)}s` + : 'Not launched yet'} + +
          +
          + 0 ? 'moderateIncrease' : 'moderateDecrease'} + > + {launchTime > 0 ? 'LIVE' : 'Not Launched'} + +
          +
          + ); +} diff --git a/telemetry/packages/public-app/components/levitation-height.tsx b/telemetry/packages/public-app/components/levitation-height.tsx new file mode 100644 index 0000000..3d1db34 --- /dev/null +++ b/telemetry/packages/public-app/components/levitation-height.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from 'react-query'; +import { LevitationHeightResponse } from '@hyped/telemetry-types'; +import { + Card, + ProgressBar, + Text, + Flex, + Button, + Metric, + BadgeDelta, + Title, +} from '@tremor/react'; +import { getLevitationHeight } from '@/helpers'; + +export default function LevitationHeight() { + const [showMore, setShowMore] = useState(false); + + const { data, isLoading, error } = useQuery( + 'levitation-height', + async () => await getLevitationHeight(), + { + refetchInterval: 1000, + }, + ); + + if (isLoading || !data) + return ( + + Levitation Height + Loading... + + ); + if (error) + return ( + + Levitation Height + Error fetching levitation height data + + ); + + /** + * Checks if the pod is levitated based on the levitation height data. + * If any of the levitation height values are greater than 0, the pod is considered levitated. + */ + const isLevitated = data + ? Object.keys(data).some((key) => data[key]?.value > 0) + : false; + + const keys = Object.keys(data); + const numSensors = keys.length; + + return ( + + + Levitation Height + {data ? ( + + Elevated + + ) : null} + + Pod Height + {data ? ( + keys + // unless showMore is true, only show the first 4 (if possible) or half of the data + .slice(0, showMore ? numSensors : numSensors > 4 ? 4 : numSensors / 2) + .map((levitationHeight) => { + const { id, value } = data[levitationHeight]; + return ( +
          + + {id} + {`${value} mm`} + + +
          + ); + }) + ) : ( +
          no data
          + )} + + + +
          + ); +} diff --git a/telemetry/packages/public-app/components/social-icons.tsx b/telemetry/packages/public-app/components/social-icons.tsx new file mode 100644 index 0000000..49deb62 --- /dev/null +++ b/telemetry/packages/public-app/components/social-icons.tsx @@ -0,0 +1,54 @@ +import { Linkedin, Facebook, Instagram, Github, Twitter } from 'lucide-react'; +import Link from 'next/link'; + +/** + * Defines the social media icons and their links. + */ +const SOCIAL_ICONS = { + facebook: { + link: 'https://www.facebook.com/hypedinburgh/', + component: ( + + ), + }, + github: { + link: 'https://github.com/hyp-ed/hyped-2024', + component: , + }, + instagram: { + link: 'https://www.instagram.com/hypedinburgh/', + component: ( + + ), + }, + linkedIn: { + link: 'https://www.linkedin.com/company/hyp-ed/', + component: ( + + ), + }, + twitter: { + link: 'https://twitter.com/hyped_hyperloop', + component: ( + + ), + }, +}; + +/** + * Displays a row of social media icons that link to the respective social media pages. + * @returns The social media icons. + */ +export const SocialIcons = () => { + const socialIcons = Object.values(SOCIAL_ICONS); + + return ( +
          + {socialIcons.map(({ link, component }, index) => ( + + {component} + + ))} +
          + ); +}; diff --git a/telemetry/packages/public-app/components/theme-switch.tsx b/telemetry/packages/public-app/components/theme-switch.tsx new file mode 100644 index 0000000..693b06b --- /dev/null +++ b/telemetry/packages/public-app/components/theme-switch.tsx @@ -0,0 +1,22 @@ +import { Switch } from '@tremor/react'; +import { useEffect, useState } from 'react'; + +function ThemeSwitch() { + const [theme, setTheme] = useState('dark'); + + useEffect(() => { + document.body.classList.remove('light', 'dark'); + document.body.classList.add(theme); + }, [theme]); + + const [enabled, setEnabled] = useState(theme == 'dark'); + + const handleThemeChange = (enabled: boolean) => { + setTheme(enabled ? 'dark' : 'light'); + setEnabled(enabled); + }; + + return ; +} + +export default ThemeSwitch; diff --git a/telemetry/packages/public-app/components/velocity-graph.tsx b/telemetry/packages/public-app/components/velocity-graph.tsx new file mode 100644 index 0000000..9c1df60 --- /dev/null +++ b/telemetry/packages/public-app/components/velocity-graph.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { getVelocity } from '@/helpers'; +import { HistoricalValueResponse } from '@hyped/telemetry-types'; +import { Card, LineChart, Metric, Text } from '@tremor/react'; +import { format } from 'date-fns'; +import { useQuery } from 'react-query'; + +/** + * The velocity chart. Fetches velocity data from the server. + * @returns The velocity chart. + */ +export const VelocityGraph = () => { + const { data, isLoading, error } = useQuery( + 'velocity', + async () => await getVelocity(), + { + refetchInterval: 1000, + }, + ); + + if (isLoading) + return ( + + Velocity + Loading... + + ); + if (error) + return ( + + Velocity + Error fetching velocity data + + ); + + const velocityData = formatData(data); + + return ( + + Velocity + + + ); +}; + +/** + * Formats the data to be displayed on the chart. + * @param data The data to format. + * @returns The formatted data. + */ +const formatData = (data: HistoricalValueResponse | undefined) => + data + ? data.map((d) => { + const time = new Date(d.timestamp); + return { + time: format(time, 'HH:mm:ss'), + velocity: d.value, + }; + }) + : []; diff --git a/telemetry/packages/public-app/helpers.ts b/telemetry/packages/public-app/helpers.ts new file mode 100644 index 0000000..e103940 --- /dev/null +++ b/telemetry/packages/public-app/helpers.ts @@ -0,0 +1,73 @@ +import { LevitationHeightResponse } from '@hyped/telemetry-types'; +import { + DisplacementResponse, + LaunchTimeResponse, + VelocityResponse, +} from '@hyped/telemetry-types/dist/server/responses'; + +/** + * The server endpoint for fetching public data. + */ +export const SERVER_ENDPOINT = `${process.env.NEXT_PUBLIC_TELEMETRY_SERVER}/pods/${process.env.NEXT_PUBLIC_POD_ID}/public-data`; + +const ONE_MINUTE = 60; + +/** + * Gets the last `prev` seconds of displacement data. + * @param prev The number of seconds to get. + * @returns The historical displacement data. + */ +export const getDisplacement = async ( + prev: number = ONE_MINUTE, +): Promise => { + const now = new Date().getTime(); + const start = now - prev * 1000; + const response = await fetch( + `${SERVER_ENDPOINT}/displacement?start=${start}`, + ); + if (response.status !== 200) throw new Error('Failed to fetch displacement'); + return response.json() as Promise; +}; + +/** + * Gets the last `prev` seconds of levitation height data. + * @param prev The number of seconds to get. + * @returns The historical levitation height data. + */ +export const getLevitationHeight = async ( + prev: number = ONE_MINUTE, +): Promise => { + const now = new Date().getTime(); + const start = now - prev * 1000; + const response = await fetch( + `${SERVER_ENDPOINT}/levitation-height?start=${start}`, + ); + if (response.status !== 200) + throw new Error('Failed to fetch levitation height'); + return response.json() as Promise; +}; + +/** + * Gets the last `prev` seconds of velocity data. + * @param prev The number of seconds to get. + * @returns The historical velocity data. + */ +export const getVelocity = async ( + prev: number = ONE_MINUTE, +): Promise => { + const now = new Date().getTime(); + const start = now - prev * 1000; + const response = await fetch(`${SERVER_ENDPOINT}/velocity?start=${start}`); + if (response.status !== 200) throw new Error('Failed to fetch velocity'); + return response.json() as Promise; +}; + +/** + * Gets the launch time of the pod (in milliseconds). + * @returns The launch time of the pod. + */ +export const getLaunchTime = async (): Promise => { + const response = await fetch(`${SERVER_ENDPOINT}/launch-time`); + if (response.status !== 200) throw new Error('Failed to fetch launch time'); + return response.json() as Promise; +}; diff --git a/telemetry/packages/public-app/next.config.js b/telemetry/packages/public-app/next.config.js new file mode 100644 index 0000000..826aa5a --- /dev/null +++ b/telemetry/packages/public-app/next.config.js @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config, context) => { + // Enable polling based on env variable being set + if (true) { + config.watchOptions = { + poll: 500, + aggregateTimeout: 300, + }; + } + return config; + }, +}; + +module.exports = nextConfig; +// next.config.js diff --git a/telemetry/packages/public-app/package.json b/telemetry/packages/public-app/package.json new file mode 100644 index 0000000..3b46ff9 --- /dev/null +++ b/telemetry/packages/public-app/package.json @@ -0,0 +1,45 @@ +{ + "name": "@hyped/public-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "dev:test": "next dev -p 3001", + "build": "next build", + "start": "next start", + "lint": "eslint --ext .ts,.tsx . --max-warnings 0 --report-unused-disable-directives", + "lint:fix": "eslint --fix --ext .ts,.tsx ." + }, + "dependencies": { + "@preact/signals-react": "^1.3.6", + "@radix-ui/react-switch": "^1.0.3", + "@tremor/react": "^3.9.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "influxdb": "^0.0.1", + "lucide-react": "^0.292.0", + "next": "^14.1.0", + "next-themes": "^0.2.1", + "react": "^18", + "react-dom": "^18", + "react-google-charts": "^4.0.1", + "react-query": "^3.39.3", + "react-switch": "^7.0.0", + "tailwind-merge": "^2.0.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@hyped/eslint-config": "workspace:*", + "@hyped/telemetry-types": "workspace:*", + "@hyped/tsconfig": "workspace:*", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10", + "eslint-config-next": "13.5.4", + "postcss": "^8", + "tailwindcss": "^3", + "typescript": "^5" + } +} diff --git a/telemetry/packages/public-app/postcss.config.js b/telemetry/packages/public-app/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/telemetry/packages/public-app/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/telemetry/packages/public-app/public/hyped-dark.png b/telemetry/packages/public-app/public/hyped-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..700ab3fb303d857c64c7655c85b90aa1ee8171c2 GIT binary patch literal 29377 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 literal 0 HcmV?d00001 diff --git a/telemetry/packages/public-app/public/hyped-light.png b/telemetry/packages/public-app/public/hyped-light.png new file mode 100644 index 0000000000000000000000000000000000000000..40947f06b359dc90f24c5e4f5ca082a2452daab2 GIT binary patch literal 29564 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 literal 0 HcmV?d00001 diff --git a/telemetry/packages/public-app/public/hyped.svg b/telemetry/packages/public-app/public/hyped.svg new file mode 100644 index 0000000..2a6f61c --- /dev/null +++ b/telemetry/packages/public-app/public/hyped.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/telemetry/packages/public-app/tailwind.config.js b/telemetry/packages/public-app/tailwind.config.js new file mode 100644 index 0000000..d4fb5c2 --- /dev/null +++ b/telemetry/packages/public-app/tailwind.config.js @@ -0,0 +1,142 @@ +/** @type {import('tailwindcss').Config} */ +/* eslint-disable max-len */ +module.exports = { + darkMode: 'class', + content: [ + './app/**/*.{js,ts,jsx,tsx}', + './pages/**/*.{js,ts,jsx,tsx}', + './components/**/*.{js,ts,jsx,tsx}', + '../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}', + './node_modules/@tremor/**/*.{js,ts,jsx,tsx}', + ], + theme: { + transparent: 'transparent', + current: 'currentColor', + extend: { + colors: { + // HYPED COLORS + 'hyped-foreground': '#ffffff', + 'hyped-background': '#000000', + 'hyped-red': '#af1f24', + 'hyped-light-gray': '#edeeec', + 'openmct-light-gray': '#535353', + 'openmct-dark-gray': '#222222', + // light mode + tremor: { + brand: { + faint: '#eff6ff', // blue-50 + muted: '#bfdbfe', // blue-200 + subtle: '#60a5fa', // blue-400 + DEFAULT: '#3b82f6', // blue-500 + emphasis: '#1d4ed8', // blue-700 + inverted: '#ffffff', // white + }, + background: { + muted: '#f9fafb', // gray-50 + subtle: '#f3f4f6', // gray-100 + DEFAULT: '#ffffff', // white + emphasis: '#374151', // gray-700 + }, + border: { + DEFAULT: '#e5e7eb', // gray-200 + }, + ring: { + DEFAULT: '#e5e7eb', // gray-200 + }, + content: { + subtle: '#9ca3af', // gray-400 + DEFAULT: '#6b7280', // gray-500 + emphasis: '#374151', // gray-700 + strong: '#111827', // gray-900 + inverted: '#ffffff', // white + }, + }, + // dark mode + 'dark-tremor': { + brand: { + faint: '#0B1229', // custom + muted: '#172554', // blue-950 + subtle: '#1e40af', // blue-800 + DEFAULT: '#3b82f6', // blue-500 + emphasis: '#60a5fa', // blue-400 + inverted: '#030712', // gray-950 + }, + background: { + muted: '#131A2B', // custom + subtle: '#1f2937', // gray-800 + DEFAULT: '#111827', // gray-900 + emphasis: '#d1d5db', // gray-300 + }, + border: { + DEFAULT: '#1f2937', // gray-800 + }, + ring: { + DEFAULT: '#1f2937', // gray-800 + }, + content: { + subtle: '#4b5563', // gray-600 + DEFAULT: '#6b7280', // gray-600 + emphasis: '#e5e7eb', // gray-200 + strong: '#f9fafb', // gray-50 + inverted: '#000000', // black + }, + }, + }, + boxShadow: { + // light + 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'tremor-card': + '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'tremor-dropdown': + '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + // dark + 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'dark-tremor-card': + '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'dark-tremor-dropdown': + '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }, + borderRadius: { + 'tremor-small': '0.375rem', + 'tremor-default': '0.5rem', + 'tremor-full': '9999px', + }, + fontSize: { + 'tremor-label': ['0.75rem'], + 'tremor-default': ['0.875rem', { lineHeight: '1.25rem' }], + 'tremor-title': ['1.125rem', { lineHeight: '1.75rem' }], + 'tremor-metric': ['1.875rem', { lineHeight: '2.25rem' }], + }, + }, + }, + safelist: [ + { + pattern: + /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ['hover', 'ui-selected'], + }, + { + pattern: + /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ['hover', 'ui-selected'], + }, + { + pattern: + /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ['hover', 'ui-selected'], + }, + { + pattern: + /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + ], + plugins: [require('@headlessui/tailwindcss')], +}; diff --git a/telemetry/packages/public-app/tsconfig.json b/telemetry/packages/public-app/tsconfig.json new file mode 100644 index 0000000..919f8a7 --- /dev/null +++ b/telemetry/packages/public-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "bundler", + "jsx": "preserve", + "plugins": [ + { + "name": "next", + }, + ], + "paths": { + "@/*": ["./*"], + }, + "allowJs": true, + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"], +} diff --git a/telemetry/packages/server/.env.docker b/telemetry/packages/server/.env.docker new file mode 100644 index 0000000..ad2e5e7 --- /dev/null +++ b/telemetry/packages/server/.env.docker @@ -0,0 +1,6 @@ +INFLUX_URL=http://influxdb:8086 +INFLUX_TOKEN=edinburgh +INFLUX_ORG=hyped +INFLUX_TELEMETRY_BUCKET=telemetry +INFLUX_FAULTS_BUCKET=faults +MQTT_BROKER_HOST=mosquitto \ No newline at end of file diff --git a/telemetry/packages/server/.env.example b/telemetry/packages/server/.env.example new file mode 100644 index 0000000..d6c53d5 --- /dev/null +++ b/telemetry/packages/server/.env.example @@ -0,0 +1,6 @@ +INFLUX_URL= +INFLUX_TOKEN= +INFLUX_ORG=hyped +INFLUX_TELEMETRY_BUCKET=telemetry +INFLUX_FAULTS_BUCKET=faults +MQTT_BROKER_HOST= \ No newline at end of file diff --git a/telemetry/packages/server/.eslintrc.js b/telemetry/packages/server/.eslintrc.js new file mode 100644 index 0000000..acab2de --- /dev/null +++ b/telemetry/packages/server/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@hyped/eslint-config/basic.js'], + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +}; diff --git a/telemetry/packages/server/README.md b/telemetry/packages/server/README.md new file mode 100644 index 0000000..8372941 --- /dev/null +++ b/telemetry/packages/server/README.md @@ -0,0 +1,73 @@ +

          + Nest Logo +

          + +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

          A progressive Node.js framework for building efficient and scalable server-side applications.

          +

          +NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

          + + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/telemetry/packages/server/nest-cli.json b/telemetry/packages/server/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/telemetry/packages/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/telemetry/packages/server/package.json b/telemetry/packages/server/package.json new file mode 100644 index 0000000..d29c973 --- /dev/null +++ b/telemetry/packages/server/package.json @@ -0,0 +1,54 @@ +{ + "name": "@hyped/telemetry-server", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "nest start --watch", + "dev:test": "nest start --watch", + "build": "nest build", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0 --report-unused-disable-directives", + "lint:fix": "eslint \"src/**/*.ts\" --fix" + }, + "dependencies": { + "@hyped/telemetry-constants": "workspace:*", + "@influxdata/influxdb-client": "^1.33.1", + "@nestjs/common": "^9.2.1", + "@nestjs/core": "^9.2.1", + "@nestjs/microservices": "^9.2.1", + "@nestjs/platform-express": "^9.2.1", + "@nestjs/platform-socket.io": "^9.4.2", + "@nestjs/websockets": "^9.4.2", + "dotenv": "^16.0.3", + "env-var": "^7.3.0", + "mqtt": "^4.3.7", + "nanoid": "^3.3.6", + "nest-mqtt": "^0.2.0", + "nest-winston": "^1.9.1", + "random": "^3.0.6", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0", + "socket.io": "^4.6.2", + "socket.io-client": "^4.6.2", + "winston": "^3.8.2", + "winston-transport": "^4.6.0", + "zod": "^3.21.4" + }, + "devDependencies": { + "@hyped/telemetry-types": "workspace:*", + "@hyped/eslint-config": "workspace:*", + "@hyped/tsconfig": "workspace:*", + "@nestjs/cli": "^9.1.8", + "@nestjs/schematics": "^9.0.4", + "@nestjs/testing": "^9.2.1", + "@types/express": "^4.17.15", + "@types/node": "18.11.18", + "source-map-support": "^0.5.21", + "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", + "tsconfig-paths": "4.1.2", + "typescript": "^5.3.3" + } +} diff --git a/telemetry/packages/server/src/app.controller.ts b/telemetry/packages/server/src/app.controller.ts new file mode 100644 index 0000000..b391b7a --- /dev/null +++ b/telemetry/packages/server/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('ping') + getPing(): string { + return this.appService.getPing(); + } +} diff --git a/telemetry/packages/server/src/app.module.ts b/telemetry/packages/server/src/app.module.ts new file mode 100644 index 0000000..6a0fada --- /dev/null +++ b/telemetry/packages/server/src/app.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { InfluxModule } from './modules/influx/Influx.module'; +import { LoggerModule } from './modules/logger/Logger.module'; +import { MqttClientModule } from './modules/mqtt/client/MqttClientModule'; +import { MqttIngestionModule } from './modules/mqtt/ingestion/MqttIngestion.module'; +import { OpenMCTModule } from './modules/openmct/OpenMCT.module'; +import { MeasurementModule } from './modules/measurement/Measurement.module'; +import { FaultModule } from './modules/openmct/faults/Fault.module'; +import { PodControlsModule } from './modules/controls/PodControls.module'; +import { WarningsModule } from './modules/warnings/Warnings.module'; +import { RemoteLogsModule } from './modules/remote-logs/RemoteLogs.module'; +import { PublicDataModule } from './modules/public-data/PublicData.module'; +import { LiveLogsGateway } from './modules/live-logs/LiveLogs.gateway'; + +@Module({ + imports: [ + LoggerModule, + MqttClientModule, + InfluxModule, + MqttIngestionModule, + OpenMCTModule, + MeasurementModule, + FaultModule, + PodControlsModule, + WarningsModule, + RemoteLogsModule, + PublicDataModule, + ], + controllers: [AppController], + providers: [AppService, LiveLogsGateway], +}) +export class AppModule {} diff --git a/telemetry/packages/server/src/app.service.ts b/telemetry/packages/server/src/app.service.ts new file mode 100644 index 0000000..177eb6c --- /dev/null +++ b/telemetry/packages/server/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getPing(): string { + return 'pong'; + } +} diff --git a/telemetry/packages/server/src/main.ts b/telemetry/packages/server/src/main.ts new file mode 100644 index 0000000..0792546 --- /dev/null +++ b/telemetry/packages/server/src/main.ts @@ -0,0 +1,18 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + }); + app.enableCors(); + app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); + + await app.listen(3000); +} + +void bootstrap(); diff --git a/telemetry/packages/server/src/modules/common/types/InfluxRow.ts b/telemetry/packages/server/src/modules/common/types/InfluxRow.ts new file mode 100644 index 0000000..052efa7 --- /dev/null +++ b/telemetry/packages/server/src/modules/common/types/InfluxRow.ts @@ -0,0 +1,5 @@ +export interface InfluxRow { + _time: string; + _value: string; + podId: string; +} diff --git a/telemetry/packages/server/src/modules/common/utils/toUnixTimestamp.ts b/telemetry/packages/server/src/modules/common/utils/toUnixTimestamp.ts new file mode 100644 index 0000000..709d855 --- /dev/null +++ b/telemetry/packages/server/src/modules/common/utils/toUnixTimestamp.ts @@ -0,0 +1,3 @@ +export function toUnixTimestamp(date: Date) { + return Math.floor(date.getTime() / 1000); +} diff --git a/telemetry/packages/server/src/modules/common/utils/zodEnumFromObjKeys.ts b/telemetry/packages/server/src/modules/common/utils/zodEnumFromObjKeys.ts new file mode 100644 index 0000000..60688f2 --- /dev/null +++ b/telemetry/packages/server/src/modules/common/utils/zodEnumFromObjKeys.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export function zodEnumFromObjKeys( + obj: Record, +): z.ZodEnum<[K, ...K[]]> { + const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; + return z.enum([firstKey, ...otherKeys]); +} diff --git a/telemetry/packages/server/src/modules/controls/PodControls.controller.ts b/telemetry/packages/server/src/modules/controls/PodControls.controller.ts new file mode 100644 index 0000000..328707b --- /dev/null +++ b/telemetry/packages/server/src/modules/controls/PodControls.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Param, Post, Query } from '@nestjs/common'; +import { PodControlsService } from './PodControls.service'; + +@Controller('pods/:podId/controls') +export class PodControlsController { + constructor(private podControlsService: PodControlsService) {} + + @Post('levitation-height') + setLevitationHeight( + @Param('podId') podId: string, + @Query('height') height: number, + ) { + return this.podControlsService.setLevitationHeight(height, podId); + } + + @Post(':control') + controlPod(@Param('control') control: string, @Param('podId') podId: string) { + return this.podControlsService.sendControlMessage(control, podId); + } +} diff --git a/telemetry/packages/server/src/modules/controls/PodControls.module.ts b/telemetry/packages/server/src/modules/controls/PodControls.module.ts new file mode 100644 index 0000000..4e99d40 --- /dev/null +++ b/telemetry/packages/server/src/modules/controls/PodControls.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PodControlsController } from './PodControls.controller'; +import { PodControlsService } from './PodControls.service'; + +@Module({ + controllers: [PodControlsController], + providers: [PodControlsService], +}) +export class PodControlsModule {} diff --git a/telemetry/packages/server/src/modules/controls/PodControls.service.ts b/telemetry/packages/server/src/modules/controls/PodControls.service.ts new file mode 100644 index 0000000..ce9aa43 --- /dev/null +++ b/telemetry/packages/server/src/modules/controls/PodControls.service.ts @@ -0,0 +1,48 @@ +import { Injectable, LoggerService, Inject } from '@nestjs/common'; +import { MqttService } from 'nest-mqtt'; +import { Logger } from '@/modules/logger/Logger.decorator'; + +@Injectable() +export class PodControlsService { + constructor( + @Inject(MqttService) private readonly mqttService: MqttService, + @Logger() + private readonly logger: LoggerService, + ) {} + + /** + * Sends a control message to a pod. + * @param control The control message to send + * @param podId The ID of the pod + * @returns True if the message was sent successfully, false otherwise + */ + async sendControlMessage(control: string, podId: string) { + await this.mqttService.publish( + `hyped/${podId}/controls/${control}`, + control, + ); + this.logger.log( + `Control message "${control}" sent to pod "${podId}"`, + PodControlsService.name, + ); + return true; + } + + /** + * Sets the levitation height of a pod. + * @param height The height in millimeters + * @param podId The ID of the pod + * @returns True if the message was sent successfully, false otherwise + */ + async setLevitationHeight(height: number, podId: string) { + await this.mqttService.publish( + `hyped/${podId}/controls/levitation_height`, + height.toString(), + ); + this.logger.log( + `Levitation height set to ${height}mm for pod "${podId}"`, + PodControlsService.name, + ); + return true; + } +} diff --git a/telemetry/packages/server/src/modules/core/config.ts b/telemetry/packages/server/src/modules/core/config.ts new file mode 100644 index 0000000..9243ab0 --- /dev/null +++ b/telemetry/packages/server/src/modules/core/config.ts @@ -0,0 +1,20 @@ +import * as env from 'env-var'; + +export const ENV = env.get('ENV').default('development').asString(); + +export const INFLUX_URL = env.get('INFLUX_URL').required().asUrlString(); +export const INFLUX_TOKEN = env.get('INFLUX_TOKEN').required().asString(); +export const INFLUX_ORG = env.get('INFLUX_ORG').required().asString(); +export const INFLUX_TELEMETRY_BUCKET = env + .get('INFLUX_TELEMETRY_BUCKET') + .required() + .asString(); +export const INFLUX_FAULTS_BUCKET = env + .get('INFLUX_FAULTS_BUCKET') + .required() + .asString(); + +export const MQTT_BROKER_HOST = env + .get('MQTT_BROKER_HOST') + .required() + .asString(); diff --git a/telemetry/packages/server/src/modules/influx/Influx.module.ts b/telemetry/packages/server/src/modules/influx/Influx.module.ts new file mode 100644 index 0000000..fb4479c --- /dev/null +++ b/telemetry/packages/server/src/modules/influx/Influx.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { InfluxService } from './Influx.service'; + +@Module({ + providers: [InfluxService], + exports: [InfluxService], +}) +export class InfluxModule {} diff --git a/telemetry/packages/server/src/modules/influx/Influx.service.ts b/telemetry/packages/server/src/modules/influx/Influx.service.ts new file mode 100644 index 0000000..ce6dc0d --- /dev/null +++ b/telemetry/packages/server/src/modules/influx/Influx.service.ts @@ -0,0 +1,78 @@ +import { InfluxDB, QueryApi, WriteApi } from '@influxdata/influxdb-client'; +import { Injectable, LoggerService, OnModuleInit } from '@nestjs/common'; +import { + INFLUX_TELEMETRY_BUCKET, + INFLUX_ORG, + INFLUX_TOKEN, + INFLUX_URL, + INFLUX_FAULTS_BUCKET, +} from '@/modules/core/config'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { InfluxServiceError } from './errors/InfluxServiceError'; + +@Injectable() +export class InfluxService implements OnModuleInit { + private connection: InfluxDB; + public telemetryWrite: WriteApi; + public faultsWrite: WriteApi; + public query: QueryApi; + @Logger() + private readonly logger: LoggerService; + + async $connect() { + this.connection = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN }); + this.telemetryWrite = this.connection.getWriteApi( + INFLUX_ORG, + INFLUX_TELEMETRY_BUCKET, + 'ns', + { + batchSize: 1, + }, + ); + this.faultsWrite = this.connection.getWriteApi( + INFLUX_ORG, + INFLUX_FAULTS_BUCKET, + 'ns', + { + batchSize: 1, + }, + ); + this.query = this.connection.getQueryApi(INFLUX_ORG); + + // Warn if InfluxDB isn't running + try { + await this.query.collectRows('from(bucket: "_monitoring")'); + } catch (e) { + this.logger.warn( + "InfluxDB doesn't seem to be running", + InfluxService.name, + ); + } + } + + async onModuleInit() { + await this.$connect(); + if (!this.telemetryWrite) { + throw new InfluxServiceError( + 'InfluxDB telemetry write API not initialized', + ); + } + + if (!this.faultsWrite) { + throw new InfluxServiceError('InfluxDB faults write API not initialized'); + } + + if (!this.query) { + throw new InfluxServiceError('InfluxDB query API not initialized'); + } + } + + async onModuleDestroy() { + try { + await this.telemetryWrite.close(); + await this.faultsWrite.close(); + } catch (e) { + throw new InfluxServiceError('Failed to close InfluxDB'); + } + } +} diff --git a/telemetry/packages/server/src/modules/influx/errors/InfluxServiceError.ts b/telemetry/packages/server/src/modules/influx/errors/InfluxServiceError.ts new file mode 100644 index 0000000..9160fe6 --- /dev/null +++ b/telemetry/packages/server/src/modules/influx/errors/InfluxServiceError.ts @@ -0,0 +1,6 @@ +export class InfluxServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'InfluxServiceError'; + } +} diff --git a/telemetry/packages/server/src/modules/live-logs/LiveLogs.gateway.ts b/telemetry/packages/server/src/modules/live-logs/LiveLogs.gateway.ts new file mode 100644 index 0000000..470c546 --- /dev/null +++ b/telemetry/packages/server/src/modules/live-logs/LiveLogs.gateway.ts @@ -0,0 +1,22 @@ +import { + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +@WebSocketGateway({ + path: '/live-logs', + cors: { + origin: '*', + }, +}) +export class LiveLogsGateway { + @WebSocketServer() + socket: Server; + + @SubscribeMessage('send-log') + handleMessage(_client: Socket, payload: unknown) { + this.socket.emit('log', payload); + } +} diff --git a/telemetry/packages/server/src/modules/live-logs/LiveLogsTransport.ts b/telemetry/packages/server/src/modules/live-logs/LiveLogsTransport.ts new file mode 100644 index 0000000..38c1634 --- /dev/null +++ b/telemetry/packages/server/src/modules/live-logs/LiveLogsTransport.ts @@ -0,0 +1,33 @@ +import Transport from 'winston-transport'; +import { io } from 'socket.io-client'; +import { Socket } from 'socket.io'; +import { DefaultEventsMap } from 'socket.io/dist/typed-events'; + +export class LiveLogsTransport extends Transport { + constructor() { + super(); + this.init(); + } + + private socket: Socket; + + // iniitalise the socket connection to the server + init() { + const socket = io('http://localhost:3000', { + path: '/live-logs', + }); + + this.socket = socket as unknown as Socket< + DefaultEventsMap, + DefaultEventsMap + >; + } + + log(info: unknown, callback: () => void) { + setImmediate(() => { + this.socket.emit('send-log', info); + }); + + callback(); + } +} diff --git a/telemetry/packages/server/src/modules/logger/Logger.decorator.ts b/telemetry/packages/server/src/modules/logger/Logger.decorator.ts new file mode 100644 index 0000000..6c049a9 --- /dev/null +++ b/telemetry/packages/server/src/modules/logger/Logger.decorator.ts @@ -0,0 +1,4 @@ +import { Inject } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +export const Logger = () => Inject(WINSTON_MODULE_NEST_PROVIDER); diff --git a/telemetry/packages/server/src/modules/logger/Logger.module.ts b/telemetry/packages/server/src/modules/logger/Logger.module.ts new file mode 100644 index 0000000..b956564 --- /dev/null +++ b/telemetry/packages/server/src/modules/logger/Logger.module.ts @@ -0,0 +1,72 @@ +import { Module } from '@nestjs/common'; +import { utilities, WinstonModule, WinstonModuleOptions } from 'nest-winston'; +import { format, transports } from 'winston'; +import { ENV } from '@/modules/core/config'; +import { LiveLogsTransport } from '../live-logs/LiveLogsTransport'; + +// In top-level 'telemetry' directory +const LOGGING_DIRECTORY = '../../logs'; + +const unhandledErrorFormat = format((info) => { + if (info[Symbol.for('level')] === 'error' && info['error']) { + const error = info['error'] as { + name: string; + message: string; + stack: string; + }; + return { + context: error.name, + message: error.message, + // stack: info['error']['stack'], - not using this for now + level: 'error', + [Symbol.for('level')]: 'error', + }; + } + return info; +}); + +export const loggerOptions: WinstonModuleOptions = { + level: ENV === 'development' ? 'debug' : 'info', + format: format.combine( + unhandledErrorFormat(), + format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss', + }), + format.errors({ stack: false }), + format.splat(), + format.json(), + ), + transports: [ + new LiveLogsTransport(), + new transports.File({ + filename: 'errors.log', + dirname: LOGGING_DIRECTORY, + level: 'error', + }), + new transports.File({ filename: 'all.log', dirname: LOGGING_DIRECTORY }), + ...(ENV === 'development' + ? [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.ms(), + utilities.format.nestLike('Telemetry', { + colors: true, + prettyPrint: true, + }), + ), + handleExceptions: true, + }), + ] + : []), + ], + exitOnError: false, +}; + +const logger = WinstonModule.forRoot(loggerOptions); + +@Module({ + imports: [logger], + exports: [logger], +}) +export class LoggerModule {} diff --git a/telemetry/packages/server/src/modules/measurement/Measurement.module.ts b/telemetry/packages/server/src/modules/measurement/Measurement.module.ts new file mode 100644 index 0000000..d4a9d6a --- /dev/null +++ b/telemetry/packages/server/src/modules/measurement/Measurement.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MeasurementService } from './Measurement.service'; +import { InfluxModule } from '@/modules/influx/Influx.module'; +import { OpenMCTDataModule } from '@/modules/openmct/data/OpenMCTData.module'; +import { FaultModule } from '@/modules/openmct/faults/Fault.module'; + +@Module({ + imports: [InfluxModule, OpenMCTDataModule, FaultModule], + providers: [MeasurementService], + exports: [MeasurementService], +}) +export class MeasurementModule {} diff --git a/telemetry/packages/server/src/modules/measurement/Measurement.service.ts b/telemetry/packages/server/src/modules/measurement/Measurement.service.ts new file mode 100644 index 0000000..452bd09 --- /dev/null +++ b/telemetry/packages/server/src/modules/measurement/Measurement.service.ts @@ -0,0 +1,98 @@ +import { pods } from '@hyped/telemetry-constants'; +import { Point } from '@influxdata/influxdb-client'; +import { Injectable, LoggerService } from '@nestjs/common'; +import { InfluxService } from '@/modules/influx/Influx.service'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { RealtimeTelemetryDataGateway } from '@/modules/openmct/data/realtime/RealtimeTelemetryData.gateway'; +import { + MeasurementReading, + MeasurementReadingSchema, +} from './MeasurementReading.types'; +import { MeasurementReadingValidationError } from './errors/MeasurementReadingValidationError'; +import { doesMeasurementBreachLimits } from './utils/doesMeasurementBreachLimits'; +import { FaultService } from '@/modules/openmct/faults/Fault.service'; + +@Injectable() +export class MeasurementService { + constructor( + @Logger() + private readonly logger: LoggerService, + private influxService: InfluxService, + private realtimeDataGateway: RealtimeTelemetryDataGateway, + private faultService: FaultService, + ) {} + + // This function _is_ ordered in importance + public async addMeasurementReading(props: MeasurementReading) { + const validatedMeasurement = this.validateMeasurementReading(props); + + if (!validatedMeasurement) { + throw new MeasurementReadingValidationError('Invalid measurement'); + } + + const { measurement, reading } = validatedMeasurement; + const { podId, measurementKey, value, timestamp } = reading; + + // First, get the data to the client ASAP + this.realtimeDataGateway.sendMeasurementReading({ + podId, + measurementKey, + value, + timestamp, + }); + + // Then check if it breaches limits + if (measurement.format === 'float' || measurement.format === 'integer') { + const breachLevel = doesMeasurementBreachLimits(measurement, reading); + if (breachLevel) { + this.logger.debug( + `Measurement breached limits {${props.podId}/${props.measurementKey}}: ${breachLevel} with value ${props.value}`, + MeasurementService.name, + ); + await this.faultService.addLimitBreachFault({ + level: breachLevel, + measurement, + tripReading: reading, + }); + } + } + + // Then save it to the database + const point = new Point('measurement') + .timestamp(timestamp) + .tag('podId', podId) + .tag('measurementKey', measurementKey) + .tag('format', measurement.format) + .floatField('value', value); + + try { + this.influxService.telemetryWrite.writePoint(point); + + this.logger.debug( + `Added measurement {${props.podId}/${props.measurementKey}}: ${props.value}`, + MeasurementService.name, + ); + } catch (e: unknown) { + this.logger.error( + `Failed to add measurement {${props.podId}/${props.measurementKey}}: ${props.value}`, + e, + MeasurementService.name, + ); + } + } + + private validateMeasurementReading(props: MeasurementReading) { + const result = MeasurementReadingSchema.safeParse(props); + + if (!result.success) { + throw new MeasurementReadingValidationError(result.error.message); + } + + const { podId, measurementKey } = result.data; + + return { + reading: result.data, + measurement: pods[podId]['measurements'][measurementKey], + }; + } +} diff --git a/telemetry/packages/server/src/modules/measurement/MeasurementReading.types.ts b/telemetry/packages/server/src/modules/measurement/MeasurementReading.types.ts new file mode 100644 index 0000000..826b747 --- /dev/null +++ b/telemetry/packages/server/src/modules/measurement/MeasurementReading.types.ts @@ -0,0 +1,48 @@ +import { pods } from '@hyped/telemetry-constants'; +import { zodEnumFromObjKeys } from '@/modules/common/utils/zodEnumFromObjKeys'; +import { z } from 'zod'; + +export const MeasurementReadingSchema = z + .object({ + podId: zodEnumFromObjKeys(pods), + measurementKey: z.string(), + timestamp: z.string(), // to handle nanoseconds timestamp + value: z.number(), + }) + // Validate measurement exists and enum value is valid (if applicable) + .refine( + ({ podId, measurementKey, value }) => { + const measurement = pods[podId]['measurements'][measurementKey]; + + if (!measurement) { + return false; + } + + // Validate enum values + if (measurement.format === 'enum') { + const enumValue = measurement.enumerations.find( + (e) => e.value === value, + ); + + if (!enumValue) { + return false; + } + } + + // Validate integers and floats + if ( + (measurement.format === 'float' && isNaN(value)) || + (measurement.format === 'integer' && !Number.isInteger(value)) + ) { + return false; + } + + return true; + }, + { + message: + 'Invalid measurement reading - measurement does not exist or invalid enum value', + }, + ); + +export type MeasurementReading = z.infer; diff --git a/telemetry/packages/server/src/modules/measurement/errors/MeasurementReadingValidationError.ts b/telemetry/packages/server/src/modules/measurement/errors/MeasurementReadingValidationError.ts new file mode 100644 index 0000000..afa7811 --- /dev/null +++ b/telemetry/packages/server/src/modules/measurement/errors/MeasurementReadingValidationError.ts @@ -0,0 +1,6 @@ +export class MeasurementReadingValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'MeasurementReadingValidationError'; + } +} diff --git a/telemetry/packages/server/src/modules/measurement/utils/doesMeasurementBreachLimits.ts b/telemetry/packages/server/src/modules/measurement/utils/doesMeasurementBreachLimits.ts new file mode 100644 index 0000000..4645281 --- /dev/null +++ b/telemetry/packages/server/src/modules/measurement/utils/doesMeasurementBreachLimits.ts @@ -0,0 +1,27 @@ +import { MeasurementReading } from '../MeasurementReading.types'; +import { RangeMeasurement } from '@hyped/telemetry-types/dist/pods/pods.types'; +import { FaultLevel } from '@hyped/telemetry-constants'; + +export type DoesMeasurementBreachLimitsReturn = false | FaultLevel; + +export function doesMeasurementBreachLimits( + measurement: RangeMeasurement, + reading: MeasurementReading, +): DoesMeasurementBreachLimitsReturn { + const { value } = reading; + + const { low, high } = measurement.limits.critical; + if (value < low || value > high) { + return 'CRITICAL'; + } + + if (measurement.limits.warning) { + const { low, high } = measurement.limits.warning; + + if (value < low || value > high) { + return 'WARNING'; + } + } + + return false; +} diff --git a/telemetry/packages/server/src/modules/mqtt/client/MqttClientModule.ts b/telemetry/packages/server/src/modules/mqtt/client/MqttClientModule.ts new file mode 100644 index 0000000..578ded4 --- /dev/null +++ b/telemetry/packages/server/src/modules/mqtt/client/MqttClientModule.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MqttModule } from 'nest-mqtt'; +import { MQTT_BROKER_HOST } from 'src/modules/core/config'; + +const mqttClient = MqttModule.forRoot({ + host: MQTT_BROKER_HOST, +}); + +@Module({ + imports: [mqttClient], + exports: [mqttClient], +}) +export class MqttClientModule {} diff --git a/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.module.ts b/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.module.ts new file mode 100644 index 0000000..5486fdc --- /dev/null +++ b/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MqttIngestionService } from './MqttIngestion.service'; +import { MeasurementModule } from 'src/modules/measurement/Measurement.module'; +import { StateModule } from '@/modules/state/State.module'; + +@Module({ + imports: [MeasurementModule, StateModule], + providers: [MqttIngestionService], +}) +export class MqttIngestionModule {} diff --git a/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.service.ts b/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.service.ts new file mode 100644 index 0000000..db456de --- /dev/null +++ b/telemetry/packages/server/src/modules/mqtt/ingestion/MqttIngestion.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { Params, Payload, Subscribe } from 'nest-mqtt'; +import { MeasurementService } from '@/modules/measurement/Measurement.service'; +import { currentTime } from '@influxdata/influxdb-client'; +import { StateService } from '@/modules/state/State.service'; +import { MqttIngestionError } from './errors/MqttIngestionError'; +import { POD_IDS, PodId, PodStateType } from '@hyped/telemetry-constants'; + +@Injectable() +export class MqttIngestionService { + constructor( + private measurementService: MeasurementService, + private stateService: StateService, + ) {} + + @Subscribe('hyped/+/measurement/+') + async getMeasurementReading( + @Params() rawParams: string[], + @Payload() rawValue: number, // TODOLater: check that this is correct + ) { + const timestamp = currentTime.nanos(); + const podId = rawParams[0]; + const measurementKey = rawParams[1]; + const value = rawValue; + + this.validateMqttMessage({ podId, measurementKey, value }); + this.validatePodId(podId); + + await this.measurementService.addMeasurementReading({ + podId, + measurementKey, + value, + timestamp, + }); + } + + @Subscribe('hyped/+/state') + getStateReading( + @Params() rawParams: string[], + @Payload() rawValue: PodStateType, + ) { + const timestamp = currentTime.nanos(); + const podId = rawParams[0]; + const value = rawValue; + + this.validateMqttMessage({ podId, measurementKey: 'state', value }); + this.validatePodId(podId); + + this.stateService.addStateReading({ + podId, + value, + timestamp, + }); + } + + private validateMqttMessage({ + podId, + measurementKey, + value, + }: { + podId: string; + measurementKey: string; + value: unknown; + }) { + if (!podId || !measurementKey || value === undefined) { + throw new MqttIngestionError('Invalid MQTT message'); + } + } + + private validatePodId(podId: string): asserts podId is PodId { + if (!POD_IDS.includes(podId as PodId)) { + throw new MqttIngestionError('Invalid pod ID'); + } + } +} diff --git a/telemetry/packages/server/src/modules/mqtt/ingestion/errors/MqttIngestionError.ts b/telemetry/packages/server/src/modules/mqtt/ingestion/errors/MqttIngestionError.ts new file mode 100644 index 0000000..b0ac0a3 --- /dev/null +++ b/telemetry/packages/server/src/modules/mqtt/ingestion/errors/MqttIngestionError.ts @@ -0,0 +1,6 @@ +export class MqttIngestionError extends Error { + constructor(message: string) { + super(message); + this.name = 'MqttIngestionError'; + } +} diff --git a/telemetry/packages/server/src/modules/openmct/OpenMCT.module.ts b/telemetry/packages/server/src/modules/openmct/OpenMCT.module.ts new file mode 100644 index 0000000..ebce5bb --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/OpenMCT.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DictionaryController } from './dictionary/Dictionary.controller'; +import { DictionaryService } from './dictionary/Dictionary.service'; +import { ObjectTypesController } from './object-types/ObjectTypes.controller'; +import { ObjectTypesService } from './object-types/ObjectTypes.service'; +import { OpenMCTDataModule } from './data/OpenMCTData.module'; + +@Module({ + imports: [OpenMCTDataModule], + controllers: [DictionaryController, ObjectTypesController], + providers: [DictionaryService, ObjectTypesService], +}) +export class OpenMCTModule {} diff --git a/telemetry/packages/server/src/modules/openmct/data/OpenMCTData.module.ts b/telemetry/packages/server/src/modules/openmct/data/OpenMCTData.module.ts new file mode 100644 index 0000000..9bafb13 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/data/OpenMCTData.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HistoricalTelemetryDataController } from './historical/HistoricalTelemetryData.controller'; +import { HistoricalTelemetryDataService } from './historical/HistoricalTelemetryData.service'; +import { InfluxModule } from 'src/modules/influx/Influx.module'; +import { RealtimeTelemetryDataGateway } from './realtime/RealtimeTelemetryData.gateway'; + +@Module({ + imports: [InfluxModule], + controllers: [HistoricalTelemetryDataController], + providers: [HistoricalTelemetryDataService, RealtimeTelemetryDataGateway], + exports: [RealtimeTelemetryDataGateway], +}) +export class OpenMCTDataModule {} diff --git a/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.controller.ts b/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.controller.ts new file mode 100644 index 0000000..94909c7 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { HistoricalTelemetryDataService } from './HistoricalTelemetryData.service'; + +@Controller('openmct/data/historical') +export class HistoricalTelemetryDataController { + constructor(private historicalDataService: HistoricalTelemetryDataService) {} + + @Get('pods/:podId/measurements/:measurementKey') + getHistoricalReading( + @Param('podId') podId: string, + @Param('measurementKey') measurementKey: string, + @Query('start') start: string, + @Query('end') end: string, + ) { + return this.historicalDataService.getHistoricalReading( + podId, + measurementKey, + start, + end, + ); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.service.ts b/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.service.ts new file mode 100644 index 0000000..c2e5d8a --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/data/historical/HistoricalTelemetryData.service.ts @@ -0,0 +1,58 @@ +import { flux, fluxDateTime } from '@influxdata/influxdb-client'; +import { HttpException, Injectable, LoggerService } from '@nestjs/common'; +import { INFLUX_TELEMETRY_BUCKET } from '@/core/config'; +import { InfluxService } from '@/modules/influx/Influx.service'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { InfluxRow } from '@/modules/common/types/InfluxRow'; + +interface InfluxHistoricalRow extends InfluxRow { + measurementKey: string; + format: string; +} + +@Injectable() +export class HistoricalTelemetryDataService { + constructor( + private influxService: InfluxService, + @Logger() + private readonly logger: LoggerService, + ) {} + + public async getHistoricalReading( + podId: string, + measurementKey: string, + startTimestamp: string, + endTimestamp: string, + ) { + const fluxStart = fluxDateTime( + new Date(parseInt(startTimestamp)).toISOString(), + ); + const fluxEnd = fluxDateTime( + new Date(parseInt(endTimestamp)).toISOString(), + ); + + const query = flux` + from(bucket: "${INFLUX_TELEMETRY_BUCKET}") + |> range(start: ${fluxStart}, stop: ${fluxEnd}) + |> filter(fn: (r) => r["measurementKey"] == "${measurementKey}") + |> filter(fn: (r) => r["podId"] == "${podId}")`; + + try { + const data = + await this.influxService.query.collectRows(query); + + return data.map((row) => ({ + id: row['measurementKey'], + timestamp: new Date(row['_time']).getTime(), + value: row['_value'], + })); + } catch (e: unknown) { + this.logger.error( + `Failed to get historical reading for {${podId}/${measurementKey}}`, + e, + HistoricalTelemetryDataService.name, + ); + throw new HttpException("Couldn't get historical reading", 500); + } + } +} diff --git a/telemetry/packages/server/src/modules/openmct/data/realtime/RealtimeTelemetryData.gateway.ts b/telemetry/packages/server/src/modules/openmct/data/realtime/RealtimeTelemetryData.gateway.ts new file mode 100644 index 0000000..7abda4c --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/data/realtime/RealtimeTelemetryData.gateway.ts @@ -0,0 +1,65 @@ +import { Logger } from '@/modules/logger/Logger.decorator'; +import { MeasurementReading } from '@/modules/measurement/MeasurementReading.types'; +import { socket as socketConstants } from '@hyped/telemetry-constants'; +import { LoggerService } from '@nestjs/common'; +import { + ConnectedSocket, + MessageBody, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +@WebSocketGateway({ + path: '/openmct/data/realtime', + cors: { + origin: '*', + }, +}) +export class RealtimeTelemetryDataGateway { + @WebSocketServer() + socket: Server; + + constructor( + @Logger() + private readonly logger: LoggerService, + ) {} + + @SubscribeMessage(socketConstants.EVENTS.SUBSCRIBE_TO_MEASUREMENT) + async subscribeToMeasurement( + @MessageBody() measurementRoom: string, + @ConnectedSocket() client: Socket, + ) { + await client.join(measurementRoom); + } + + @SubscribeMessage(socketConstants.EVENTS.UNSUBSCRIBE_FROM_MEASUREMENT) + async unsubscribeFromMeasurement( + @MessageBody() measurementRoom: string, + @ConnectedSocket() client: Socket, + ) { + await client.leave(measurementRoom); + } + + sendMeasurementReading(props: MeasurementReading) { + const { podId, measurementKey, value, timestamp } = props; + + const measurementRoom = socketConstants.getMeasurementRoomName( + podId, + measurementKey, + ); + this.socket.to(measurementRoom).emit(socketConstants.MEASUREMENT_EVENT, { + podId, + measurementKey, + value, + // convert timestamp from nanoseconds to milliseconds + timestamp: Math.floor(Number(timestamp) / 1e6), + }); + + this.logger.debug( + `Sending realtime ${value} to ${measurementRoom}`, + RealtimeTelemetryDataGateway.name, + ); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.controller.ts b/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.controller.ts new file mode 100644 index 0000000..b96bb1b --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { DictionaryService } from './Dictionary.service'; +import { OpenMctPod } from '@hyped/telemetry-types'; + +@Controller('openmct/dictionary') +export class DictionaryController { + constructor(private dictionaryService: DictionaryService) {} + + @Get('pods') + getPodIds() { + const ids = this.dictionaryService.getPodIds(); + return { + ids, + }; + } + + @Get('pods/:podId') + getPod(@Param('podId') podId: string): OpenMctPod { + return this.dictionaryService.getPod(podId); + } + + @Get('pods/:podId/measurements/:measurementKey') + getMeasurement( + @Param('podId') podId: string, + @Param('measurementKey') measurementKey: string, + ) { + return this.dictionaryService.getMeasurement(podId, measurementKey); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.service.ts b/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.service.ts new file mode 100644 index 0000000..39212c3 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/dictionary/Dictionary.service.ts @@ -0,0 +1,52 @@ +import { POD_IDS, PodId, pods } from '@hyped/telemetry-constants'; +import { OpenMctDictionary, OpenMctPod } from '@hyped/telemetry-types'; +import { Injectable } from '@nestjs/common'; +import { mapMeasurementToOpenMct } from './utils/mapMeasurementToOpenMct'; + +@Injectable() +export class DictionaryService { + getDictionary(): OpenMctDictionary { + const dictionary: OpenMctDictionary = {}; + POD_IDS.forEach((podId) => { + dictionary[podId] = this.getPod(podId); + }); + return dictionary; + } + + getPodIds() { + return POD_IDS; + } + + getPod(podId: string): OpenMctPod { + this.validatePodId(podId); + const pod = pods[podId]; + + const measurements = Object.values(pod.measurements).map((measurement) => + mapMeasurementToOpenMct(measurement), + ); + + return { + name: pod.name, + id: pod.id, + measurements, + }; + } + + getMeasurement(podId: string, measurementKey: string) { + this.validatePodId(podId); + const pod = pods[podId]; + + const measurement = pod.measurements[measurementKey]; + if (!measurement) { + throw new Error(`Measurement ${measurementKey} not found`); + } + + return mapMeasurementToOpenMct(measurement); + } + + private validatePodId(podId: string): asserts podId is PodId { + if (!POD_IDS.includes(podId as PodId)) { + throw new Error(`Pod ${podId} not found`); + } + } +} diff --git a/telemetry/packages/server/src/modules/openmct/dictionary/utils/mapMeasurementToOpenMct.ts b/telemetry/packages/server/src/modules/openmct/dictionary/utils/mapMeasurementToOpenMct.ts new file mode 100644 index 0000000..265db91 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/dictionary/utils/mapMeasurementToOpenMct.ts @@ -0,0 +1,39 @@ +import { Measurement, OpenMctMeasurement } from '@hyped/telemetry-types'; + +export function mapMeasurementToOpenMct( + measurement: Measurement, +): OpenMctMeasurement { + return { + name: measurement.name, + key: measurement.key, + type: measurement.type, + values: [ + { + key: 'value', + name: measurement.name, + unit: measurement.unit, + format: measurement.format, + ...('limits' in measurement && { + min: measurement.limits?.critical.low, + max: measurement.limits?.critical.high, + limits: measurement.limits, + }), + ...('enumerations' in measurement && { + enumerations: measurement.enumerations, + }), + hints: { + range: 1, + }, + }, + { + key: 'utc', + source: 'timestamp', + name: 'Timestamp', + format: 'utc', + hints: { + domain: 1, + }, + }, + ], + }; +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/Fault.controller.ts b/telemetry/packages/server/src/modules/openmct/faults/Fault.controller.ts new file mode 100644 index 0000000..dd38d42 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/Fault.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { FaultService } from './Fault.service'; + +@Controller('openmct/faults') +export class FaultsController { + constructor(private faultsService: FaultService) {} + + @Post('acknowledge') + acknowledgeFault( + @Body() { faultId, comment }: { faultId: string; comment: string }, + ) { + return this.faultsService.acknowledgeFault(faultId, comment); + } + + @Post('shelve') + shelveFault( + @Body() + { + faultId, + shelved, + shelveDuration, + comment, + }: { + faultId: string; + shelved: boolean; + shelveDuration: number; + comment: string; + }, + ) { + return this.faultsService.shelveFault( + faultId, + shelved, + shelveDuration, + comment, + ); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/Fault.module.ts b/telemetry/packages/server/src/modules/openmct/faults/Fault.module.ts new file mode 100644 index 0000000..f04bc4f --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/Fault.module.ts @@ -0,0 +1,19 @@ +import { InfluxModule } from '@/modules/influx/Influx.module'; +import { Module } from '@nestjs/common'; +import { FaultService } from './Fault.service'; +import { HistoricalFaultDataService } from './data/historical/HistoricalFaultData.service'; +import { RealtimeFaultDataGateway } from './data/realtime/RealtimeFaultData.gateway'; +import { HistoricalFaultsDataController } from './data/historical/HistoricalFaultData.controller'; +import { FaultsController } from './Fault.controller'; + +@Module({ + imports: [InfluxModule], + controllers: [FaultsController, HistoricalFaultsDataController], + providers: [ + FaultService, + HistoricalFaultDataService, + RealtimeFaultDataGateway, + ], + exports: [FaultService], +}) +export class FaultModule {} diff --git a/telemetry/packages/server/src/modules/openmct/faults/Fault.service.ts b/telemetry/packages/server/src/modules/openmct/faults/Fault.service.ts new file mode 100644 index 0000000..e8094bb --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/Fault.service.ts @@ -0,0 +1,262 @@ +import { InfluxService } from '@/modules/influx/Influx.service'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { MeasurementReading } from '@/modules/measurement/MeasurementReading.types'; +import { FaultLevel } from '@hyped/telemetry-constants'; +import { OpenMctFault, Unpacked } from '@hyped/telemetry-types'; +import { RangeMeasurement } from '@hyped/telemetry-types/dist/pods/pods.types'; +import { Point } from '@influxdata/influxdb-client'; +import { Injectable, LoggerService } from '@nestjs/common'; +import { HistoricalFaultDataService } from './data/historical/HistoricalFaultData.service'; +import { RealtimeFaultDataGateway } from './data/realtime/RealtimeFaultData.gateway'; +import { convertToOpenMctFault } from './utils/convertToOpenMctFault'; +import { HistoricalFaults } from '@hyped/telemetry-types/dist/openmct/openmct-fault.types'; + +export type Fault = { + level: FaultLevel; + measurement: RangeMeasurement; + tripReading: MeasurementReading; +}; + +@Injectable() +export class FaultService { + constructor( + @Logger() + private readonly logger: LoggerService, + private influxService: InfluxService, + private historicalService: HistoricalFaultDataService, + private realtimeService: RealtimeFaultDataGateway, + ) {} + + /** + * Adds a limit breach fault to the database and sends it to the client. + * @param fault The fault to add + */ + public async addLimitBreachFault(fault: Fault) { + const { measurement, tripReading } = fault; + + const possibleExistingFaults = + await this.historicalService.getHistoricalFaults({ + podId: tripReading.podId, + measurementKey: measurement.key, + }); + + // If there's an existing fault, update it instead of creating a new one + if (possibleExistingFaults && possibleExistingFaults?.length > 0) { + const existingFault = possibleExistingFaults[0]; + this.logger.debug( + `Found existing fault ${existingFault.openMctFault.fault.id}, updating`, + FaultService.name, + ); + this.updateExistingFault(existingFault, tripReading); + return; + } + + const openMctFault = convertToOpenMctFault(fault); + this.saveFault(fault, openMctFault); + this.realtimeService.sendFault(openMctFault); + } + + /** + * Acknowledges a fault in the database and sends it to the client. + * @param faultId The id of the fault to acknowledge + * @param comment The comment sent with the acknowledgement + */ + public async acknowledgeFault(faultId: string, comment?: string) { + this.logger.debug( + `Acknowledging fault with id ${faultId}. ${comment ? `Comment: ${comment}` : ''}`, + FaultService.name, + ); + + // Get the fault from the database + const possibleFault = await this.historicalService.getHistoricalFaults({ + faultId, + }); + + if (!possibleFault || possibleFault.length === 0) { + this.logger.error( + `Fault with id ${faultId} not found`, + FaultService.name, + ); + return; + } + + const fault = possibleFault[0]; + const updatedFault = fault.openMctFault; + updatedFault.fault.acknowledged = true; + + const now = new Date(Date.now()); + + const point = new Point('fault') + .timestamp(now) + .tag('faultId', updatedFault.fault.id) + .tag('podId', fault.podId) + .tag('measurementKey', fault.measurementKey) + .stringField('fault', JSON.stringify(updatedFault)); + + try { + this.influxService.faultsWrite.writePoint(point); + + this.logger.debug( + `Acknowledged fault with id ${updatedFault.fault.id}`, + FaultService.name, + ); + } catch (e: unknown) { + this.logger.error( + `Failed to acknowledge fault with id ${updatedFault.fault.id}`, + e, + FaultService.name, + ); + } + + this.realtimeService.sendFault(updatedFault); + } + + /** + * Shelves or unshelves a fault in the database and sends it to the client. + * @param faultId The id of the fault to shelve + * @param shelved Whether to shelve or unshelve the fault + * @param shelveDuration The duration to shelve the fault for (in milliseconds) + * @param comment The comment sent with the shelve + */ + public async shelveFault( + faultId: string, + shelved: boolean, + shelveDuration: number, + comment?: string, + ) { + this.logger.debug( + `Shelving fault with id ${faultId} for ${shelveDuration} seconds. ${comment ? `Comment: ${comment}` : ''}`, + FaultService.name, + ); + + // Get the fault from the database + const possibleFault = await this.historicalService.getHistoricalFaults({ + faultId, + }); + + if (!possibleFault || possibleFault.length === 0) { + this.logger.error( + `Fault with id ${faultId} not found`, + FaultService.name, + ); + return; + } + + const fault = possibleFault[0]; + const updatedFault = fault.openMctFault; + updatedFault.fault.shelved = shelved; + + const now = new Date(Date.now()); + + const point = new Point('fault') + .timestamp(now) + .tag('faultId', updatedFault.fault.id) + .tag('podId', fault.podId) + .tag('measurementKey', fault.measurementKey) + .stringField('fault', JSON.stringify(updatedFault)); + + // If the fault is being shelved, set a timer to unshelve it + if (shelved) { + this.logger.debug( + `Setting timer to unshelve fault with id ${faultId}`, + FaultService.name, + ); + // This isn't ideal, since this doesn't persist if the server restarts. + // Also, if the fault is manually unshelved before the timer runs out, the timer should be cancelled and it isn't. + // (So the fault will be unshelved twice, once manually and once automatically. Plus, if the fault is unshelved and then shelved again, the timer won't be reset.) + setTimeout(() => { + this.logger.debug( + `Automatically unshelving fault with id ${faultId} because the shelve duration has passed`, + FaultService.name, + ); + void this.shelveFault(faultId, false, 0, ''); + }, shelveDuration); + } + + try { + this.influxService.faultsWrite.writePoint(point); + + this.logger.debug( + `Shelved fault with id ${updatedFault.fault.id}`, + FaultService.name, + ); + } catch (e: unknown) { + this.logger.error( + `Failed to shelve fault with id ${updatedFault.fault.id}`, + e, + FaultService.name, + ); + } + + this.realtimeService.sendFault(updatedFault); + } + + /** + * Saves a fault to the database. + * @param fault The fault to save + * @param openMctFault The Open MCT fault object to save + */ + private saveFault(fault: Fault, openMctFault: OpenMctFault) { + const { measurement, tripReading } = fault; + + const point = new Point('fault') + .timestamp(tripReading.timestamp) + .tag('faultId', openMctFault.fault.id) + .tag('podId', tripReading.podId) + .tag('measurementKey', measurement.key) + // is influx the right choice? probably not - but we're already using it for telemetry + .stringField('fault', JSON.stringify(openMctFault)); + + try { + this.influxService.faultsWrite.writePoint(point); + + this.logger.debug( + `Adding fault with id ${openMctFault.fault.id}`, + FaultService.name, + ); + } catch (e: unknown) { + this.logger.error( + `Failed to add fault {${openMctFault.fault.id}}`, + e, + FaultService.name, + ); + } + } + + /** + * Updates an existing fault in the database. + * @param influxFault The fault to update + * @param updatedReading The updated reading + */ + private updateExistingFault( + influxFault: Unpacked, + updatedReading: MeasurementReading, + ) { + const updatedFault = influxFault.openMctFault; + updatedFault.fault.currentValueInfo.value = updatedReading.value; + + const point = new Point('fault') + .timestamp(updatedReading.timestamp) + .tag('faultId', updatedFault.fault.id) + .tag('podId', updatedReading.podId) + .tag('measurementKey', updatedReading.measurementKey) + .stringField('fault', JSON.stringify(updatedFault)); + + try { + this.influxService.faultsWrite.writePoint(point); + + this.logger.debug( + `Updating fault with id ${updatedFault.fault.id}`, + FaultService.name, + ); + } catch (e: unknown) { + this.logger.error( + `Failed to update fault with id ${updatedFault.fault.id}`, + e, + FaultService.name, + ); + } + + this.realtimeService.sendFault(updatedFault); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.controller.ts b/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.controller.ts new file mode 100644 index 0000000..c7fd240 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { HistoricalFaultDataService } from './HistoricalFaultData.service'; +import { OpenMctHistoricalFaults } from '@hyped/telemetry-types/dist/openmct/openmct-fault.types'; + +@Controller('openmct/faults/historical') +export class HistoricalFaultsDataController { + constructor(private historicalDataService: HistoricalFaultDataService) {} + + @Get() + async getAllFaults(): Promise { + const faults = await this.historicalDataService.getHistoricalFaults({}); + return faults.map((fault) => ({ + timestamp: fault.timestamp, + fault: fault.openMctFault, + })); + } + + @Get('pods/:podId') + async getFaultsForPod( + @Param('podId') podId: string, + ): Promise { + const faults = await this.historicalDataService.getHistoricalFaults({ + podId, + }); + return faults.map((fault) => ({ + timestamp: fault.timestamp, + fault: fault.openMctFault, + })); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.service.ts b/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.service.ts new file mode 100644 index 0000000..19b3f8e --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/data/historical/HistoricalFaultData.service.ts @@ -0,0 +1,75 @@ +import { INFLUX_FAULTS_BUCKET } from '@/core/config'; +import { InfluxRow } from '@/modules/common/types/InfluxRow'; +import { InfluxService } from '@/modules/influx/Influx.service'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { OpenMctFault } from '@hyped/telemetry-types'; +import { HistoricalFaults } from '@hyped/telemetry-types/dist/openmct/openmct-fault.types'; +import { fluxString } from '@influxdata/influxdb-client'; +import { HttpException, Injectable, LoggerService } from '@nestjs/common'; + +interface InfluxFaultRow extends InfluxRow { + faultId: string; + measurementKey: string; + /** + * This is the result of JSON.stringify on an OpenMctFault + */ + openMctFault: string; +} + +type GetHistoricalFaultsInput = { + podId?: string; + measurementKey?: string; + faultId?: string; +}; + +@Injectable() +export class HistoricalFaultDataService { + constructor( + private influxService: InfluxService, + @Logger() + private readonly logger: LoggerService, + ) {} + + public async getHistoricalFaults( + props: GetHistoricalFaultsInput, + ): Promise { + const { podId, measurementKey, faultId } = props; + + const query = `from(bucket: "${INFLUX_FAULTS_BUCKET}") + |> range(start: -24h) + ${podId ? `|> filter(fn: (r) => r["podId"] == ${fluxString(podId) as unknown as string})` : ''} + ${ + measurementKey + ? `|> filter(fn: (r) => r["measurementKey"] == ${ + fluxString(measurementKey) as unknown as string + })` + : '' + } + ${ + faultId + ? `|> filter(fn: (r) => r["faultId"] == ${fluxString(faultId) as unknown as string})` + : '' + } + |> group(columns: ["faultId"]) + |> last()`; + + try { + const data = + await this.influxService.query.collectRows(query); + return data.map((row) => ({ + faultId: row['faultId'], + timestamp: new Date(row['_time']).getTime(), + openMctFault: JSON.parse(row['_value']) as OpenMctFault, + podId: row['podId'], + measurementKey: row['measurementKey'], + })); + } catch (e: unknown) { + this.logger.error( + `Failed to get faults for pod ${podId}`, + e, + HistoricalFaultDataService.name, + ); + throw new HttpException("Couldn't get historical faults", 500); + } + } +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/data/realtime/RealtimeFaultData.gateway.ts b/telemetry/packages/server/src/modules/openmct/faults/data/realtime/RealtimeFaultData.gateway.ts new file mode 100644 index 0000000..7351e76 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/data/realtime/RealtimeFaultData.gateway.ts @@ -0,0 +1,50 @@ +import { Logger } from '@/modules/logger/Logger.decorator'; +import { socket as socketConstants } from '@hyped/telemetry-constants'; +import { OpenMctFault } from '@hyped/telemetry-types'; +import { LoggerService } from '@nestjs/common'; +import { + ConnectedSocket, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +const FAULT_ROOM = 'faults'; + +@WebSocketGateway({ + path: '/openmct/faults/realtime', + cors: { + origin: '*', + }, +}) +export class RealtimeFaultDataGateway { + @WebSocketServer() + socket: Server; + + constructor( + @Logger() + private readonly logger: LoggerService, + ) {} + + @SubscribeMessage(socketConstants.EVENTS.SUBSCRIBE_TO_FAULTS) + async subscribeToFaults(@ConnectedSocket() client: Socket) { + await client.join(FAULT_ROOM); + } + + @SubscribeMessage(socketConstants.EVENTS.UNSUBSCRIBE_FROM_FAULTS) + async unsubscribeFromFaults(@ConnectedSocket() client: Socket) { + await client.leave(FAULT_ROOM); + } + + sendFault(fault: OpenMctFault) { + this.socket.to(FAULT_ROOM).emit(socketConstants.FAULT_EVENT, { + fault, + }); + + this.logger.debug( + `Sending fault with id ${fault.fault.id}`, + RealtimeFaultDataGateway.name, + ); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/faults/utils/convertToOpenMctFault.ts b/telemetry/packages/server/src/modules/openmct/faults/utils/convertToOpenMctFault.ts new file mode 100644 index 0000000..6465c65 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/faults/utils/convertToOpenMctFault.ts @@ -0,0 +1,39 @@ +import { OpenMctFault } from '@hyped/telemetry-types'; +import { nanoid } from 'nanoid'; +import { Fault } from '../Fault.service'; + +/** + * Converts a fault to an Open MCT fault object. + * @param fault The fault to convert + * @returns The Open MCT fault object + */ +export function convertToOpenMctFault(fault: Fault): OpenMctFault { + const { measurement, tripReading, level } = fault; + + const namespace = `/${tripReading.podId}/${measurement.key}`; + + return { + type: 'global-alarm-status', + fault: { + id: `${namespace}-${nanoid()}`, + name: `${measurement.name} is out of range`, + namespace, + seqNum: 0, + severity: level, + shortDescription: '', + shelved: false, + acknowledged: false, + triggerTime: tripReading.timestamp.toString(), + triggerValueInfo: { + value: tripReading.value, + rangeCondition: level, + monitoringResult: level, + }, + currentValueInfo: { + value: tripReading.value, + rangeCondition: level, + monitoringResult: level, + }, + }, + }; +} diff --git a/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.controller.ts b/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.controller.ts new file mode 100644 index 0000000..d95b708 --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { ObjectTypesService as ObjectTypesService } from './ObjectTypes.service'; + +@Controller('openmct/object-types') +export class ObjectTypesController { + constructor(private objectTypesService: ObjectTypesService) {} + + @Get() + getObjectTypes() { + return this.objectTypesService.getObjectTypes(); + } +} diff --git a/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.service.ts b/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.service.ts new file mode 100644 index 0000000..62d900a --- /dev/null +++ b/telemetry/packages/server/src/modules/openmct/object-types/ObjectTypes.service.ts @@ -0,0 +1,14 @@ +import { openMctObjectTypes } from '@hyped/telemetry-constants'; +import { OpenMctObjectTypes } from '@hyped/telemetry-types'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ObjectTypesService { + /** + * Get the object types for Open MCT. + * @returns The object types for Open MCT from the constants. + */ + getObjectTypes(): OpenMctObjectTypes { + return openMctObjectTypes; + } +} diff --git a/telemetry/packages/server/src/modules/public-data/PublicData.controller.ts b/telemetry/packages/server/src/modules/public-data/PublicData.controller.ts new file mode 100644 index 0000000..9856f46 --- /dev/null +++ b/telemetry/packages/server/src/modules/public-data/PublicData.controller.ts @@ -0,0 +1,175 @@ +import { Controller, Get, HttpException, Param, Query } from '@nestjs/common'; +import { PublicDataService } from './PublicData.service'; +import { HistoricalTelemetryDataService } from '@/modules/openmct/data/historical/HistoricalTelemetryData.service'; +import { + LevitationHeightResponse, + RawLevitationHeight, +} from '@hyped/telemetry-types'; +import { + HistoricalValueResponse, + LaunchTimeResponse, + LevitationHeight, + StateResponse, +} from '@hyped/telemetry-types/dist/server/responses'; +import { POD_IDS, PodId } from '@hyped/telemetry-constants'; + +@Controller('pods/:podId/public-data') +export class PublicDataController { + constructor( + private publicDataService: PublicDataService, + private historialTelemetryDataService: HistoricalTelemetryDataService, + ) {} + + @Get('launch-time') + async getLaunchTime( + @Param('podId') podId: string, + ): Promise { + this.validatePodId(podId); + return this.publicDataService.getLaunchTime(podId); + } + + @Get('state') + async getState(@Param('podId') podId: string): Promise { + this.validatePodId(podId); + return this.publicDataService.getState(podId); + } + + @Get('velocity') + async getData( + @Param('podId') podId: string, + @Query('start') startTimestamp: string, + @Query('end') endTimestamp?: string, + ): Promise { + if (!startTimestamp) { + throw new HttpException("Missing 'start' query parameter", 400); + } + this.validatePodId(podId); + return this.historialTelemetryDataService.getHistoricalReading( + podId, + 'velocity', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ); + } + + @Get('displacement') + async getDisplacement( + @Param('podId') podId: string, + @Query('start') startTimestamp: string, + @Query('end') endTimestamp?: string, + ): Promise { + if (!startTimestamp) { + throw new HttpException("Missing 'start' query parameter", 400); + } + this.validatePodId(podId); + return this.historialTelemetryDataService.getHistoricalReading( + podId, + 'displacement', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ); + } + + @Get('acceleration') + async getAcceleration( + @Param('podId') podId: string, + @Query('start') startTimestamp: string, + @Query('end') endTimestamp?: string, + ): Promise { + if (!startTimestamp) { + throw new HttpException("Missing 'start' query parameter", 400); + } + this.validatePodId(podId); + return this.historialTelemetryDataService.getHistoricalReading( + podId, + 'acceleration', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ); + } + + @Get('levitation-height') + async getLevitationHeight( + @Param('podId') podId: string, + @Query('start') startTimestamp: string, + @Query('end') endTimestamp?: string, + ): Promise { + this.validatePodId(podId); + + // TODOLater: this is basically quite bad, but we'll fix it later + const [ + levitation_height_1, + levitation_height_2, + levitation_height_3, + levitation_height_4, + levitation_height_lateral_1, + levitation_height_lateral_2, + ] = await Promise.all([ + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_1', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_2', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_3', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_4', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_lateral_1', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + this.historialTelemetryDataService.getHistoricalReading( + podId, + 'levitation_height_lateral_2', + startTimestamp, + endTimestamp ?? new Date().getTime().toString(), + ), + ]); + + return { + levitation_height_1: this.convertValueToInt(levitation_height_1[0]), + levitation_height_2: this.convertValueToInt(levitation_height_2[0]), + levitation_height_3: this.convertValueToInt(levitation_height_3[0]), + levitation_height_4: this.convertValueToInt(levitation_height_4[0]), + levitation_height_lateral_1: this.convertValueToInt( + levitation_height_lateral_1[0], + ), + levitation_height_lateral_2: this.convertValueToInt( + levitation_height_lateral_2[0], + ), + } satisfies LevitationHeightResponse; + } + + private convertValueToInt( + levitationHeights: RawLevitationHeight, + ): LevitationHeight { + return { + id: levitationHeights.id, + timestamp: levitationHeights.timestamp, + value: parseInt(levitationHeights.value), + }; + } + + private validatePodId(podId: string) { + if (!POD_IDS.includes(podId as PodId)) { + throw new HttpException('Invalid pod ID', 400); + } + } +} diff --git a/telemetry/packages/server/src/modules/public-data/PublicData.module.ts b/telemetry/packages/server/src/modules/public-data/PublicData.module.ts new file mode 100644 index 0000000..fba83cc --- /dev/null +++ b/telemetry/packages/server/src/modules/public-data/PublicData.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PublicDataController } from './PublicData.controller'; +import { PublicDataService } from './PublicData.service'; +import { HistoricalTelemetryDataService } from '@/modules/openmct/data/historical/HistoricalTelemetryData.service'; +import { InfluxModule } from '@/modules/influx/Influx.module'; + +@Module({ + imports: [InfluxModule], + controllers: [PublicDataController], + providers: [PublicDataService, HistoricalTelemetryDataService], +}) +export class PublicDataModule {} diff --git a/telemetry/packages/server/src/modules/public-data/PublicData.service.ts b/telemetry/packages/server/src/modules/public-data/PublicData.service.ts new file mode 100644 index 0000000..fa64bb3 --- /dev/null +++ b/telemetry/packages/server/src/modules/public-data/PublicData.service.ts @@ -0,0 +1,123 @@ +import { HttpException, Injectable, LoggerService } from '@nestjs/common'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { INFLUX_TELEMETRY_BUCKET } from '../core/config'; +import { flux } from '@influxdata/influxdb-client'; +import { InfluxService } from '@/modules/influx/Influx.service'; +import { ACTIVE_STATES } from '@hyped/telemetry-constants'; +import { InfluxRow } from '@/modules/common/types/InfluxRow'; +import { + LaunchTimeResponse, + StateResponse, +} from '@hyped/telemetry-types/dist/server/responses'; + +interface InfluxStateRow extends InfluxRow { + stateType: string; +} + +@Injectable() +export class PublicDataService { + constructor( + @Logger() + private readonly logger: LoggerService, + private influxService: InfluxService, + ) {} + + /** + * Get the current state of a pod. + * @param podId The pod's ID. + * @returns The current state and the previous state of the pod. + */ + public async getState(podId: string): Promise { + // Get the last state reading from InfluxDB (measurement name should be 'state') + const query = flux` + from(bucket: "${INFLUX_TELEMETRY_BUCKET}") + |> range(start: -1d) + |> filter(fn: (r) => r["_measurement"] == "state") + |> filter(fn: (r) => r["podId"] == "${podId}") + |> group() + |> sort(columns: ["_time"], desc: true) + |> limit(n: 2) + `; + + try { + const data = + await this.influxService.query.collectRows(query); + + return { + currentState: data[0] + ? { + state: data[0]['_value'], + timestamp: new Date(data[0]['_time']).getTime(), + stateType: data[0]['stateType'], + } + : null, + previousState: data[1] + ? { + state: data[1]['_value'], + timestamp: new Date(data[1]['_time']).getTime(), + stateType: data[1]['stateType'], + } + : null, + }; + } catch (e: unknown) { + this.logger.error( + `Failed to get historical reading for ${podId}'s state`, + e, + PublicDataService.name, + ); + throw new HttpException("Couldn't get pod's state", 500); + } + } + + /** + * Get the launch time of a pod. + * We consider the launch time to be when the pod's state changes from "READY" to "ACCELERATING", and we must currently be in active state or "STOPPED". + * @param podId The pod's ID. + * @returns The launch time of the pod. + */ + public async getLaunchTime(podId: string): Promise { + const currentState = await this.getState(podId); + + // If the pod is not in an active state or stopped, launch time isn't defined + if ( + currentState.currentState === null || + !( + Object.keys(ACTIVE_STATES).includes(currentState.currentState.state) || + currentState.currentState.state === 'STOPPED' + ) + ) { + return { + launchTime: null, + }; + } + + // Get the last "ACCELERATING" state reading from InfluxDB + const query = flux` + from(bucket: "${INFLUX_TELEMETRY_BUCKET}") + |> range(start: -1d) + |> filter(fn: (r) => r["_measurement"] == "state") + |> filter(fn: (r) => r["podId"] == "${podId}") + |> filter(fn: (r) => r["_value"] == "ACCELERATING") + |> group() + |> sort(columns: ["_time"], desc: true) + |> limit(n: 1) + `; + + try { + const data = + await this.influxService.query.collectRows(query); + const launchTime = new Date(data[0]['_time']).getTime(); + + return { + launchTime, + }; + } catch (e: unknown) { + this.logger.error( + `Failed to get launch time for ${podId}`, + e, + PublicDataService.name, + ); + throw new HttpException("Couldn't get pod's launch time", 500); + } + } +} diff --git a/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.controller.ts b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.controller.ts new file mode 100644 index 0000000..aee85cc --- /dev/null +++ b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { RemoteLogsService } from './RemoteLogs.service'; + +@Controller('logs') +export class RemoteLogsController { + constructor(private remoteLogsService: RemoteLogsService) {} + + @Post() + logUIMessage(@Body() body: { message: string }) { + return this.remoteLogsService.logRemoteMessage(body.message); + } + + @Post(':podId') + logUIMessageWithPodID( + @Param('podId') podId: string, + @Body() body: { message: string }, + ) { + return this.remoteLogsService.logRemoteMessageWithPodID( + podId, + body.message, + ); + } +} diff --git a/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.module.ts b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.module.ts new file mode 100644 index 0000000..97a531b --- /dev/null +++ b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RemoteLogsController } from './RemoteLogs.controller'; +import { RemoteLogsService } from './RemoteLogs.service'; + +@Module({ + controllers: [RemoteLogsController], + providers: [RemoteLogsService], +}) +export class RemoteLogsModule {} diff --git a/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.service.ts b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.service.ts new file mode 100644 index 0000000..bafd890 --- /dev/null +++ b/telemetry/packages/server/src/modules/remote-logs/RemoteLogs.service.ts @@ -0,0 +1,34 @@ +import { Injectable, LoggerService } from '@nestjs/common'; +import { Logger } from '@/modules/logger/Logger.decorator'; + +@Injectable() +export class RemoteLogsService { + constructor( + @Logger() + private readonly logger: LoggerService, + ) {} + + /** + * Logs a message from the GUI, which is not associated with a particular pod. + * @param message The message from the GUI to log + * @returns True if the message was logged successfully, false otherwise + */ + logRemoteMessage(message: string) { + this.logger.verbose(`[GUI] ${message}`, RemoteLogsService.name); + return true; + } + + /** + * Logs a message from the GUI, which is associated with a particular pod. + * @param podId The ID of the pod + * @param message The message from the GUI to log + * @returns True if the message was logged successfully, false otherwise + */ + logRemoteMessageWithPodID(podId: string, message: string) { + this.logger.verbose( + `[GUI - Pod ${podId}] ${message}`, + RemoteLogsService.name, + ); + return true; + } +} diff --git a/telemetry/packages/server/src/modules/state/State.module.ts b/telemetry/packages/server/src/modules/state/State.module.ts new file mode 100644 index 0000000..5e7ba54 --- /dev/null +++ b/telemetry/packages/server/src/modules/state/State.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StateService } from './State.service'; +import { InfluxModule } from '../influx/Influx.module'; + +@Module({ + imports: [InfluxModule], + providers: [StateService], + exports: [StateService], +}) +export class StateModule {} diff --git a/telemetry/packages/server/src/modules/state/State.service.ts b/telemetry/packages/server/src/modules/state/State.service.ts new file mode 100644 index 0000000..2b2eb82 --- /dev/null +++ b/telemetry/packages/server/src/modules/state/State.service.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { Logger } from '@/modules/logger/Logger.decorator'; +import { StateUpdate, StateUpdateSchema } from './StateUpdate.types'; +import { StateUpdateValidationError } from './errors/MeasurementReadingValidationError'; +import { Point } from '@influxdata/influxdb-client'; +import { InfluxService } from '@/modules/influx/Influx.service'; +import { getStateType } from '@hyped/telemetry-constants'; +import { MqttService } from 'nest-mqtt'; + +@Injectable() +export class StateService { + constructor( + @Logger() + private readonly logger: LoggerService, + private influxService: InfluxService, + @Inject(MqttService) private readonly mqttService: MqttService, + ) {} + + public addStateReading(props: StateUpdate) { + const validatedState = this.validateStateUpdate(props); + + const { podId, value: state, timestamp } = validatedState; + const stateType = getStateType(state); + + // If we want to add state to the OpenMCT dashboard, we can do it here + + // Then save it to the database + const point = new Point('state') + .timestamp(timestamp) + .tag('podId', podId) + .tag('stateType', stateType) + .stringField('state', state); + + try { + this.influxService.telemetryWrite.writePoint(point); + + this.logger.debug( + `Added state ${props.podId}: ${props.value} (Type: ${stateType})`, + StateService.name, + ); + } catch (e) { + this.logger.error( + `Failed to add state ${props.podId}: ${props.value} (Type: ${stateType})`, + e, + StateService.name, + ); + } + } + + /** + * Validates a state update. + * @param props The state update to validate + * @returns The validated state update, or throws an error if invalid + */ + private validateStateUpdate(props: StateUpdate) { + const result = StateUpdateSchema.safeParse(props); + + if (!result.success) { + throw new StateUpdateValidationError(result.error.message); + } + + return result.data; + } +} diff --git a/telemetry/packages/server/src/modules/state/StateUpdate.types.ts b/telemetry/packages/server/src/modules/state/StateUpdate.types.ts new file mode 100644 index 0000000..7f7ac72 --- /dev/null +++ b/telemetry/packages/server/src/modules/state/StateUpdate.types.ts @@ -0,0 +1,11 @@ +import { ALL_POD_STATES, pods } from '@hyped/telemetry-constants'; +import { zodEnumFromObjKeys } from '@/modules/common/utils/zodEnumFromObjKeys'; +import { z } from 'zod'; + +export const StateUpdateSchema = z.object({ + podId: zodEnumFromObjKeys(pods), + timestamp: z.string(), // to handle nanoseconds timestamp + value: zodEnumFromObjKeys(ALL_POD_STATES), +}); + +export type StateUpdate = z.infer; diff --git a/telemetry/packages/server/src/modules/state/errors/MeasurementReadingValidationError.ts b/telemetry/packages/server/src/modules/state/errors/MeasurementReadingValidationError.ts new file mode 100644 index 0000000..1bf933c --- /dev/null +++ b/telemetry/packages/server/src/modules/state/errors/MeasurementReadingValidationError.ts @@ -0,0 +1,6 @@ +export class StateUpdateValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'StateUpdateValidationError'; + } +} diff --git a/telemetry/packages/server/src/modules/state/utils/isValidState.ts b/telemetry/packages/server/src/modules/state/utils/isValidState.ts new file mode 100644 index 0000000..a5d88ea --- /dev/null +++ b/telemetry/packages/server/src/modules/state/utils/isValidState.ts @@ -0,0 +1,5 @@ +import { ALL_POD_STATES } from '@hyped/telemetry-constants'; + +export const isValidState = (state: string) => { + return ALL_POD_STATES[state as keyof typeof ALL_POD_STATES] !== undefined; +}; diff --git a/telemetry/packages/server/src/modules/warnings/Warnings.controller.ts b/telemetry/packages/server/src/modules/warnings/Warnings.controller.ts new file mode 100644 index 0000000..4d6d102 --- /dev/null +++ b/telemetry/packages/server/src/modules/warnings/Warnings.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Param, Post } from '@nestjs/common'; +import { WarningsService } from './Warnings.service'; + +@Controller('pods/:podId/warnings') +export class WarningsController { + constructor(private warningsService: WarningsService) {} + + @Post('latency') + createLatencyWarning(@Param('podId') podId: string) { + this.warningsService.createLatencyWarning(podId); + } +} diff --git a/telemetry/packages/server/src/modules/warnings/Warnings.module.ts b/telemetry/packages/server/src/modules/warnings/Warnings.module.ts new file mode 100644 index 0000000..d4678ba --- /dev/null +++ b/telemetry/packages/server/src/modules/warnings/Warnings.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { WarningsController } from './Warnings.controller'; +import { WarningsService } from './Warnings.service'; + +@Module({ + controllers: [WarningsController], + providers: [WarningsService], +}) +export class WarningsModule {} diff --git a/telemetry/packages/server/src/modules/warnings/Warnings.service.ts b/telemetry/packages/server/src/modules/warnings/Warnings.service.ts new file mode 100644 index 0000000..731fc8d --- /dev/null +++ b/telemetry/packages/server/src/modules/warnings/Warnings.service.ts @@ -0,0 +1,17 @@ +import { Injectable, LoggerService } from '@nestjs/common'; +import { Logger } from '@/modules/logger/Logger.decorator'; + +@Injectable() +export class WarningsService { + constructor( + @Logger() + private readonly logger: LoggerService, + ) {} + + createLatencyWarning(podId: string) { + this.logger.log( + `Creating latency warning for pod ${podId}`, + WarningsService.name, + ); + } +} diff --git a/telemetry/packages/server/test/app.e2e-spec.ts b/telemetry/packages/server/test/app.e2e-spec.ts new file mode 100644 index 0000000..50cda62 --- /dev/null +++ b/telemetry/packages/server/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/telemetry/packages/server/test/jest-e2e.json b/telemetry/packages/server/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/telemetry/packages/server/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/telemetry/packages/server/tsconfig.json b/telemetry/packages/server/tsconfig.json new file mode 100644 index 0000000..795f529 --- /dev/null +++ b/telemetry/packages/server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "module": "commonjs", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "es2017", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "noEmit": false, + "strictBindCallApply": true, + "strictPropertyInitialization": false, + "paths": { + "@/modules/*": ["src/modules/*"], + "@/core/*": ["src/modules/core/*"], + }, + }, + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"], +} diff --git a/telemetry/packages/tsconfig/base.json b/telemetry/packages/tsconfig/base.json new file mode 100644 index 0000000..e908bb2 --- /dev/null +++ b/telemetry/packages/tsconfig/base.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Base", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "module": "ESNext", + "moduleResolution": "node", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "strictNullChecks": true, + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "incremental": true + }, + "exclude": ["node_modules"] +} diff --git a/telemetry/packages/tsconfig/package.json b/telemetry/packages/tsconfig/package.json new file mode 100644 index 0000000..8e9c1a3 --- /dev/null +++ b/telemetry/packages/tsconfig/package.json @@ -0,0 +1,5 @@ +{ + "name": "@hyped/tsconfig", + "version": "0.0.0", + "private": true +} diff --git a/telemetry/packages/types/.eslintrc.js b/telemetry/packages/types/.eslintrc.js new file mode 100644 index 0000000..acab2de --- /dev/null +++ b/telemetry/packages/types/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@hyped/eslint-config/basic.js'], + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +}; diff --git a/telemetry/packages/types/package.json b/telemetry/packages/types/package.json new file mode 100644 index 0000000..d6cb13e --- /dev/null +++ b/telemetry/packages/types/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hyped/telemetry-types", + "private": true, + "version": "0.0.0", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "lint": "eslint --ext .ts src --max-warnings 0 --report-unused-disable-directives", + "lint:fix": "eslint --ext .ts src --fix" + }, + "dependencies": { + "@hyped/eslint-config": "workspace:*", + "@hyped/tsconfig": "workspace:*", + "typescript": "^5.3.3" + } +} diff --git a/telemetry/packages/types/src/index.ts b/telemetry/packages/types/src/index.ts new file mode 100644 index 0000000..1d82c6e --- /dev/null +++ b/telemetry/packages/types/src/index.ts @@ -0,0 +1,25 @@ +export type { + Measurement, + RangeMeasurement, + Limits, + Pod, +} from './pods/pods.types'; +export type { + OpenMctDictionary, + OpenMctPod, + OpenMctMeasurement, +} from './openmct/openmct-dictionary.types'; +export type { + OpenMctObjectTypes, + OpenMctObjectType, +} from './openmct/openmct-object-types.types'; +export type { OpenMctFault } from './openmct/openmct-fault.types'; +export type { Unpacked } from './utils/Unpacked'; +export type { + RawLevitationHeight, + LevitationHeight, + LevitationHeightResponse, + LaunchTimeResponse, + StateResponse, + HistoricalValueResponse, +} from './server/responses'; diff --git a/telemetry/packages/types/src/openmct/openmct-dictionary.types.ts b/telemetry/packages/types/src/openmct/openmct-dictionary.types.ts new file mode 100644 index 0000000..f3f1000 --- /dev/null +++ b/telemetry/packages/types/src/openmct/openmct-dictionary.types.ts @@ -0,0 +1,45 @@ +import { Limits } from '../pods/pods.types'; + +/** + * Type of an Open MCT measurement. + */ +export type OpenMctMeasurement = { + name: string; + key: string; + type: string; + values: { + key: string; + name: string; + unit?: string; + format: string; + min?: number; + max?: number; + limits?: Limits; + enumerations?: { + value: number; + string: string; + }[]; + hints?: { + range?: number; + domain?: number; + }; + source?: string; + units?: { + domain: string; + }; + }[]; +}; + +/** + * Type of an Open MCT pod. + */ +export type OpenMctPod = { + id: string; + name: string; + measurements: OpenMctMeasurement[]; +}; + +/** + * Type of an Open MCT dictionary. + */ +export type OpenMctDictionary = Record; diff --git a/telemetry/packages/types/src/openmct/openmct-fault.types.ts b/telemetry/packages/types/src/openmct/openmct-fault.types.ts new file mode 100644 index 0000000..a072a2e --- /dev/null +++ b/telemetry/packages/types/src/openmct/openmct-fault.types.ts @@ -0,0 +1,37 @@ +export type OpenMctFault = { + type: null | string; + fault: { + acknowledged: boolean; + currentValueInfo: { + value: number; + rangeCondition: string; + monitoringResult: string; + }; + id: string; + name: string; + namespace: string; + seqNum: number; + severity: string; + shelved: boolean; + shortDescription: string; + triggerTime: string; + triggerValueInfo: { + value: number; + rangeCondition: string; + monitoringResult: string; + }; + }; +}; + +export type HistoricalFaults = { + faultId: string; + timestamp: number; + openMctFault: OpenMctFault; + podId: string; + measurementKey: string; +}[]; + +export type OpenMctHistoricalFaults = { + timestamp: number; + fault: OpenMctFault; +}[]; diff --git a/telemetry/packages/types/src/openmct/openmct-object-types.types.ts b/telemetry/packages/types/src/openmct/openmct-object-types.types.ts new file mode 100644 index 0000000..864852b --- /dev/null +++ b/telemetry/packages/types/src/openmct/openmct-object-types.types.ts @@ -0,0 +1,8 @@ +export type OpenMctObjectType = { + id: string; + name: string; + description?: string; + icon: string; +}; + +export type OpenMctObjectTypes = OpenMctObjectType[]; diff --git a/telemetry/packages/types/src/pods/pods.types.ts b/telemetry/packages/types/src/pods/pods.types.ts new file mode 100644 index 0000000..65a754e --- /dev/null +++ b/telemetry/packages/types/src/pods/pods.types.ts @@ -0,0 +1,49 @@ +// common properties shared by all response variables +export type BaseMeasurement = { + name: string; + key: string; + unit: string; + type: string; +}; + +// range limits not to be exceeded +// some give warnings when reaching range limits +export type Limits = { + warning?: { + low: number; + high: number; + }; + critical: { + low: number; + high: number; + }; +}; + +// For numerical sensor readings described by operational range sampling parameters +export type RangeMeasurement = BaseMeasurement & { + format: 'float' | 'integer'; + limits: Limits; + rms_noise: number; + sampling_time: number; +}; + +// For discrete status measurements with enumerated states +export type EnumMeasurement = BaseMeasurement & { + format: 'enum'; + enumerations: { + value: number; + string: string; + }[]; +}; + +// export type Measurement as union +export type Measurement = RangeMeasurement | EnumMeasurement; + +// create Pod type +export type Pod = { + name: string; + id: string; + measurements: Record; + // Not ideal given this is defined in the constants package but will do until TOML is done + operationMode: 'ALL_SYSTEMS_ON' | 'LEVITATION_ONLY' | 'LIM_ONLY'; +}; diff --git a/telemetry/packages/types/src/server/responses.ts b/telemetry/packages/types/src/server/responses.ts new file mode 100644 index 0000000..0342224 --- /dev/null +++ b/telemetry/packages/types/src/server/responses.ts @@ -0,0 +1,39 @@ +export type RawLevitationHeight = { + id: string; + timestamp: number; + value: string; +}; + +export type LevitationHeight = { + id: string; + timestamp: number; + value: number; +}; + +export type LevitationHeightResponse = Record; + +export type LaunchTimeResponse = { + launchTime: number | null; +}; + +export type StateResponse = { + currentState: { + state: string; + timestamp: number; + stateType: string; + } | null; + previousState: { + state: string; + timestamp: number; + stateType: string; + } | null; +}; + +export type HistoricalValueResponse = { + id: string; + timestamp: number; + value: string; +}[]; + +export type VelocityResponse = HistoricalValueResponse; +export type DisplacementResponse = HistoricalValueResponse; diff --git a/telemetry/packages/types/src/utils/Unpacked.ts b/telemetry/packages/types/src/utils/Unpacked.ts new file mode 100644 index 0000000..2476404 --- /dev/null +++ b/telemetry/packages/types/src/utils/Unpacked.ts @@ -0,0 +1 @@ +export type Unpacked = T extends (infer U)[] ? U : T; diff --git a/telemetry/packages/types/tsconfig.json b/telemetry/packages/types/tsconfig.json new file mode 100644 index 0000000..9edc652 --- /dev/null +++ b/telemetry/packages/types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@hyped/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "noEmit": false, + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/telemetry/packages/ui/.env.docker b/telemetry/packages/ui/.env.docker new file mode 100644 index 0000000..a3f8b2b --- /dev/null +++ b/telemetry/packages/ui/.env.docker @@ -0,0 +1,9 @@ +VITE_SERVER_ENDPOINT=http://localhost:3000 +VITE_MQTT_BROKER=ws://localhost:8080 +VITE_MQTT_QOS=0 # one of 0, 1, 2 +HOST='0.0.0.0' + +# Optional +VITE_DISABLE_POD_DISCONNECTED_ERROR=true # default: false +VITE_HTTP_DEBUG=false # default: true +VITE_EXTENDED_DEBUGGING_TOOLS=false # default: false diff --git a/telemetry/packages/ui/.env.example b/telemetry/packages/ui/.env.example new file mode 100644 index 0000000..221c7c0 --- /dev/null +++ b/telemetry/packages/ui/.env.example @@ -0,0 +1,9 @@ +VITE_SERVER_ENDPOINT=http://localhost:3000 +VITE_MQTT_BROKER=ws://localhost:8080 +VITE_MQTT_QOS=0 # one of 0, 1, 2 +HOST= # for docker + +# Optional +VITE_DISABLE_POD_DISCONNECTED_ERROR=false # default: false +VITE_HTTP_DEBUG=true # default: true +VITE_EXTENDED_DEBUGGING_TOOLS=false # default: false diff --git a/telemetry/packages/ui/.eslintrc.cjs b/telemetry/packages/ui/.eslintrc.cjs new file mode 100644 index 0000000..c2dc8a4 --- /dev/null +++ b/telemetry/packages/ui/.eslintrc.cjs @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@hyped/eslint-config/react.js'], + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, + ignorePatterns: ['vite.config.ts', 'app/components/ui/**'], +}; diff --git a/telemetry/packages/ui/.gitignore b/telemetry/packages/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/telemetry/packages/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/telemetry/packages/ui/app/App.tsx b/telemetry/packages/ui/app/App.tsx new file mode 100644 index 0000000..54fe271 --- /dev/null +++ b/telemetry/packages/ui/app/App.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { VIEWS, VIEW_KEYS, VIEW_OPTIONS, ViewOption } from './views'; +import { Sidebar } from './components/sidebar'; +import { cn } from './lib/utils'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/components/ui/resizable'; + +/** + * The default view to display when the app is opened. + */ +const DEFAULT_VIEW: ViewOption = VIEW_OPTIONS.OPEN_MCT; + +export const App = () => { + const [currentView, setCurrentView] = useState(DEFAULT_VIEW); + + return ( + + + {VIEW_KEYS.map((key) => ( +
          + {VIEWS[key].component} +
          + ))} +
          + + + + +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/error.tsx b/telemetry/packages/ui/app/components/error.tsx new file mode 100644 index 0000000..c355d9d --- /dev/null +++ b/telemetry/packages/ui/app/components/error.tsx @@ -0,0 +1,95 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useErrors } from '@/context/errors'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; +import { DialogHeader } from './ui/dialog'; +import { Button } from './ui/button'; +import { CONTROLS, sendControlMessage } from '@/lib/controls'; +import { useCurrentPod } from '@/context/pods'; + +/** + * Dialog to display error messages from the error context. + */ +export const Error = () => { + const { errors } = useErrors(); + const [index, setIndex] = useState(0); + + const { currentPod } = useCurrentPod(); + + const open = errors.length > 0; + + return ( + + + {/* Error message */} + {errors[index] && ( + <> + + {errors[index].title} + + + {errors[index].message} + + + )} + + {errors[index] && ( + +
          + {/* Arrows for navigating between errors */} +
          + +

          + {index + 1} / {errors.length} +

          + +
          +
          + {/* Emergency Stop Button */} + + {/* Acknowledge button */} + { + errors[index].acknowledge(); + setIndex((i) => (i > 0 ? i - 1 : 0)); + }} + > + Acknowledge + +
          +
          +
          + )} +
          +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/shared/latency-chart.tsx b/telemetry/packages/ui/app/components/shared/latency-chart.tsx new file mode 100644 index 0000000..c0efc46 --- /dev/null +++ b/telemetry/packages/ui/app/components/shared/latency-chart.tsx @@ -0,0 +1,45 @@ +import { PreviousLatenciesType } from '@/context/pods'; +import { LineChart } from '@tremor/react'; + +/** + * Wrapper around LineChart to display latency data. + * @param data The latency values to display, possibly undefined. + * @param minValue The minimum value to display on the y-axis. + * @param maxValue The maximum value to display on the y-axis. + * @returns A LineChart component. + */ +export const LatencyChart = ({ + data, + minValue = 0, + maxValue = 100, +}: { + data: PreviousLatenciesType | undefined; + minValue?: number; + maxValue?: number; +}) => { + return ( +
          + {data && data.length > 0 ? ( + + ) : ( +

          No latencies to show

          + )} +
          + ); +}; + +const dataFormatter = (number: number) => `${number.toString()}ms`; diff --git a/telemetry/packages/ui/app/components/shared/logo.tsx b/telemetry/packages/ui/app/components/shared/logo.tsx new file mode 100644 index 0000000..c04825e --- /dev/null +++ b/telemetry/packages/ui/app/components/shared/logo.tsx @@ -0,0 +1,30 @@ +import { SVGProps } from 'react'; + +/** + * The HYPED logo as an SVG. + * @param props (Optional) props to pass to the SVG. + * @returns An SVG component. + */ +export const Logo = (props: SVGProps) => ( + + + + + +); diff --git a/telemetry/packages/ui/app/components/shared/pod-state.tsx b/telemetry/packages/ui/app/components/shared/pod-state.tsx new file mode 100644 index 0000000..33f66d5 --- /dev/null +++ b/telemetry/packages/ui/app/components/shared/pod-state.tsx @@ -0,0 +1,60 @@ +import { usePod } from '@/context/pods'; +import { cn } from '@/lib/utils'; +import { + ACTIVE_STATES, + FAILURE_STATES, + NULL_STATES, + PASSIVE_STATES, + PodStateCategoryType, +} from '@hyped/telemetry-constants'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { CircleDashed } from 'lucide-react'; + +/** + * Defines the styling for each pod state. + */ +export const styles: Record = { + ACTIVE: 'bg-green-700 border-2 border-green-900 text-white', + FAILURE: 'bg-red-700 border-2 border-red-900 text-white', + PASSIVE: 'bg-gray-600 border-2 border-gray-800 text-white', + NULL: '', +}; + +/** + * Displays the current state of a pod. + * @param podId The ID of the pod to display the state of. + * @returns A Card component displaying the pod state. + */ +export const PodState = ({ podId }: { podId: string }) => { + const { podState: state, name } = usePod(podId); + + return ( + + + + Pod State + + The current state of {name} + + +

          + {state} +

          +
          +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/shared/set-levitation-height.tsx b/telemetry/packages/ui/app/components/shared/set-levitation-height.tsx new file mode 100644 index 0000000..84bdde8 --- /dev/null +++ b/telemetry/packages/ui/app/components/shared/set-levitation-height.tsx @@ -0,0 +1,44 @@ +import { log } from '@/lib/logger'; +import { http } from 'openmct/core/http'; +import { useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +/** + * Sets the levitation height of a pod. + * @param podId The ID of the pod to set the levitation height of. + * @returns A component to set the levitation height of a pod. + */ +export const SetLevitationHeight = ({ podId }: { podId: string }) => { + const [height, setHeight] = useState(null); + + const setLevigationHeight = async () => { + // Don't do anything if the height is invalid + if (!height || height < 0) return; + log(`Setting the levitation height of ${podId} to ${height}mm`); + const url = `pods/${podId}/controls/levitation-height?height=${height}`; + await http.post(url); + // Clear the input + setHeight(null); + }; + + return ( +
          +

          Set levitation height (mm):

          +
          + setHeight(Number(e.target.value))} + /> + +
          +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/sidebar/index.tsx b/telemetry/packages/ui/app/components/sidebar/index.tsx new file mode 100644 index 0000000..0c634f2 --- /dev/null +++ b/telemetry/packages/ui/app/components/sidebar/index.tsx @@ -0,0 +1,81 @@ +import { VIEWS, VIEW_KEYS, ViewOption } from '@/views'; +import { log } from '@/lib/logger'; +import { cn } from '@/lib/utils'; +import { POD_IDS } from '@hyped/telemetry-constants'; +import { useEffect } from 'react'; +import toast from 'react-hot-toast'; +import { useCurrentPod } from '@/context/pods'; +import { Latency } from './latency'; +import { PodControls } from './pod-controls'; +import { PodConnectionStatus } from './pod-connection-status'; +import { Logo } from '@/components/shared/logo'; +import { PodSelector } from './pod-selector'; + +/** + * The custom sidebar for the GUI which allows us to select a pod, control it, view its connection status, and change the view. + * AKA the "Controls UI" + */ +export const Sidebar = ({ + currentView, + setCurrentView, +}: { + currentView: ViewOption; + setCurrentView: React.Dispatch>; +}) => { + const { + currentPod, + pod: { podState }, + } = useCurrentPod(); + + // Display notification when the pod state changes + useEffect( + function notifyPodStateChanges() { + toast(`Pod state changed: ${podState}`); + log(`Pod state changed: ${podState}`, currentPod); + }, + [podState, currentPod], + ); + + return ( +
          +
          + + {/* Status, Latency, State, Title */} +
          +

          Connection to pod

          + + +
          +
          +

          Controls

          + {POD_IDS.map((podId) => ( + + ))} +
          +
          +

          View

          +
          + {VIEW_KEYS.map((key) => ( + + ))} +
          +
          +
          + +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/sidebar/latency.tsx b/telemetry/packages/ui/app/components/sidebar/latency.tsx new file mode 100644 index 0000000..a69add7 --- /dev/null +++ b/telemetry/packages/ui/app/components/sidebar/latency.tsx @@ -0,0 +1,51 @@ +import { usePod } from '@/context/pods'; +import { POD_CONNECTION_STATUS } from '@/types/PodConnectionStatus'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { LatencyChart } from '../shared/latency-chart'; +import { cn } from '@/lib/utils'; + +/** + * Displays the latency between the base station (GUI) and the pod. + */ +export const Latency = ({ podId }: { podId: string }) => { + const { latency, connectionStatus, previousLatencies } = usePod(podId); + + return ( + + + + {connectionStatus === POD_CONNECTION_STATUS.CONNECTED && latency ? ( +

          + Latency: + 50 && 'text-orange-500', + latency > 100 && 'text-red-500', + )} + > + {latency} ms + +

          + ) : ( +

          + Latency: N/A +

          + )} +
          + + + +
          +
          + ); +}; diff --git a/telemetry/packages/ui/app/components/sidebar/pod-connection-status.tsx b/telemetry/packages/ui/app/components/sidebar/pod-connection-status.tsx new file mode 100644 index 0000000..2ac56f8 --- /dev/null +++ b/telemetry/packages/ui/app/components/sidebar/pod-connection-status.tsx @@ -0,0 +1,87 @@ +import { usePod } from '@/context/pods'; +import { cn } from '@/lib/utils'; +import { POD_CONNECTION_STATUS } from '@/types/PodConnectionStatus'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useState, useEffect } from 'react'; + +/** + * Displays the connection status of a pod to the base station (GUI). + * @param podId The ID of the pod + */ +export const PodConnectionStatus = ({ podId }: { podId: string }) => { + const { connectionStatus, connectionEstablished } = usePod(podId); + + const [uptime, setUptime] = useState(0); + + // Update the uptime every second + useEffect(() => { + if (connectionStatus !== POD_CONNECTION_STATUS.CONNECTED) { + setUptime(0); + return; + } + const interval = setInterval(() => { + setUptime((uptime) => uptime + 1); + }, 1000); + return () => clearInterval(interval); + }, [connectionStatus]); + + const CONNECTED = connectionStatus === POD_CONNECTION_STATUS.CONNECTED; + const UNKNOWN = connectionStatus === POD_CONNECTION_STATUS.UNKNOWN; + const DISCONNECTED = connectionStatus === POD_CONNECTION_STATUS.DISCONNECTED; + const ERROR = connectionStatus === POD_CONNECTION_STATUS.ERROR; + + return ( + + + +
          +
          +

          + {connectionStatus} +

          +
          + + + {/* */} +

          + Status: {connectionStatus} +

          +

          + GUI connection uptime:{' '} + {/* uptime in s then x mins, y secs etc. */} + {Math.floor(uptime / 60)} mins {uptime % 60} secs +

          +

          + GUI connection established:{' '} + {/* connection time in mm:hh:ss */} + {connectionEstablished + ? connectionEstablished.toLocaleTimeString() + : 'N/A'} +

          +
          + + + ); +}; diff --git a/telemetry/packages/ui/app/components/sidebar/pod-controls.tsx b/telemetry/packages/ui/app/components/sidebar/pod-controls.tsx new file mode 100644 index 0000000..ef5f100 --- /dev/null +++ b/telemetry/packages/ui/app/components/sidebar/pod-controls.tsx @@ -0,0 +1,92 @@ +import { Button } from '@/components/ui/button'; +import { CONTROLS, sendControlMessage } from '@/lib/controls'; +import { cn } from '@/lib/utils'; +import { ArrowUpFromLine, Rocket, Siren } from 'lucide-react'; +import { SetLevitationHeight } from '../shared/set-levitation-height'; +import { useState } from 'react'; +import { Switch } from '../ui/switch'; +import { Label } from '@radix-ui/react-label'; + +/** + * Displays the pod controls. + * @param podId The ID of the pod to display the controls of. + * @param show Whether or not to show the controls. This is used to keep the state of the controls when the podId changes (rather than unmounting and remounting the component). + * @returns The pod controls. + */ +export const PodControls = ({ + podId, + show, +}: { + podId: string; + show: boolean; +}) => { + return ( +
          +
          +
          + + +
          + + +
          +
          + ); +}; + +const LevitateButton = ({ podId }: { podId: string }) => ( + +); + +const LaunchButton = ({ podId }: { podId: string }) => { + const [enabled, setEnabled] = useState(false); + + return ( +
          + {/* This switch is used to enable the launch button */} +
          + + +
          + +
          + ); +}; + +export const EmergencyStopButton = ({ + podId, + className, +}: { + podId: string; + className?: string; +}) => ( + +); diff --git a/telemetry/packages/ui/app/components/sidebar/pod-selector.tsx b/telemetry/packages/ui/app/components/sidebar/pod-selector.tsx new file mode 100644 index 0000000..b49b9ee --- /dev/null +++ b/telemetry/packages/ui/app/components/sidebar/pod-selector.tsx @@ -0,0 +1,61 @@ +import { POD_IDS, PodId, pods } from '@hyped/telemetry-constants'; +import { Label } from '../ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { useCurrentPod } from '@/context/pods'; + +/** + * A pod selector component which allows us to select a pod to view/control. + */ +export const PodSelector = () => { + const { currentPod, setCurrentPod } = useCurrentPod(); + + return ( +
          + + +
          + ); +}; + +/** + * Returns the pod options for the pod selector by getting the display text for each pod in the `pods.ts` file. + */ +const PodOptions = () => { + // Get the display text for each pod in the `pods.ts` file + const podOptions = POD_IDS.map((podId) => getDisplayText(podId)); + + return podOptions.map((podOption) => ( + + {podOption} + + )); +}; + +/** + * Gets the text to display in the pod selector. + */ +const getDisplayText = (podId: PodId) => `${pods[podId].name} (${podId})`; + +/** + * Opposite of `getDisplayText()`. Gets the pod ID from the display text. + */ +const getPodIdFromDisplayText = (displayText: string) => { + const podId = displayText.split('(')[1].split(')')[0]; + return podId as PodId; +}; diff --git a/telemetry/packages/ui/app/components/ui/alert-dialog.tsx b/telemetry/packages/ui/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..cd7595e --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/alert-dialog.tsx @@ -0,0 +1,145 @@ +// @ts-nocheck + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = ({ + className, + ...props +}: AlertDialogPrimitive.AlertDialogPortalProps) => ( + +); +AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
          +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
          +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/telemetry/packages/ui/app/components/ui/button.tsx b/telemetry/packages/ui/app/components/ui/button.tsx new file mode 100644 index 0000000..0709f7f --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'underline-offset-4 hover:underline text-primary', + }, + size: { + default: 'h-10 py-2 px-4', + sm: 'h-9 px-3 rounded-md', + lg: 'h-11 px-8 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + // @ts-ignore + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/telemetry/packages/ui/app/components/ui/card.tsx b/telemetry/packages/ui/app/components/ui/card.tsx new file mode 100644 index 0000000..3028066 --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
          +)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
          +)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

          +)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

          +)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

          +)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
          +)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/telemetry/packages/ui/app/components/ui/carousel.tsx b/telemetry/packages/ui/app/components/ui/carousel.tsx new file mode 100644 index 0000000..bf3358d --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +import * as React from 'react'; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error('useCarousel must be used within a '); + } + + return context; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref, + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return; + } + + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) { + return; + } + + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) { + return; + } + + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + + return () => { + api?.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + +
          + {children} +
          +
          + ); + }, +); +Carousel.displayName = 'Carousel'; + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
          +
          +
          + ); +}); +CarouselContent.displayName = 'CarouselContent'; + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( +
          + ); +}); +CarouselItem.displayName = 'CarouselItem'; + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +}); +CarouselPrevious.displayName = 'CarouselPrevious'; + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +}); +CarouselNext.displayName = 'CarouselNext'; + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/telemetry/packages/ui/app/components/ui/dialog.tsx b/telemetry/packages/ui/app/components/ui/dialog.tsx new file mode 100644 index 0000000..0da4496 --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +// @ts-nocheck + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = ({ + className, + ...props +}: DialogPrimitive.DialogPortalProps) => ( + +); +DialogPortal.displayName = DialogPrimitive.Portal.displayName; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
          +); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
          +); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/telemetry/packages/ui/app/components/ui/dropdown-menu.tsx b/telemetry/packages/ui/app/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..82ef8b3 --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +// @ts-nocheck + +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/telemetry/packages/ui/app/components/ui/form.tsx b/telemetry/packages/ui/app/components/ui/form.tsx new file mode 100644 index 0000000..47fe3ae --- /dev/null +++ b/telemetry/packages/ui/app/components/ui/form.tsx @@ -0,0 +1,179 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form'; + +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
          + + ); +}); +FormItem.displayName = 'FormItem'; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +