diff --git a/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/.gitignore b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/.gitignore new file mode 100644 index 00000000..4e301317 --- /dev/null +++ b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +Cargo.lock +bin/ +pkg/ +wasm-pack.log diff --git a/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/Cargo.toml b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/Cargo.toml new file mode 100644 index 00000000..c500603e --- /dev/null +++ b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "wasm-interpolate" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +wasm-bindgen = "0.2.84" + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.7", optional = true } +interpolator = { version = "0.5.0", features = ["number"] } +js-sys = "0.3.66" +anyhow = "1.0.79" + +[dev-dependencies] +wasm-bindgen-test = "0.3.34" + +[profile.release] +# Tell `rustc` to optimize for small code size. +opt-level = "s" diff --git a/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/lib.rs b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/lib.rs new file mode 100644 index 00000000..97746758 --- /dev/null +++ b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/lib.rs @@ -0,0 +1,56 @@ +mod utils; + +use interpolator::{format, Formattable}; +use wasm_bindgen::prelude::*; + +use anyhow::{anyhow, Result}; +use js_sys::BigInt; +use std::collections::HashMap; + +enum Value { + I64(i64), + F64(f64), + String(String), +} + +impl Value { + pub fn formattable(&self) -> Formattable { + use Value::*; + match self { + I64(v) => Formattable::integer(v), + F64(v) => Formattable::float(v), + String(v) => Formattable::display(v), + } + } +} + +impl TryFrom<&JsValue> for Value { + type Error = anyhow::Error; + fn try_from(value: &JsValue) -> Result { + if value.is_bigint() { + let value = BigInt::new(&value) + .map_err(|_| anyhow!("not a bigint"))? + .try_into() + .map_err(|_| anyhow!("couldn't convert bigint to i64"))?; + Ok(Value::I64(value)) + } else if let Some(v) = value.as_f64() { + Ok(Value::F64(v)) + } else if let Some(s) = value.as_string() { + Ok(Value::String(s)) + } else { + Err(anyhow!("not a string, f64, or bigint")) + } + } +} + +pub fn format_value_inner(format_string: &str, arg: &JsValue) -> Result { + let arg = Value::try_from(arg)?; + let arg = arg.formattable(); + let args = HashMap::from([("value", arg)]); + format(format_string, &args).map_err(Into::into) +} + +#[wasm_bindgen] +pub fn format_value(format_string: &str, arg: &JsValue) -> Result { + format_value_inner(format_string, arg).map_err(|e| e.to_string()) +} diff --git a/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/utils.rs b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/utils.rs new file mode 100644 index 00000000..b1d7929d --- /dev/null +++ b/tmtc-c2a/devtools_frontend/crates/wasm-interpolate/src/utils.rs @@ -0,0 +1,10 @@ +pub fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} diff --git a/tmtc-c2a/devtools_frontend/package.json b/tmtc-c2a/devtools_frontend/package.json index 49bc4562..5ace92d1 100644 --- a/tmtc-c2a/devtools_frontend/package.json +++ b/tmtc-c2a/devtools_frontend/package.json @@ -7,10 +7,16 @@ "codegen:proto:tmtc_generic_c2a": "protoc --ts_out src/proto --proto_path ../../tmtc-c2a/proto ../../tmtc-c2a/proto/tmtc_generic_c2a.proto", "codegen:proto": "run-p codegen:proto:*", "codegen": "run-s codegen:proto", + "crate:build": "cd crates && wasm-pack build --target web --release", + "crate:dev": "cd crates && cargo watch -s 'wasm-pack build --target web --dev' -C", + "crate": "yarn crate:${MODE:-build}", + "crates:wasm-interpolate": "yarn crate wasm-interpolate", + "dev:crates": "MODE=dev run-p crates:*", "dev:vite": "vite --host", "dev": "run-p dev:*", + "build:crates": "run-s crates:*", "build:vite": "vite build", - "build": "run-s build:vite", + "build": "run-s build:crates build:vite", "typecheck": "tsc", "lint:prettier": "prettier . --check", "lint:eslint": "eslint . --format stylish", diff --git a/tmtc-c2a/devtools_frontend/src/components/TelemetryView.tsx b/tmtc-c2a/devtools_frontend/src/components/TelemetryView.tsx index 31754e22..b47c35fa 100644 --- a/tmtc-c2a/devtools_frontend/src/components/TelemetryView.tsx +++ b/tmtc-c2a/devtools_frontend/src/components/TelemetryView.tsx @@ -7,25 +7,34 @@ import { useParams } from "react-router-dom"; import { Helmet } from "react-helmet-async"; import { TelemetrySchema } from "../proto/tmtc_generic_c2a"; +import initInterpolate, * as interpolate from "../../crates/wasm-interpolate/pkg"; + +initInterpolate(); + +type DisplayInfo = { + formatString: string; +}; + const buildTelemetryFieldTreeBlueprintFromSchema = ( tlm: TelemetrySchema, -): TreeNamespace => { - const fieldNames = tlm.fields.map((f) => f.name); - const root: TreeNamespace = new Map(); - for (const fieldName of fieldNames) { - const path = fieldName.split("."); - addToNamespace(root, path, undefined); +): TreeNamespace => { + const root: TreeNamespace = new Map(); + for (const field of tlm.fields) { + const path = field.name.split("."); + const formatString = field.metadata?.displayFormat ?? ""; + addToNamespace(root, path, { formatString }); } return root; }; type TelemetryValuePair = { + displayInfo: DisplayInfo; converted: TmivField["value"] | null; raw: TmivField["value"] | null; }; const buildTelemetryFieldTree = ( - blueprint: TreeNamespace, + blueprint: TreeNamespace, fields: TmivField[], ): TreeNamespace => { const convertedFieldMap = new Map(); @@ -38,15 +47,33 @@ const buildTelemetryFieldTree = ( convertedFieldMap.set(field.name, field.value); } } - return mapNamespace(blueprint, (path, _key) => { + return mapNamespace(blueprint, (path, displayInfo) => { const key = path.join("."); const converted = convertedFieldMap.get(key) ?? null; const raw = rawFieldMap.get(key) ?? null; - return { converted, raw }; + return { displayInfo, converted, raw }; }); }; -const prettyprintValue = (value: TmivField["value"] | null) => { +const prettyprintValue = ( + value: TmivField["value"] | null, + displayInfo: DisplayInfo, +) => { + if (value === null) { + return "****"; + } + try { + const ks = Object.keys(value).find((k) => k !== "oneofKind")!; + const rawValue = value[ks as keyof typeof value]!; //FIXME: ???? + const interpolated = interpolate.format_value("{value:#0x}", rawValue); + return defaultPrettyPrint(value) + "/" + interpolated; + } catch (e) { + // TODO: show warning + return defaultPrettyPrint(value) + "!"; + } +}; + +const defaultPrettyPrint = (value: TmivField["value"] | null) => { if (value === null) { return "****"; } @@ -76,7 +103,7 @@ const LeafCell: React.FC = ({ name, value }) => { {name} - {prettyprintValue(value.converted)} + {prettyprintValue(value.converted, value.displayInfo)} ); @@ -149,7 +176,7 @@ const InlineNamespaceContentCell: React.FC = ({ {name}: - {prettyprintValue(v.value.converted)} + {prettyprintValue(v.value.converted, v.value.displayInfo)} ); diff --git a/tmtc-c2a/devtools_frontend/src/proto/tmtc_generic_c2a.ts b/tmtc-c2a/devtools_frontend/src/proto/tmtc_generic_c2a.ts index 9f9a80ae..50920cf0 100644 --- a/tmtc-c2a/devtools_frontend/src/proto/tmtc_generic_c2a.ts +++ b/tmtc-c2a/devtools_frontend/src/proto/tmtc_generic_c2a.ts @@ -181,11 +181,15 @@ export interface TelemetryFieldSchema { name: string; // TODO: TelemetryFieldDataType data_type = 3; } /** - * TODO: string description = 1; - * * @generated from protobuf message tmtc_generic_c2a.TelemetryFieldSchemaMetadata */ export interface TelemetryFieldSchemaMetadata { + /** + * TODO: string description = 1; + * + * @generated from protobuf field: string display_format = 1; + */ + displayFormat: string; } /** * @generated from protobuf message tmtc_generic_c2a.TelemetryChannelSchema @@ -1070,19 +1074,40 @@ export const TelemetryFieldSchema = new TelemetryFieldSchema$Type(); // @generated message type with reflection information, may provide speed optimized methods class TelemetryFieldSchemaMetadata$Type extends MessageType { constructor() { - super("tmtc_generic_c2a.TelemetryFieldSchemaMetadata", []); + super("tmtc_generic_c2a.TelemetryFieldSchemaMetadata", [ + { no: 1, name: "display_format", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); } create(value?: PartialMessage): TelemetryFieldSchemaMetadata { - const message = {}; + const message = { displayFormat: "" }; globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this }); if (value !== undefined) reflectionMergePartial(this, message, value); return message; } internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: TelemetryFieldSchemaMetadata): TelemetryFieldSchemaMetadata { - return target ?? this.create(); + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string display_format */ 1: + message.displayFormat = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; } internalBinaryWrite(message: TelemetryFieldSchemaMetadata, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string display_format = 1; */ + if (message.displayFormat !== "") + writer.tag(1, WireType.LengthDelimited).string(message.displayFormat); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);