From 27508a70ab05ec43d1bbdd4fbb1e6ea1417d7448 Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:30:08 -0400 Subject: [PATCH] fix(mcp): require signalRequests/signalNames on signals and signalsLatest The MCP shortcut tools for `signals` and `signalsLatest` wrapped those GraphQL fields with a static selection of `timestamp` / `lastSeen` only. The underlying resolvers read signal names and aggregations from the GraphQL selection set, so the MCP tool returned buckets with no data and models frequently stuffed signal names into the `filter` argument (the only shape that looked plausible). Add required arguments that carry signal names through explicitly: - `signals(signalRequests: [SignalAggregationRequest!]!)` where each entry is `{name, agg}`, and a new `signals: [SignalAggregationValue!]!` sub-field on `SignalAggregations` carrying one entry per supplied request. - `signalsLatest(signalNames: [String!]!)` and a new `signals: [LatestSignal!]!` sub-field on `SignalCollection` carrying the latest value for each requested name, filtered by caller privileges. The tool names (`get_signals_time_series`, `get_latest_signals`) and selection-set-based aggregation paths are preserved; this is purely an additive schema change that makes the shortcut tools usable. Breaking change for GraphQL clients of `signals`/`signalsLatest`: the new arguments are required (non-null), so clients must pass them (an empty list `[]` is valid when using only selection-set aggregation). --- internal/graph/base.resolvers.go | 30 +++- internal/graph/generated.go | 244 ++++++++++++++++++++++++++-- internal/graph/mcp_tools_gen.go | 12 +- internal/graph/model/models_gen.go | 12 ++ internal/graph/model/signal_aggs.go | 5 + internal/graph/signal_requests.go | 122 ++++++++++++++ schema/base.graphqls | 46 +++++- 7 files changed, 440 insertions(+), 31 deletions(-) create mode 100644 internal/graph/signal_requests.go diff --git a/internal/graph/base.resolvers.go b/internal/graph/base.resolvers.go index 422038f..df3cc31 100644 --- a/internal/graph/base.resolvers.go +++ b/internal/graph/base.resolvers.go @@ -16,21 +16,43 @@ import ( ) // Signals is the resolver for the Signals field. -func (r *queryResolver) Signals(ctx context.Context, tokenID int, interval string, from time.Time, to time.Time, filter *model.SignalFilter) ([]*model.SignalAggregations, error) { +func (r *queryResolver) Signals(ctx context.Context, tokenID int, interval string, from time.Time, to time.Time, signalRequests []*model.SignalAggregationRequest, filter *model.SignalFilter) ([]*model.SignalAggregations, error) { aggArgs, err := aggregationArgsFromContext(ctx, tokenID, interval, from, to, filter) if err != nil { return nil, err } - return r.BaseRepo.GetSignal(ctx, aggArgs) + permissions := permissionsFromContext(ctx) + allowedReqs, reqAliases := appendSignalRequestArgs(aggArgs, signalRequests, permissions) + + buckets, err := r.BaseRepo.GetSignal(ctx, aggArgs) + if err != nil { + return nil, err + } + populateAggregationSignals(buckets, allowedReqs, reqAliases) + return buckets, nil } // SignalsLatest is the resolver for the SignalsLatest field. -func (r *queryResolver) SignalsLatest(ctx context.Context, tokenID int, filter *model.SignalFilter) (*model.SignalCollection, error) { +func (r *queryResolver) SignalsLatest(ctx context.Context, tokenID int, signalNames []string, filter *model.SignalFilter) (*model.SignalCollection, error) { latestArgs, err := latestArgsFromContext(ctx, tokenID, filter) if err != nil { return nil, err } - return r.BaseRepo.GetSignalLatest(ctx, latestArgs) + coll, err := r.BaseRepo.GetSignalLatest(ctx, latestArgs) + if err != nil { + return nil, err + } + + allowedNames := filterSignalNamesByPrivilege(signalNames, permissionsFromContext(ctx)) + if len(allowedNames) == 0 { + return coll, nil + } + snap, err := r.BaseRepo.GetSignalSnapshot(ctx, uint32(tokenID), filter) + if err != nil { + return nil, err + } + coll.Signals = filterSnapshotByName(snap.Signals, allowedNames) + return coll, nil } // AvailableSignals is the resolver for the AvailableSignals field. diff --git a/internal/graph/generated.go b/internal/graph/generated.go index da1979a..3a0baba 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -116,8 +116,8 @@ type ComplexityRoot struct { DataSummary func(childComplexity int, tokenID int, filter *model.SignalFilter) int Events func(childComplexity int, tokenID int, from time.Time, to time.Time, filter *model.EventFilter) int Segments func(childComplexity int, tokenID int, from time.Time, to time.Time, mechanism model.DetectionMechanism, config *model.SegmentConfig, signalRequests []*model.SegmentSignalRequest, eventRequests []*model.SegmentEventRequest, limit *int, after *time.Time) int - Signals func(childComplexity int, tokenID int, interval string, from time.Time, to time.Time, filter *model.SignalFilter) int - SignalsLatest func(childComplexity int, tokenID int, filter *model.SignalFilter) int + Signals func(childComplexity int, tokenID int, interval string, from time.Time, to time.Time, signalRequests []*model.SignalAggregationRequest, filter *model.SignalFilter) int + SignalsLatest func(childComplexity int, tokenID int, signalNames []string, filter *model.SignalFilter) int SignalsSnapshot func(childComplexity int, tokenID int, filter *model.SignalFilter) int VinVCLatest func(childComplexity int, tokenID int) int } @@ -255,6 +255,7 @@ type ComplexityRoot struct { PowertrainType func(childComplexity int, agg model.StringAggregation) int ServiceDistanceToService func(childComplexity int, agg model.FloatAggregation, filter *model.SignalFloatFilter) int ServiceTimeToService func(childComplexity int, agg model.FloatAggregation, filter *model.SignalFloatFilter) int + Signals func(childComplexity int) int Speed func(childComplexity int, agg model.FloatAggregation, filter *model.SignalFloatFilter) int Timestamp func(childComplexity int) int } @@ -377,6 +378,7 @@ type ComplexityRoot struct { PowertrainType func(childComplexity int) int ServiceDistanceToService func(childComplexity int) int ServiceTimeToService func(childComplexity int) int + Signals func(childComplexity int) int Speed func(childComplexity int) int } @@ -421,8 +423,8 @@ type ComplexityRoot struct { } type QueryResolver interface { - Signals(ctx context.Context, tokenID int, interval string, from time.Time, to time.Time, filter *model.SignalFilter) ([]*model.SignalAggregations, error) - SignalsLatest(ctx context.Context, tokenID int, filter *model.SignalFilter) (*model.SignalCollection, error) + Signals(ctx context.Context, tokenID int, interval string, from time.Time, to time.Time, signalRequests []*model.SignalAggregationRequest, filter *model.SignalFilter) ([]*model.SignalAggregations, error) + SignalsLatest(ctx context.Context, tokenID int, signalNames []string, filter *model.SignalFilter) (*model.SignalCollection, error) AvailableSignals(ctx context.Context, tokenID int, filter *model.SignalFilter) ([]string, error) SignalsSnapshot(ctx context.Context, tokenID int, filter *model.SignalFilter) (*model.SignalsSnapshotResponse, error) DataSummary(ctx context.Context, tokenID int, filter *model.SignalFilter) (*model.DataSummary, error) @@ -897,7 +899,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.ComplexityRoot.Query.Signals(childComplexity, args["tokenId"].(int), args["interval"].(string), args["from"].(time.Time), args["to"].(time.Time), args["filter"].(*model.SignalFilter)), true + return e.ComplexityRoot.Query.Signals(childComplexity, args["tokenId"].(int), args["interval"].(string), args["from"].(time.Time), args["to"].(time.Time), args["signalRequests"].([]*model.SignalAggregationRequest), args["filter"].(*model.SignalFilter)), true case "Query.signalsLatest": if e.ComplexityRoot.Query.SignalsLatest == nil { break @@ -908,7 +910,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.ComplexityRoot.Query.SignalsLatest(childComplexity, args["tokenId"].(int), args["filter"].(*model.SignalFilter)), true + return e.ComplexityRoot.Query.SignalsLatest(childComplexity, args["tokenId"].(int), args["signalNames"].([]string), args["filter"].(*model.SignalFilter)), true case "Query.signalsSnapshot": if e.ComplexityRoot.Query.SignalsSnapshot == nil { break @@ -2270,6 +2272,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.SignalAggregations.ServiceTimeToService(childComplexity, args["agg"].(model.FloatAggregation), args["filter"].(*model.SignalFloatFilter)), true + case "SignalAggregations.signals": + if e.ComplexityRoot.SignalAggregations.Signals == nil { + break + } + + return e.ComplexityRoot.SignalAggregations.Signals(childComplexity), true case "SignalAggregations.speed": if e.ComplexityRoot.SignalAggregations.Speed == nil { break @@ -2990,6 +2998,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.SignalCollection.ServiceTimeToService(childComplexity), true + case "SignalCollection.signals": + if e.ComplexityRoot.SignalCollection.Signals == nil { + break + } + + return e.ComplexityRoot.SignalCollection.Signals(childComplexity), true case "SignalCollection.speed": if e.ComplexityRoot.SignalCollection.Speed == nil { break @@ -3144,6 +3158,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputSegmentConfig, ec.unmarshalInputSegmentEventRequest, ec.unmarshalInputSegmentSignalRequest, + ec.unmarshalInputSignalAggregationRequest, ec.unmarshalInputSignalFilter, ec.unmarshalInputSignalFloatFilter, ec.unmarshalInputSignalLocationFilter, @@ -3307,14 +3322,29 @@ type Query { interval: String! from: Time! to: Time! + """ + List of {name, agg} pairs specifying which float signals to aggregate. + Required — an empty list produces no signal data. Signal names come from + ` + "`" + `availableSignals` + "`" + ` or ` + "`" + `dataSummary` + "`" + `. Aggregations: AVG, MED, MAX, MIN, + RAND, FIRST, LAST. + """ + signalRequests: [SignalAggregationRequest!]! filter: SignalFilter ): [SignalAggregations!] @requiresVehicleToken - @mcpTool(name: "get_signals_time_series", description: "Get aggregated signal time series for a vehicle over a date range. Returns signal values bucketed by the specified interval (e.g. '1h', '15m'). Use with signal field names and aggregation functions.", selection: "timestamp") - @mcpExample(description: "Hourly average speed over a time range", query: "query TimeSeries($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to) { timestamp speed(agg:AVG) } }") - signalsLatest(tokenId: Int!, filter: SignalFilter): SignalCollection + @mcpTool(name: "get_signals_time_series", description: "Get aggregated time series for a named list of float signals. Pass signalRequests as an array of {name, agg} objects (e.g. [{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]). Returns one bucket per interval with signals as [{name, agg, value}]. Signal names come from get_available_signals or get_data_summary. Aggregations: AVG, MED, MAX, MIN, RAND, FIRST, LAST.", selection: "timestamp signals { name agg value }") + @mcpExample(description: "Hourly average speed and last state of charge", query: "query TS($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to,signalRequests:[{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]) { timestamp signals { name agg value } } }") + signalsLatest( + tokenId: Int! + """ + List of signal names to return. Required — an empty list produces no + signal data. Signal names come from ` + "`" + `availableSignals` + "`" + ` or ` + "`" + `dataSummary` + "`" + `. + """ + signalNames: [String!]! + filter: SignalFilter + ): SignalCollection @requiresVehicleToken - @mcpTool(name: "get_latest_signals", description: "Get the most recent signal values for a vehicle by token ID. Returns the last-seen timestamp for the vehicle.", selection: "lastSeen") - @mcpExample(description: "Latest speed and battery charge", query: "query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId) { lastSeen speed{timestamp value} powertrainTractionBatteryStateOfChargeCurrent{timestamp value} } }") + @mcpTool(name: "get_latest_signals", description: "Get the most recent value for a named list of signals. Pass signalNames as an array of strings (e.g. [\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]). Signal names come from get_available_signals or get_data_summary. Returns lastSeen plus a signals list, each entry with one of valueNumber, valueString, or valueLocation populated depending on signal type.", selection: "lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } }") + @mcpExample(description: "Latest speed and battery charge by name", query: "query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId,signalNames:[\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]) { lastSeen signals { name timestamp valueNumber } } }") availableSignals(tokenId: Int!, filter: SignalFilter): [String!] @requiresVehicleToken @mcpTool(name: "get_available_signals", description: "List queryable signal names that have stored data for a vehicle by token ID.", selection: "") @@ -3334,6 +3364,11 @@ type Query { type SignalAggregations { timestamp: Time! """ + Per-bucket list of {name, agg, value} entries, one per entry in the request's + ` + "`" + `signalRequests` + "`" + ` argument. Populated only when ` + "`" + `signalRequests` + "`" + ` is supplied. + """ + signals: [SignalAggregationValue!]! + """ Approximate location of the vehicle in WGS 84 coordinates. The aggregation is applied to the raw location values and the result is then replaced with the center of the containing H3 cell of resolution 6. HDOP is not obscured at all. @@ -3351,6 +3386,12 @@ type SignalAggregations { type SignalCollection { lastSeen: Time """ + Flat list of latest values for the names passed in the request's + ` + "`" + `signalNames` + "`" + ` argument, filtered by caller privileges. Populated only when + ` + "`" + `signalNames` + "`" + ` is supplied. + """ + signals: [LatestSignal!]! + """ Approximate location of the vehicle in WGS 84 coordinates. The raw value is replaced with the center of the containing H3 cell of resolution 6. HDOP is not obscured at all. Required Privileges: [VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION] @@ -3483,6 +3524,16 @@ type Location { hdop: Float! } +""" +Request to compute one float-signal aggregation in a time-series query. +Shape mirrors SegmentSignalRequest; used by the ` + "`" + `signals` + "`" + ` query's +` + "`" + `signalRequests` + "`" + ` argument. +""" +input SignalAggregationRequest { + name: String! + agg: FloatAggregation! +} + """ Result of aggregating a float signal over an interval. Used by segments and daily activity summaries. Same shape as one row of aggregated signal data (name, aggregation type, computed value). @@ -5897,11 +5948,16 @@ func (ec *executionContext) field_Query_signalsLatest_args(ctx context.Context, return nil, err } args["tokenId"] = arg0 - arg1, err := graphql.ProcessArgField(ctx, rawArgs, "filter", ec.unmarshalOSignalFilter2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalFilter) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "signalNames", ec.unmarshalNString2ᚕstringᚄ) if err != nil { return nil, err } - args["filter"] = arg1 + args["signalNames"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "filter", ec.unmarshalOSignalFilter2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalFilter) + if err != nil { + return nil, err + } + args["filter"] = arg2 return args, nil } @@ -5944,11 +6000,16 @@ func (ec *executionContext) field_Query_signals_args(ctx context.Context, rawArg return nil, err } args["to"] = arg3 - arg4, err := graphql.ProcessArgField(ctx, rawArgs, "filter", ec.unmarshalOSignalFilter2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalFilter) + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "signalRequests", ec.unmarshalNSignalAggregationRequest2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationRequestᚄ) if err != nil { return nil, err } - args["filter"] = arg4 + args["signalRequests"] = arg4 + arg5, err := graphql.ProcessArgField(ctx, rawArgs, "filter", ec.unmarshalOSignalFilter2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalFilter) + if err != nil { + return nil, err + } + args["filter"] = arg5 return args, nil } @@ -9103,7 +9164,7 @@ func (ec *executionContext) _Query_signals(ctx context.Context, field graphql.Co ec.fieldContext_Query_signals, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().Signals(ctx, fc.Args["tokenId"].(int), fc.Args["interval"].(string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["filter"].(*model.SignalFilter)) + return ec.Resolvers.Query().Signals(ctx, fc.Args["tokenId"].(int), fc.Args["interval"].(string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["signalRequests"].([]*model.SignalAggregationRequest), fc.Args["filter"].(*model.SignalFilter)) }, func(ctx context.Context, next graphql.Resolver) graphql.Resolver { directive0 := next @@ -9135,6 +9196,8 @@ func (ec *executionContext) fieldContext_Query_signals(ctx context.Context, fiel switch field.Name { case "timestamp": return ec.fieldContext_SignalAggregations_timestamp(ctx, field) + case "signals": + return ec.fieldContext_SignalAggregations_signals(ctx, field) case "currentLocationApproximateCoordinates": return ec.fieldContext_SignalAggregations_currentLocationApproximateCoordinates(ctx, field) case "angularVelocityYaw": @@ -9395,7 +9458,7 @@ func (ec *executionContext) _Query_signalsLatest(ctx context.Context, field grap ec.fieldContext_Query_signalsLatest, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Query().SignalsLatest(ctx, fc.Args["tokenId"].(int), fc.Args["filter"].(*model.SignalFilter)) + return ec.Resolvers.Query().SignalsLatest(ctx, fc.Args["tokenId"].(int), fc.Args["signalNames"].([]string), fc.Args["filter"].(*model.SignalFilter)) }, func(ctx context.Context, next graphql.Resolver) graphql.Resolver { directive0 := next @@ -9427,6 +9490,8 @@ func (ec *executionContext) fieldContext_Query_signalsLatest(ctx context.Context switch field.Name { case "lastSeen": return ec.fieldContext_SignalCollection_lastSeen(ctx, field) + case "signals": + return ec.fieldContext_SignalCollection_signals(ctx, field) case "currentLocationApproximateCoordinates": return ec.fieldContext_SignalCollection_currentLocationApproximateCoordinates(ctx, field) case "angularVelocityYaw": @@ -10703,6 +10768,43 @@ func (ec *executionContext) fieldContext_SignalAggregations_timestamp(_ context. return fc, nil } +func (ec *executionContext) _SignalAggregations_signals(ctx context.Context, field graphql.CollectedField, obj *model.SignalAggregations) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SignalAggregations_signals, + func(ctx context.Context) (any, error) { + return obj.Signals, nil + }, + nil, + ec.marshalNSignalAggregationValue2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationValueᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SignalAggregations_signals(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SignalAggregations", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext_SignalAggregationValue_name(ctx, field) + case "agg": + return ec.fieldContext_SignalAggregationValue_agg(ctx, field) + case "value": + return ec.fieldContext_SignalAggregationValue_value(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SignalAggregationValue", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _SignalAggregations_currentLocationApproximateCoordinates(ctx context.Context, field graphql.CollectedField, obj *model.SignalAggregations) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -19289,6 +19391,47 @@ func (ec *executionContext) fieldContext_SignalCollection_lastSeen(_ context.Con return fc, nil } +func (ec *executionContext) _SignalCollection_signals(ctx context.Context, field graphql.CollectedField, obj *model.SignalCollection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SignalCollection_signals, + func(ctx context.Context) (any, error) { + return obj.Signals, nil + }, + nil, + ec.marshalNLatestSignal2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐLatestSignalᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SignalCollection_signals(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SignalCollection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext_LatestSignal_name(ctx, field) + case "timestamp": + return ec.fieldContext_LatestSignal_timestamp(ctx, field) + case "valueNumber": + return ec.fieldContext_LatestSignal_valueNumber(ctx, field) + case "valueString": + return ec.fieldContext_LatestSignal_valueString(ctx, field) + case "valueLocation": + return ec.fieldContext_LatestSignal_valueLocation(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type LatestSignal", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _SignalCollection_currentLocationApproximateCoordinates(ctx context.Context, field graphql.CollectedField, obj *model.SignalCollection) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -28729,6 +28872,43 @@ func (ec *executionContext) unmarshalInputSegmentSignalRequest(ctx context.Conte return it, nil } +func (ec *executionContext) unmarshalInputSignalAggregationRequest(ctx context.Context, obj any) (model.SignalAggregationRequest, error) { + var it model.SignalAggregationRequest + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"name", "agg"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "agg": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("agg")) + data, err := ec.unmarshalNFloatAggregation2githubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐFloatAggregation(ctx, v) + if err != nil { + return it, err + } + it.Agg = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputSignalFilter(ctx context.Context, obj any) (model.SignalFilter, error) { var it model.SignalFilter if obj == nil { @@ -29837,6 +30017,11 @@ func (ec *executionContext) _SignalAggregations(ctx context.Context, sel ast.Sel if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "signals": + out.Values[i] = ec._SignalAggregations_signals(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } case "currentLocationApproximateCoordinates": field := field @@ -33734,6 +33919,11 @@ func (ec *executionContext) _SignalCollection(ctx context.Context, sel ast.Selec out.Values[i] = graphql.MarshalString("SignalCollection") case "lastSeen": out.Values[i] = ec._SignalCollection_lastSeen(ctx, field, obj) + case "signals": + out.Values[i] = ec._SignalCollection_signals(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "currentLocationApproximateCoordinates": out.Values[i] = ec._SignalCollection_currentLocationApproximateCoordinates(ctx, field, obj) case "angularVelocityYaw": @@ -34936,6 +35126,26 @@ func (ec *executionContext) unmarshalNSegmentSignalRequest2ᚖgithubᚗcomᚋDIM return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNSignalAggregationRequest2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationRequestᚄ(ctx context.Context, v any) ([]*model.SignalAggregationRequest, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.SignalAggregationRequest, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNSignalAggregationRequest2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationRequest(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNSignalAggregationRequest2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationRequest(ctx context.Context, v any) (*model.SignalAggregationRequest, error) { + res, err := ec.unmarshalInputSignalAggregationRequest(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNSignalAggregationValue2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋtelemetryᚑapiᚋinternalᚋgraphᚋmodelᚐSignalAggregationValueᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.SignalAggregationValue) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) diff --git a/internal/graph/mcp_tools_gen.go b/internal/graph/mcp_tools_gen.go index 83ba1c9..8c68e66 100644 --- a/internal/graph/mcp_tools_gen.go +++ b/internal/graph/mcp_tools_gen.go @@ -11,15 +11,16 @@ func boolPtr(b bool) *bool { return &b } var MCPTools = []mcpserver.ToolDefinition{ { Name: "telemetry_get_signals_time_series", - Description: "Get aggregated signal time series for a vehicle over a date range. Returns signal values bucketed by the specified interval (e.g. '1h', '15m'). Use with signal field names and aggregation functions.", + Description: "Get aggregated time series for a named list of float signals. Pass signalRequests as an array of {name, agg} objects (e.g. [{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]). Returns one bucket per interval with signals as [{name, agg, value}]. Signal names come from get_available_signals or get_data_summary. Aggregations: AVG, MED, MAX, MIN, RAND, FIRST, LAST.", Args: []mcpserver.ArgDefinition{ {Name: "tokenId", Type: "integer", Description: "tokenId (Int!, required)", Required: true, ItemsType: ""}, {Name: "interval", Type: "string", Description: "Duration string for data aggregation buckets (e.g., \"5m\", \"1h\", \"2h45m\"). Valid units: ms, s, m, h. Common values: \"5m\" (5 minutes), \"1h\" (1 hour), \"6h\", \"24h\". Days are not a valid unit — use \"24h\" instead of \"1d\".", Required: true, ItemsType: ""}, {Name: "from", Type: "string", Description: "from (Time!, required)", Required: true, ItemsType: ""}, {Name: "to", Type: "string", Description: "to (Time!, required)", Required: true, ItemsType: ""}, + {Name: "signalRequests", Type: "array", Description: "List of {name, agg} pairs specifying which float signals to aggregate.\nRequired — an empty list produces no signal data. Signal names come from\n`availableSignals` or `dataSummary`. Aggregations: AVG, MED, MAX, MIN,\nRAND, FIRST, LAST.", Required: true, ItemsType: "object"}, {Name: "filter", Type: "object", Description: "filter (SignalFilter, optional)", Required: false, ItemsType: ""}, }, - Query: "query($tokenId: Int!, $interval: String!, $from: Time!, $to: Time!, $filter: SignalFilter) { signals(tokenId: $tokenId, interval: $interval, from: $from, to: $to, filter: $filter) { timestamp } }", + Query: "query($tokenId: Int!, $interval: String!, $from: Time!, $to: Time!, $signalRequests: [SignalAggregationRequest!]!, $filter: SignalFilter) { signals(tokenId: $tokenId, interval: $interval, from: $from, to: $to, signalRequests: $signalRequests, filter: $filter) { timestamp signals { name agg value } } }", Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, DestructiveHint: boolPtr(false), @@ -29,12 +30,13 @@ var MCPTools = []mcpserver.ToolDefinition{ }, { Name: "telemetry_get_latest_signals", - Description: "Get the most recent signal values for a vehicle by token ID. Returns the last-seen timestamp for the vehicle.", + Description: "Get the most recent value for a named list of signals. Pass signalNames as an array of strings (e.g. [\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]). Signal names come from get_available_signals or get_data_summary. Returns lastSeen plus a signals list, each entry with one of valueNumber, valueString, or valueLocation populated depending on signal type.", Args: []mcpserver.ArgDefinition{ {Name: "tokenId", Type: "integer", Description: "tokenId (Int!, required)", Required: true, ItemsType: ""}, + {Name: "signalNames", Type: "array", Description: "List of signal names to return. Required — an empty list produces no\nsignal data. Signal names come from `availableSignals` or `dataSummary`.", Required: true, ItemsType: "string"}, {Name: "filter", Type: "object", Description: "filter (SignalFilter, optional)", Required: false, ItemsType: ""}, }, - Query: "query($tokenId: Int!, $filter: SignalFilter) { signalsLatest(tokenId: $tokenId, filter: $filter) { lastSeen } }", + Query: "query($tokenId: Int!, $signalNames: [String!]!, $filter: SignalFilter) { signalsLatest(tokenId: $tokenId, signalNames: $signalNames, filter: $filter) { lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } } } }", Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, DestructiveHint: boolPtr(false), @@ -179,4 +181,4 @@ var MCPTools = []mcpserver.ToolDefinition{ }, } -var CondensedSchema = "scalar Address # A 20-byte Ethereum address, encoded as a checksummed hex string with 0x prefix.\nscalar Map\nscalar Time # A point in time, encoded per RFC-3339.\nscalar Uint64 # A 64-bit unsigned integer.\n\n# ═══ SIGNAL FIELDS (117 total) ═══\n# All signals below exist on every signal type. Calling convention per type:\n# SignalAggregations:\n# fieldName(agg: LocationAggregation!): Location\n# fieldName(agg: FloatAggregation!, filter: SignalFloatFilter): Float\n# fieldName(agg: LocationAggregation!, filter: SignalLocationFilter): Location\n# fieldName(agg: StringAggregation!): String\n# SignalCollection:\n# fieldName(): SignalLocation\n# fieldName(): SignalFloat\n# fieldName(): SignalString\n# Float is the default type. Location: currentLocationApproximateCoordinates, currentLocationCoordinates. String: obdDTCList, obdFuelTypeName, powertrainCombustionEngineEngineOilLevel, powertrainFuelSystemSupportedFuelTypes, powertrainTransmissionRetarderTorqueMode, powertrainType.\n# | Signal | Unit | Description |\n# |--------|------|-------------|\n# Shared descriptions (blank rows below use these):\n# - Is item open or closed? True = Fully or partially open\n# - Is the belt engaged\n# - Measured Load on axle row 3\n# ── CURRENT (privilege: VEHICLE_ALL_TIME_LOCATION) ──\n# | currentLocationApproximateCoordinates | | Approximate location of the vehicle in WGS 84 coordinates (privilege: VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION) |\n# | currentLocationAltitude | m | Current altitude relative to WGS 84 reference ellipsoid, as measured at the position of GNSS receiver antenna |\n# | currentLocationCoordinates | | Current location of the vehicle in WGS 84 coordinates |\n# | currentLocationHeading | degrees | Current heading relative to geographic north |\n# ── OTHER (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | angularVelocityYaw | degrees/s | Vehicle rotation rate along Z (vertical) |\n# | connectivityCellularIsJammingDetected | | Indicates whether cellular radio signal jamming or interference is detected that prevents normal communication |\n# | exteriorAirTemperature | celsius | Air temperature outside the vehicle |\n# | isIgnitionOn | | Vehicle ignition status |\n# | lowVoltageBatteryCurrentVoltage | V | |\n# | speed | km/h | |\n# ── BODY (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | bodyLightsIsAirbagWarningOn | | Indicates whether the airbag/SRS warning telltale is active |\n# | bodyLockIsLocked | | Indicates whether the vehicle is locked via the central locking system |\n# | bodyTrunkFrontIsOpen | | |\n# | bodyTrunkRearIsOpen | | |\n# ── CABIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | cabinDoorRow1DriverSideIsOpen | | |\n# | cabinDoorRow1DriverSideWindowIsOpen | | |\n# | cabinDoorRow1PassengerSideIsOpen | | |\n# | cabinDoorRow1PassengerSideWindowIsOpen | | |\n# | cabinDoorRow2DriverSideIsOpen | | |\n# | cabinDoorRow2DriverSideWindowIsOpen | | |\n# | cabinDoorRow2PassengerSideIsOpen | | |\n# | cabinDoorRow2PassengerSideWindowIsOpen | | |\n# | cabinSeatRow1DriverSideIsBelted | | |\n# | cabinSeatRow1PassengerSideIsBelted | | |\n# | cabinSeatRow2DriverSideIsBelted | | |\n# | cabinSeatRow2MiddleIsBelted | | |\n# | cabinSeatRow2PassengerSideIsBelted | | |\n# | cabinSeatRow3DriverSideIsBelted | | |\n# | cabinSeatRow3PassengerSideIsBelted | | |\n# ── CHASSIS (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: Rotational speed of a vehicle's wheel\n# shared: Pneumatic pressure in the service brake circuit or reservoir\n# | chassisAxleRow1WheelLeftSpeed | km/h | |\n# | chassisAxleRow1WheelLeftTirePressure | kPa | |\n# | chassisAxleRow1WheelRightSpeed | km/h | |\n# | chassisAxleRow1WheelRightTirePressure | kPa | |\n# | chassisAxleRow2WheelLeftTirePressure | kPa | |\n# | chassisAxleRow2WheelRightTirePressure | kPa | |\n# | chassisAxleRow3Weight | kg | |\n# | chassisAxleRow4Weight | kg | |\n# | chassisAxleRow5Weight | kg | |\n# | chassisBrakeABSIsWarningOn | | Indicates whether the ABS warning telltale is active (any non-off state) |\n# | chassisBrakeCircuit1PressurePrimary | kPa | |\n# | chassisBrakeCircuit2PressurePrimary | kPa | |\n# | chassisBrakeIsPedalPressed | | Indicates whether the brake pedal is pressed |\n# | chassisBrakePedalPosition | percent | Brake pedal position as percent |\n# | chassisParkingBrakeIsEngaged | | |\n# | chassisTireSystemIsWarningOn | | Indicates whether the tire system warning telltale is active |\n# ── OBD (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: PID 2x (byte CD) - Voltage for wide range/band oxygen sensor\n# | obdBarometricPressure | kPa | PID 33 - Barometric pressure |\n# | obdCommandedEGR | percent | PID 2C - Commanded exhaust gas recirculation (EGR) |\n# | obdCommandedEVAP | percent | PID 2E - Commanded evaporative purge (EVAP) valve |\n# | obdDTCList | | List of currently active DTCs formatted according OBD II (SAE-J2012DA_201812) standard ([P|C|B|U]XXXXX ) |\n# | obdDistanceSinceDTCClear | km | PID 31 - Distance traveled since codes cleared |\n# | obdDistanceWithMIL | km | PID 21 - Distance traveled with MIL on |\n# | obdEngineLoad | percent | PID 04 - Engine load in percent - 0 = no load, 100 = full load |\n# | obdEthanolPercent | percent | PID 52 - Percentage of ethanol in the fuel |\n# | obdFuelPressure | kPa | PID 0A - Fuel pressure |\n# | obdFuelRailPressure | kPa | |\n# | obdFuelRate | l/h | PID 5E - Engine fuel rate |\n# | obdFuelTypeName | | Fuel type names decoded from PID 51 |\n# | obdIntakeTemp | celsius | PID 0F - Intake temperature |\n# | obdIsEngineBlocked | | Engine block status, 0 = engine unblocked, 1 = engine blocked |\n# | obdIsPTOActive | | PID 1E - Auxiliary input status (power take off) |\n# | obdIsPluggedIn | | Aftermarket device plugged in status |\n# | obdLongTermFuelTrim1 | percent | PID 07 - Long Term (learned) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdLongTermFuelTrim2 | percent | PID 09 - Long Term (learned) Fuel Trim - Bank 2 - negative percent leaner, positive percent richer |\n# | obdMAP | kPa | PID 0B - Intake manifold pressure |\n# | obdMaxMAF | g/s | PID 50 - Maximum flow for mass air flow sensor |\n# | obdO2WRSensor1Voltage | V | |\n# | obdO2WRSensor2Voltage | V | |\n# | obdOilTemperature | celsius | PID 5C - Engine oil temperature |\n# | obdRunTime | s | PID 1F - Engine run time |\n# | obdShortTermFuelTrim1 | percent | PID 06 - Short Term (immediate) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdStatusDTCCount | | Number of Diagnostic Trouble Codes (DTC) |\n# | obdThrottlePosition | percent | PID 11 - Throttle position - 0 = closed throttle, 100 = open throttle |\n# | obdWarmupsSinceDTCClear | | PID 30 - Number of warm-ups since codes cleared |\n# ── POWERTRAIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | powertrainCombustionEngineDieselExhaustFluidCapacity | l | Capacity in liters of the Diesel Exhaust Fluid Tank |\n# | powertrainCombustionEngineDieselExhaustFluidLevel | percent | Level of the Diesel Exhaust Fluid tank as percent of capacity |\n# | powertrainCombustionEngineECT | celsius | Engine coolant temperature |\n# | powertrainCombustionEngineEOP | kPa | Engine oil pressure |\n# | powertrainCombustionEngineEOT | celsius | Engine oil temperature |\n# | powertrainCombustionEngineEngineOilLevel | | |\n# | powertrainCombustionEngineEngineOilRelativeLevel | percent | Engine oil level as a percentage |\n# | powertrainCombustionEngineMAF | g/s | Grams of air drawn into engine per second |\n# | powertrainCombustionEngineSpeed | rpm | Engine speed measured as rotations per minute |\n# | powertrainCombustionEngineTPS | percent | Current throttle position |\n# | powertrainCombustionEngineTorque | Nm | |\n# | powertrainCombustionEngineTorquePercent | percent | Actual engine output torque as a percentage of reference engine torque (FMS / J1939 parameter SPN 513) |\n# | powertrainFuelSystemAbsoluteLevel | l | Current available fuel in the fuel tank expressed in liters |\n# | powertrainFuelSystemAccumulatedConsumption | l | Accumulated fuel consumption (totalized) reported by the vehicle (FMS SPN 250) |\n# | powertrainFuelSystemRelativeLevel | percent | Level in fuel tank as percent of capacity |\n# | powertrainFuelSystemSupportedFuelTypes | | High level information of fuel types supported |\n# | powertrainRange | km | Remaining range in kilometers using all energy sources available in the vehicle |\n# | powertrainTractionBatteryChargingAddedEnergy | kWh | Amount of charge added to the high voltage battery during the current charging session, expressed in kilowatt-hours |\n# | powertrainTractionBatteryChargingChargeCurrentAC | A | Current AC charging current (rms) at inlet |\n# | powertrainTractionBatteryChargingChargeLimit | percent | Target charge limit (state of charge) for battery |\n# | powertrainTractionBatteryChargingChargeVoltageUnknownType | V | Current charging voltage at inlet |\n# | powertrainTractionBatteryChargingIsCharging | | True if charging is ongoing |\n# | powertrainTractionBatteryChargingIsChargingCableConnected | | Indicates if a charging cable is physically connected to the vehicle or not |\n# | powertrainTractionBatteryChargingPower | kW | Instantaneous charging power recorded during a charging event |\n# | powertrainTractionBatteryCurrentPower | W | Current electrical energy flowing in/out of battery |\n# | powertrainTractionBatteryCurrentVoltage | V | |\n# | powertrainTractionBatteryGrossCapacity | kWh | |\n# | powertrainTractionBatteryRange | km | Remaining range in kilometers using only battery |\n# | powertrainTractionBatteryStateOfChargeCurrent | percent | Physical state of charge of the high voltage battery, relative to net capacity |\n# | powertrainTractionBatteryStateOfChargeCurrentEnergy | kWh | Physical state of charge of high voltage battery expressed in kWh |\n# | powertrainTractionBatteryStateOfHealth | percent | Calculated battery state of health at standard conditions |\n# | powertrainTractionBatteryTemperatureAverage | celsius | Current average temperature of the battery cells |\n# | powertrainTransmissionActualGear | | Actual transmission gear currently engaged |\n# | powertrainTransmissionActualGearRatio | | |\n# | powertrainTransmissionCurrentGear | | |\n# | powertrainTransmissionIsClutchSwitchOperated | | Indicates if the Clutch switch is operated, so engine and transmission are partially or fully decoupled |\n# | powertrainTransmissionRetarderActualTorque | percent | Actual retarder torque as a percentage (FMS / J1939 SPN 520) |\n# | powertrainTransmissionRetarderTorqueMode | | Active engine torque mode |\n# | powertrainTransmissionSelectedGear | | |\n# | powertrainTransmissionTemperature | celsius | The current gearbox temperature |\n# | powertrainTransmissionTravelledDistance | km | Odometer reading, total distance travelled during the lifetime of the transmission |\n# | powertrainType | | Defines the powertrain type of the vehicle |\n# ── SERVICE (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | serviceDistanceToService | km | Remaining distance to service (of any kind) |\n# | serviceTimeToService | s | Remaining time to service (of any kind) |\n\ntype Query {\n signals(\n tokenId: Int!\n \"\"\"\n Duration string for data aggregation buckets (e.g., \"5m\", \"1h\", \"2h45m\"). Valid\n units: ms, s, m, h. Common values: \"5m\" (5 minutes), \"1h\" (1 hour), \"6h\", \"24h\".\n Days are not a valid unit — use \"24h\" instead of \"1d\".\n \"\"\"\n interval: String!\n from: Time!\n to: Time!\n filter: SignalFilter\n ): [SignalAggregations!]\n # Example - Hourly average speed over a time range:\n # query TimeSeries($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to) { timestamp speed(agg:AVG) } }\n\n signalsLatest(tokenId: Int!, filter: SignalFilter): SignalCollection\n # Example - Latest speed and battery charge:\n # query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId) { lastSeen speed{timestamp value} powertrainTractionBatteryStateOfChargeCurrent{timestamp value} } }\n\n availableSignals(tokenId: Int!, filter: SignalFilter): [String!]\n \"Point-in-time snapshot of all accessible signals. Equivalent to availableSignals + signalsLatest in a single request.\"\n signalsSnapshot(tokenId: Int!, filter: SignalFilter): SignalsSnapshotResponse\n # Example - Full snapshot of all signals for a vehicle:\n # query Snapshot($tokenId:Int!) { signalsSnapshot(tokenId:$tokenId) { lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } } } }\n\n dataSummary(tokenId: Int!, filter: SignalFilter): DataSummary\n attestations(tokenId: Int, subject: String, filter: AttestationFilter): [Attestation]\n events(tokenId: Int!, from: Time!, to: Time!, filter: EventFilter): [Event!]\n \"\"\"\n Returns vehicle usage segments detected using the specified mechanism. Maximum\n date range: 31 days.\n Detection mechanisms:\n - ignitionDetection: Uses 'isIgnitionOn' signal with configurable debouncing\n - frequencyAnalysis: Analyzes signal update frequency to detect activity periods\n - changePointDetection: CUSUM-based regime change detection\n - idling: Idling segments (engine rpm idle)\n - refuel: Refueling segments (fuel level increased)\n - recharge: Charging segments (battery SoC increased)\n Segment IDs are stable and consistent across queries as long as the segment\n start is captured in the underlying data source.\n Each segment includes summary: signals, start/end location, and (when requested)\n eventCounts. A default set of signal requests is always applied (e.g. speed,\n odometer; for refuel/recharge also the level signal at start and end). When\n signalRequests is provided, those requests are added on top of the default set;\n duplicates (same name and agg) are omitted.\n \"\"\"\n segments(\n tokenId: Int!\n from: Time!\n to: Time!\n mechanism: DetectionMechanism!\n config: SegmentConfig\n signalRequests: [SegmentSignalRequest!]\n eventRequests: [SegmentEventRequest!]\n \"Maximum number of segments to return. Default 100, max 200.\"\n limit: Int = 100\n after: Time\n ): [Segment!]!\n # Example - Trip segments with start/end locations and signal aggregates:\n # query Trips($tokenId:Int!,$from:Time!,$to:Time!) { segments(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { start{timestamp value{latitude longitude}} end{timestamp value{latitude longitude}} duration isOngoing signals{name agg value} eventCounts{name count} } }\n\n \"\"\"\n Returns one record per calendar day in the date range. Mechanism must be\n ignitionDetection, frequencyAnalysis, or changePointDetection (idling, refuel,\n and recharge not allowed). Maximum date range: 31 days.\n \"\"\"\n dailyActivity(tokenId: Int!, from: Time!, to: Time!, mechanism: DetectionMechanism!, config: SegmentConfig, signalRequests: [SegmentSignalRequest!], eventRequests: [SegmentEventRequest!], timezone: String): [DailyActivity!]!\n # Example - Daily activity summaries:\n # query Daily($tokenId:Int!,$from:Time!,$to:Time!) { dailyActivity(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { segmentCount duration signals{name agg value} eventCounts{name count} } }\n\n \"Required Privileges: [VEHICLE_VIN_CREDENTIAL]\"\n vinVCLatest(tokenId: Int!): VINVC\n}\n\ntype Attestation { id: String!, vehicleTokenId: Int!, time: Time!, attestation: String!, type: String!, source: Address!, dataVersion: String!, producer: String, signature: String!, tags: [String!] }\n\ninput AttestationFilter {\n id: String\n \"The attesting party.\"\n source: Address\n dataVersion: String\n producer: String\n \"Before this timestamp.\"\n before: Time\n \"After this timestamp.\"\n after: Time\n \"Max results. Default 10.\"\n limit: Int\n \"Pagination cursor (exclusive).\"\n cursor: Time\n tags: StringArrayFilter\n}\n\ntype DailyActivity { start: SignalLocation, end: SignalLocation, segmentCount: Int!, duration: Int!, signals: [SignalAggregationValue!]!, eventCounts: [EventCount!]! }\n\ntype DataSummary { numberOfSignals: Uint64!, availableSignals: [String!]!, firstSeen: Time!, lastSeen: Time!, signalDataSummary: [SignalDataSummary!]!, eventDataSummary: [EventDataSummary!]! }\n\nenum DetectionMechanism {\n \"Ignition-based detection: Segments are identified by isIgnitionOn state transitions. Most reliable for vehicles with proper ignition signal support.\"\n ignitionDetection\n \"Frequency analysis: Segments are detected by analyzing signal update patterns. Uses pre-computed materialized view for optimal performance. Ideal for real-time APIs and bulk queries.\"\n frequencyAnalysis\n \"\"\"\n Change point detection: Uses CUSUM algorithm to detect statistical regime\n changes. Monitors cumulative deviation in signal frequency via materialized\n view. Excellent noise resistance with 100% accuracy match to ignition baseline.\n Best alternative when ignition signal is unavailable - same accuracy, same speed\n as frequency analysis.\n \"\"\"\n changePointDetection\n \"Idling: Segments are contiguous periods where engine RPM remains in idle range.\"\n idling\n \"Refuel: Detects where fuel level rises significantly.\"\n refuel\n \"Recharge: Hybrid detection. Uses charging signals and state of charge for detection.\"\n recharge\n}\n\ntype Event { timestamp: Time!, name: String!, source: String!, durationNs: Int!, metadata: String }\n\ntype EventCount { name: String!, count: Int! }\n\ntype EventDataSummary { name: String!, numberOfEvents: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput EventFilter {\n name: StringValueFilter\n \"Source connection that created the event.\"\n source: StringValueFilter\n tags: StringArrayFilter\n}\n\ninput FilterLocation {\n \"Latitude in the range [-90, 90].\"\n latitude: Float!\n \"Longitude in the range [-180, 180].\"\n longitude: Float!\n}\n\nenum FloatAggregation { AVG, MED, MAX, MIN, RAND, FIRST, LAST }\n\ninput InCircleFilter {\n center: FilterLocation!\n \"Radius in kilometers.\"\n radius: Float!\n}\n\ntype LatestSignal { name: String!, timestamp: Time!, valueNumber: Float, valueString: String, valueLocation: Location }\n\ntype Location { latitude: Float!, longitude: Float!, hdop: Float! }\n\nenum LocationAggregation { AVG, RAND, FIRST, LAST }\n\nenum Privilege { VEHICLE_NON_LOCATION_DATA, VEHICLE_COMMANDS, VEHICLE_CURRENT_LOCATION, VEHICLE_ALL_TIME_LOCATION, VEHICLE_VIN_CREDENTIAL, VEHICLE_APPROXIMATE_LOCATION, VEHICLE_RAW_DATA }\n\ntype Segment { start: SignalLocation!, end: SignalLocation, duration: Int!, isOngoing: Boolean!, startedBeforeRange: Boolean!, signals: [SignalAggregationValue!], eventCounts: [EventCount!] }\n\ninput SegmentConfig {\n \"\"\"\n Maximum gap (seconds) between data points before a segment is split. For\n ignitionDetection: filters noise from brief ignition OFF events. For\n frequencyAnalysis: maximum gap between active windows to merge. Default: 300 (5\n minutes), Min: 60, Max: 3600\n \"\"\"\n maxGapSeconds: Int = 300\n \"Minimum segment duration (seconds) to include in results. Filters very short segments (testing, engine cycling). Default: 240 (4 minutes), Min: 60, Max: 3600\"\n minSegmentDurationSeconds: Int = 240\n \"\"\"\n [frequencyAnalysis] Minimum signal count per window for activity detection.\n [idling] Minimum samples per window to consider it idle (same semantics). Higher\n values = more conservative. Lower values = more sensitive. Default: 10, Min: 1,\n Max: 3600\n \"\"\"\n signalCountThreshold: Int = 10\n \"[idling only] Upper bound for idle RPM. Windows with max(RPM) <= this are considered idle. Default: 1000, Min: 300, Max: 3000\"\n maxIdleRpm: Int = 1000\n \"[refuel and recharge only] Minimum percent increase within a window to consider it a level-increase window.\"\n minIncreasePercent: Int = 15\n}\n\ninput SegmentEventRequest { name: String! }\n\ninput SegmentSignalRequest { name: String!, agg: FloatAggregation! }\n\ntype SignalAggregationValue { name: String!, agg: String!, value: Float! }\n\ntype SignalAggregations {\n timestamp: Time!\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalCollection {\n lastSeen: Time\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalDataSummary { name: String!, numberOfSignals: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput SignalFilter {\n \"\"\"\n Filter by source ethr DID. Example:\n \"did:ethr:137:0xcd445F4c6bDAD32b68a2939b912150Fe3C88803E\"\n \"\"\"\n source: String\n}\n\ntype SignalFloat { timestamp: Time!, value: Float! }\n\ninput SignalFloatFilter { eq: Float, neq: Float, gt: Float, lt: Float, gte: Float, lte: Float, notIn: [Float!], in: [Float!], or: [SignalFloatFilter!] }\n\ntype SignalLocation { timestamp: Time!, value: Location! }\n\ninput SignalLocationFilter {\n \"Filter for locations within a polygon. The vertices should be ordered clockwise or counterclockwise, and there must be at least 3. May produce inaccurate results around the poles and the antimeridian.\"\n inPolygon: [FilterLocation!]\n \"Filter for locations within a given distance of a given point. Distances are computed using WGS 84, and points that are exactly a distance `radius` from the `center` will be included.\"\n inCircle: InCircleFilter\n}\n\ntype SignalString { timestamp: Time!, value: String! }\n\ntype SignalsSnapshotResponse { lastSeen: Time, signals: [LatestSignal!]! }\n\nenum StringAggregation {\n \"Randomly select a value from the group.\"\n RAND\n \"Select the most frequently occurring value in the group.\"\n TOP\n \"Return a list of unique values in the group.\"\n UNIQUE\n \"Return value in group associated with the minimum time value.\"\n FIRST\n \"Return value in group associated with the maximum time value.\"\n LAST\n}\n\ninput StringArrayFilter { containsAny: [String!], containsAll: [String!], notContainsAny: [String!], notContainsAll: [String!], or: [StringArrayFilter!] }\n\ninput StringValueFilter {\n eq: String\n neq: String\n notIn: [String!]\n in: [String!]\n \"Matches strings that begin with the given prefix.\"\n startsWith: String\n or: [StringValueFilter!]\n}\n\ntype VINVC { vehicleTokenId: Int, vin: String, recordedBy: String, recordedAt: Time, countryCode: String, vehicleContractAddress: String, validFrom: Time, validTo: Time, rawVC: String! }\n" +var CondensedSchema = "scalar Address # A 20-byte Ethereum address, encoded as a checksummed hex string with 0x prefix.\nscalar Map\nscalar Time # A point in time, encoded per RFC-3339.\nscalar Uint64 # A 64-bit unsigned integer.\n\n# ═══ SIGNAL FIELDS (117 total) ═══\n# All signals below exist on every signal type. Calling convention per type:\n# SignalAggregations:\n# fieldName(agg: LocationAggregation!): Location\n# fieldName(agg: FloatAggregation!, filter: SignalFloatFilter): Float\n# fieldName(agg: LocationAggregation!, filter: SignalLocationFilter): Location\n# fieldName(agg: StringAggregation!): String\n# SignalCollection:\n# fieldName(): SignalLocation\n# fieldName(): SignalFloat\n# fieldName(): SignalString\n# Float is the default type. Location: currentLocationApproximateCoordinates, currentLocationCoordinates. String: obdDTCList, obdFuelTypeName, powertrainCombustionEngineEngineOilLevel, powertrainFuelSystemSupportedFuelTypes, powertrainTransmissionRetarderTorqueMode, powertrainType.\n# | Signal | Unit | Description |\n# |--------|------|-------------|\n# Shared descriptions (blank rows below use these):\n# - Is item open or closed? True = Fully or partially open\n# - Is the belt engaged\n# - Measured Load on axle row 3\n# ── CURRENT (privilege: VEHICLE_ALL_TIME_LOCATION) ──\n# | currentLocationApproximateCoordinates | | Approximate location of the vehicle in WGS 84 coordinates (privilege: VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION) |\n# | currentLocationAltitude | m | Current altitude relative to WGS 84 reference ellipsoid, as measured at the position of GNSS receiver antenna |\n# | currentLocationCoordinates | | Current location of the vehicle in WGS 84 coordinates |\n# | currentLocationHeading | degrees | Current heading relative to geographic north |\n# ── OTHER (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | angularVelocityYaw | degrees/s | Vehicle rotation rate along Z (vertical) |\n# | connectivityCellularIsJammingDetected | | Indicates whether cellular radio signal jamming or interference is detected that prevents normal communication |\n# | exteriorAirTemperature | celsius | Air temperature outside the vehicle |\n# | isIgnitionOn | | Vehicle ignition status |\n# | lowVoltageBatteryCurrentVoltage | V | |\n# | speed | km/h | |\n# ── BODY (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | bodyLightsIsAirbagWarningOn | | Indicates whether the airbag/SRS warning telltale is active |\n# | bodyLockIsLocked | | Indicates whether the vehicle is locked via the central locking system |\n# | bodyTrunkFrontIsOpen | | |\n# | bodyTrunkRearIsOpen | | |\n# ── CABIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | cabinDoorRow1DriverSideIsOpen | | |\n# | cabinDoorRow1DriverSideWindowIsOpen | | |\n# | cabinDoorRow1PassengerSideIsOpen | | |\n# | cabinDoorRow1PassengerSideWindowIsOpen | | |\n# | cabinDoorRow2DriverSideIsOpen | | |\n# | cabinDoorRow2DriverSideWindowIsOpen | | |\n# | cabinDoorRow2PassengerSideIsOpen | | |\n# | cabinDoorRow2PassengerSideWindowIsOpen | | |\n# | cabinSeatRow1DriverSideIsBelted | | |\n# | cabinSeatRow1PassengerSideIsBelted | | |\n# | cabinSeatRow2DriverSideIsBelted | | |\n# | cabinSeatRow2MiddleIsBelted | | |\n# | cabinSeatRow2PassengerSideIsBelted | | |\n# | cabinSeatRow3DriverSideIsBelted | | |\n# | cabinSeatRow3PassengerSideIsBelted | | |\n# ── CHASSIS (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: Rotational speed of a vehicle's wheel\n# shared: Pneumatic pressure in the service brake circuit or reservoir\n# | chassisAxleRow1WheelLeftSpeed | km/h | |\n# | chassisAxleRow1WheelLeftTirePressure | kPa | |\n# | chassisAxleRow1WheelRightSpeed | km/h | |\n# | chassisAxleRow1WheelRightTirePressure | kPa | |\n# | chassisAxleRow2WheelLeftTirePressure | kPa | |\n# | chassisAxleRow2WheelRightTirePressure | kPa | |\n# | chassisAxleRow3Weight | kg | |\n# | chassisAxleRow4Weight | kg | |\n# | chassisAxleRow5Weight | kg | |\n# | chassisBrakeABSIsWarningOn | | Indicates whether the ABS warning telltale is active (any non-off state) |\n# | chassisBrakeCircuit1PressurePrimary | kPa | |\n# | chassisBrakeCircuit2PressurePrimary | kPa | |\n# | chassisBrakeIsPedalPressed | | Indicates whether the brake pedal is pressed |\n# | chassisBrakePedalPosition | percent | Brake pedal position as percent |\n# | chassisParkingBrakeIsEngaged | | |\n# | chassisTireSystemIsWarningOn | | Indicates whether the tire system warning telltale is active |\n# ── OBD (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: PID 2x (byte CD) - Voltage for wide range/band oxygen sensor\n# | obdBarometricPressure | kPa | PID 33 - Barometric pressure |\n# | obdCommandedEGR | percent | PID 2C - Commanded exhaust gas recirculation (EGR) |\n# | obdCommandedEVAP | percent | PID 2E - Commanded evaporative purge (EVAP) valve |\n# | obdDTCList | | List of currently active DTCs formatted according OBD II (SAE-J2012DA_201812) standard ([P|C|B|U]XXXXX ) |\n# | obdDistanceSinceDTCClear | km | PID 31 - Distance traveled since codes cleared |\n# | obdDistanceWithMIL | km | PID 21 - Distance traveled with MIL on |\n# | obdEngineLoad | percent | PID 04 - Engine load in percent - 0 = no load, 100 = full load |\n# | obdEthanolPercent | percent | PID 52 - Percentage of ethanol in the fuel |\n# | obdFuelPressure | kPa | PID 0A - Fuel pressure |\n# | obdFuelRailPressure | kPa | |\n# | obdFuelRate | l/h | PID 5E - Engine fuel rate |\n# | obdFuelTypeName | | Fuel type names decoded from PID 51 |\n# | obdIntakeTemp | celsius | PID 0F - Intake temperature |\n# | obdIsEngineBlocked | | Engine block status, 0 = engine unblocked, 1 = engine blocked |\n# | obdIsPTOActive | | PID 1E - Auxiliary input status (power take off) |\n# | obdIsPluggedIn | | Aftermarket device plugged in status |\n# | obdLongTermFuelTrim1 | percent | PID 07 - Long Term (learned) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdLongTermFuelTrim2 | percent | PID 09 - Long Term (learned) Fuel Trim - Bank 2 - negative percent leaner, positive percent richer |\n# | obdMAP | kPa | PID 0B - Intake manifold pressure |\n# | obdMaxMAF | g/s | PID 50 - Maximum flow for mass air flow sensor |\n# | obdO2WRSensor1Voltage | V | |\n# | obdO2WRSensor2Voltage | V | |\n# | obdOilTemperature | celsius | PID 5C - Engine oil temperature |\n# | obdRunTime | s | PID 1F - Engine run time |\n# | obdShortTermFuelTrim1 | percent | PID 06 - Short Term (immediate) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdStatusDTCCount | | Number of Diagnostic Trouble Codes (DTC) |\n# | obdThrottlePosition | percent | PID 11 - Throttle position - 0 = closed throttle, 100 = open throttle |\n# | obdWarmupsSinceDTCClear | | PID 30 - Number of warm-ups since codes cleared |\n# ── POWERTRAIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | powertrainCombustionEngineDieselExhaustFluidCapacity | l | Capacity in liters of the Diesel Exhaust Fluid Tank |\n# | powertrainCombustionEngineDieselExhaustFluidLevel | percent | Level of the Diesel Exhaust Fluid tank as percent of capacity |\n# | powertrainCombustionEngineECT | celsius | Engine coolant temperature |\n# | powertrainCombustionEngineEOP | kPa | Engine oil pressure |\n# | powertrainCombustionEngineEOT | celsius | Engine oil temperature |\n# | powertrainCombustionEngineEngineOilLevel | | |\n# | powertrainCombustionEngineEngineOilRelativeLevel | percent | Engine oil level as a percentage |\n# | powertrainCombustionEngineMAF | g/s | Grams of air drawn into engine per second |\n# | powertrainCombustionEngineSpeed | rpm | Engine speed measured as rotations per minute |\n# | powertrainCombustionEngineTPS | percent | Current throttle position |\n# | powertrainCombustionEngineTorque | Nm | |\n# | powertrainCombustionEngineTorquePercent | percent | Actual engine output torque as a percentage of reference engine torque (FMS / J1939 parameter SPN 513) |\n# | powertrainFuelSystemAbsoluteLevel | l | Current available fuel in the fuel tank expressed in liters |\n# | powertrainFuelSystemAccumulatedConsumption | l | Accumulated fuel consumption (totalized) reported by the vehicle (FMS SPN 250) |\n# | powertrainFuelSystemRelativeLevel | percent | Level in fuel tank as percent of capacity |\n# | powertrainFuelSystemSupportedFuelTypes | | High level information of fuel types supported |\n# | powertrainRange | km | Remaining range in kilometers using all energy sources available in the vehicle |\n# | powertrainTractionBatteryChargingAddedEnergy | kWh | Amount of charge added to the high voltage battery during the current charging session, expressed in kilowatt-hours |\n# | powertrainTractionBatteryChargingChargeCurrentAC | A | Current AC charging current (rms) at inlet |\n# | powertrainTractionBatteryChargingChargeLimit | percent | Target charge limit (state of charge) for battery |\n# | powertrainTractionBatteryChargingChargeVoltageUnknownType | V | Current charging voltage at inlet |\n# | powertrainTractionBatteryChargingIsCharging | | True if charging is ongoing |\n# | powertrainTractionBatteryChargingIsChargingCableConnected | | Indicates if a charging cable is physically connected to the vehicle or not |\n# | powertrainTractionBatteryChargingPower | kW | Instantaneous charging power recorded during a charging event |\n# | powertrainTractionBatteryCurrentPower | W | Current electrical energy flowing in/out of battery |\n# | powertrainTractionBatteryCurrentVoltage | V | |\n# | powertrainTractionBatteryGrossCapacity | kWh | |\n# | powertrainTractionBatteryRange | km | Remaining range in kilometers using only battery |\n# | powertrainTractionBatteryStateOfChargeCurrent | percent | Physical state of charge of the high voltage battery, relative to net capacity |\n# | powertrainTractionBatteryStateOfChargeCurrentEnergy | kWh | Physical state of charge of high voltage battery expressed in kWh |\n# | powertrainTractionBatteryStateOfHealth | percent | Calculated battery state of health at standard conditions |\n# | powertrainTractionBatteryTemperatureAverage | celsius | Current average temperature of the battery cells |\n# | powertrainTransmissionActualGear | | Actual transmission gear currently engaged |\n# | powertrainTransmissionActualGearRatio | | |\n# | powertrainTransmissionCurrentGear | | |\n# | powertrainTransmissionIsClutchSwitchOperated | | Indicates if the Clutch switch is operated, so engine and transmission are partially or fully decoupled |\n# | powertrainTransmissionRetarderActualTorque | percent | Actual retarder torque as a percentage (FMS / J1939 SPN 520) |\n# | powertrainTransmissionRetarderTorqueMode | | Active engine torque mode |\n# | powertrainTransmissionSelectedGear | | |\n# | powertrainTransmissionTemperature | celsius | The current gearbox temperature |\n# | powertrainTransmissionTravelledDistance | km | Odometer reading, total distance travelled during the lifetime of the transmission |\n# | powertrainType | | Defines the powertrain type of the vehicle |\n# ── SERVICE (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | serviceDistanceToService | km | Remaining distance to service (of any kind) |\n# | serviceTimeToService | s | Remaining time to service (of any kind) |\n\ntype Query {\n signals(\n tokenId: Int!\n \"\"\"\n Duration string for data aggregation buckets (e.g., \"5m\", \"1h\", \"2h45m\"). Valid\n units: ms, s, m, h. Common values: \"5m\" (5 minutes), \"1h\" (1 hour), \"6h\", \"24h\".\n Days are not a valid unit — use \"24h\" instead of \"1d\".\n \"\"\"\n interval: String!\n from: Time!\n to: Time!\n \"\"\"\n of {name, agg} pairs specifying which float signals to aggregate. Required —\n an empty list produces no signal data. Signal names come from `availableSignals`\n or `dataSummary`. Aggregations: AVG, MED, MAX, MIN, RAND, FIRST, LAST.\n \"\"\"\n signalRequests: [SignalAggregationRequest!]!\n filter: SignalFilter\n ): [SignalAggregations!]\n # Example - Hourly average speed and last state of charge:\n # query TS($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to,signalRequests:[{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]) { timestamp signals { name agg value } } }\n\n signalsLatest(\n tokenId: Int!\n \"of signal names to return. Required — an empty list produces no signal data. Signal names come from `availableSignals` or `dataSummary`.\"\n signalNames: [String!]!\n filter: SignalFilter\n ): SignalCollection\n # Example - Latest speed and battery charge by name:\n # query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId,signalNames:[\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]) { lastSeen signals { name timestamp valueNumber } } }\n\n availableSignals(tokenId: Int!, filter: SignalFilter): [String!]\n \"Point-in-time snapshot of all accessible signals. Equivalent to availableSignals + signalsLatest in a single request.\"\n signalsSnapshot(tokenId: Int!, filter: SignalFilter): SignalsSnapshotResponse\n # Example - Full snapshot of all signals for a vehicle:\n # query Snapshot($tokenId:Int!) { signalsSnapshot(tokenId:$tokenId) { lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } } } }\n\n dataSummary(tokenId: Int!, filter: SignalFilter): DataSummary\n attestations(tokenId: Int, subject: String, filter: AttestationFilter): [Attestation]\n events(tokenId: Int!, from: Time!, to: Time!, filter: EventFilter): [Event!]\n \"\"\"\n Returns vehicle usage segments detected using the specified mechanism. Maximum\n date range: 31 days.\n Detection mechanisms:\n - ignitionDetection: Uses 'isIgnitionOn' signal with configurable debouncing\n - frequencyAnalysis: Analyzes signal update frequency to detect activity periods\n - changePointDetection: CUSUM-based regime change detection\n - idling: Idling segments (engine rpm idle)\n - refuel: Refueling segments (fuel level increased)\n - recharge: Charging segments (battery SoC increased)\n Segment IDs are stable and consistent across queries as long as the segment\n start is captured in the underlying data source.\n Each segment includes summary: signals, start/end location, and (when requested)\n eventCounts. A default set of signal requests is always applied (e.g. speed,\n odometer; for refuel/recharge also the level signal at start and end). When\n signalRequests is provided, those requests are added on top of the default set;\n duplicates (same name and agg) are omitted.\n \"\"\"\n segments(\n tokenId: Int!\n from: Time!\n to: Time!\n mechanism: DetectionMechanism!\n config: SegmentConfig\n signalRequests: [SegmentSignalRequest!]\n eventRequests: [SegmentEventRequest!]\n \"Maximum number of segments to return. Default 100, max 200.\"\n limit: Int = 100\n after: Time\n ): [Segment!]!\n # Example - Trip segments with start/end locations and signal aggregates:\n # query Trips($tokenId:Int!,$from:Time!,$to:Time!) { segments(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { start{timestamp value{latitude longitude}} end{timestamp value{latitude longitude}} duration isOngoing signals{name agg value} eventCounts{name count} } }\n\n \"\"\"\n Returns one record per calendar day in the date range. Mechanism must be\n ignitionDetection, frequencyAnalysis, or changePointDetection (idling, refuel,\n and recharge not allowed). Maximum date range: 31 days.\n \"\"\"\n dailyActivity(tokenId: Int!, from: Time!, to: Time!, mechanism: DetectionMechanism!, config: SegmentConfig, signalRequests: [SegmentSignalRequest!], eventRequests: [SegmentEventRequest!], timezone: String): [DailyActivity!]!\n # Example - Daily activity summaries:\n # query Daily($tokenId:Int!,$from:Time!,$to:Time!) { dailyActivity(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { segmentCount duration signals{name agg value} eventCounts{name count} } }\n\n \"Required Privileges: [VEHICLE_VIN_CREDENTIAL]\"\n vinVCLatest(tokenId: Int!): VINVC\n}\n\ntype Attestation { id: String!, vehicleTokenId: Int!, time: Time!, attestation: String!, type: String!, source: Address!, dataVersion: String!, producer: String, signature: String!, tags: [String!] }\n\ninput AttestationFilter {\n id: String\n \"The attesting party.\"\n source: Address\n dataVersion: String\n producer: String\n \"Before this timestamp.\"\n before: Time\n \"After this timestamp.\"\n after: Time\n \"Max results. Default 10.\"\n limit: Int\n \"Pagination cursor (exclusive).\"\n cursor: Time\n tags: StringArrayFilter\n}\n\ntype DailyActivity { start: SignalLocation, end: SignalLocation, segmentCount: Int!, duration: Int!, signals: [SignalAggregationValue!]!, eventCounts: [EventCount!]! }\n\ntype DataSummary { numberOfSignals: Uint64!, availableSignals: [String!]!, firstSeen: Time!, lastSeen: Time!, signalDataSummary: [SignalDataSummary!]!, eventDataSummary: [EventDataSummary!]! }\n\nenum DetectionMechanism {\n \"Ignition-based detection: Segments are identified by isIgnitionOn state transitions. Most reliable for vehicles with proper ignition signal support.\"\n ignitionDetection\n \"Frequency analysis: Segments are detected by analyzing signal update patterns. Uses pre-computed materialized view for optimal performance. Ideal for real-time APIs and bulk queries.\"\n frequencyAnalysis\n \"\"\"\n Change point detection: Uses CUSUM algorithm to detect statistical regime\n changes. Monitors cumulative deviation in signal frequency via materialized\n view. Excellent noise resistance with 100% accuracy match to ignition baseline.\n Best alternative when ignition signal is unavailable - same accuracy, same speed\n as frequency analysis.\n \"\"\"\n changePointDetection\n \"Idling: Segments are contiguous periods where engine RPM remains in idle range.\"\n idling\n \"Refuel: Detects where fuel level rises significantly.\"\n refuel\n \"Recharge: Hybrid detection. Uses charging signals and state of charge for detection.\"\n recharge\n}\n\ntype Event { timestamp: Time!, name: String!, source: String!, durationNs: Int!, metadata: String }\n\ntype EventCount { name: String!, count: Int! }\n\ntype EventDataSummary { name: String!, numberOfEvents: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput EventFilter {\n name: StringValueFilter\n \"Source connection that created the event.\"\n source: StringValueFilter\n tags: StringArrayFilter\n}\n\ninput FilterLocation {\n \"Latitude in the range [-90, 90].\"\n latitude: Float!\n \"Longitude in the range [-180, 180].\"\n longitude: Float!\n}\n\nenum FloatAggregation { AVG, MED, MAX, MIN, RAND, FIRST, LAST }\n\ninput InCircleFilter {\n center: FilterLocation!\n \"Radius in kilometers.\"\n radius: Float!\n}\n\ntype LatestSignal { name: String!, timestamp: Time!, valueNumber: Float, valueString: String, valueLocation: Location }\n\ntype Location { latitude: Float!, longitude: Float!, hdop: Float! }\n\nenum LocationAggregation { AVG, RAND, FIRST, LAST }\n\nenum Privilege { VEHICLE_NON_LOCATION_DATA, VEHICLE_COMMANDS, VEHICLE_CURRENT_LOCATION, VEHICLE_ALL_TIME_LOCATION, VEHICLE_VIN_CREDENTIAL, VEHICLE_APPROXIMATE_LOCATION, VEHICLE_RAW_DATA }\n\ntype Segment { start: SignalLocation!, end: SignalLocation, duration: Int!, isOngoing: Boolean!, startedBeforeRange: Boolean!, signals: [SignalAggregationValue!], eventCounts: [EventCount!] }\n\ninput SegmentConfig {\n \"\"\"\n Maximum gap (seconds) between data points before a segment is split. For\n ignitionDetection: filters noise from brief ignition OFF events. For\n frequencyAnalysis: maximum gap between active windows to merge. Default: 300 (5\n minutes), Min: 60, Max: 3600\n \"\"\"\n maxGapSeconds: Int = 300\n \"Minimum segment duration (seconds) to include in results. Filters very short segments (testing, engine cycling). Default: 240 (4 minutes), Min: 60, Max: 3600\"\n minSegmentDurationSeconds: Int = 240\n \"\"\"\n [frequencyAnalysis] Minimum signal count per window for activity detection.\n [idling] Minimum samples per window to consider it idle (same semantics). Higher\n values = more conservative. Lower values = more sensitive. Default: 10, Min: 1,\n Max: 3600\n \"\"\"\n signalCountThreshold: Int = 10\n \"[idling only] Upper bound for idle RPM. Windows with max(RPM) <= this are considered idle. Default: 1000, Min: 300, Max: 3000\"\n maxIdleRpm: Int = 1000\n \"[refuel and recharge only] Minimum percent increase within a window to consider it a level-increase window.\"\n minIncreasePercent: Int = 15\n}\n\ninput SegmentEventRequest { name: String! }\n\ninput SegmentSignalRequest { name: String!, agg: FloatAggregation! }\n\ninput SignalAggregationRequest { name: String!, agg: FloatAggregation! }\n\ntype SignalAggregationValue { name: String!, agg: String!, value: Float! }\n\ntype SignalAggregations {\n timestamp: Time!\n \"Per-bucket list of {name, agg, value} entries, one per entry in the request's `signalRequests` argument. Populated only when `signalRequests` is supplied.\"\n signals: [SignalAggregationValue!]!\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalCollection {\n lastSeen: Time\n \"Flat list of latest values for the names passed in the request's `signalNames` argument, filtered by caller privileges. Populated only when `signalNames` is supplied.\"\n signals: [LatestSignal!]!\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalDataSummary { name: String!, numberOfSignals: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput SignalFilter {\n \"\"\"\n Filter by source ethr DID. Example:\n \"did:ethr:137:0xcd445F4c6bDAD32b68a2939b912150Fe3C88803E\"\n \"\"\"\n source: String\n}\n\ntype SignalFloat { timestamp: Time!, value: Float! }\n\ninput SignalFloatFilter { eq: Float, neq: Float, gt: Float, lt: Float, gte: Float, lte: Float, notIn: [Float!], in: [Float!], or: [SignalFloatFilter!] }\n\ntype SignalLocation { timestamp: Time!, value: Location! }\n\ninput SignalLocationFilter {\n \"Filter for locations within a polygon. The vertices should be ordered clockwise or counterclockwise, and there must be at least 3. May produce inaccurate results around the poles and the antimeridian.\"\n inPolygon: [FilterLocation!]\n \"Filter for locations within a given distance of a given point. Distances are computed using WGS 84, and points that are exactly a distance `radius` from the `center` will be included.\"\n inCircle: InCircleFilter\n}\n\ntype SignalString { timestamp: Time!, value: String! }\n\ntype SignalsSnapshotResponse { lastSeen: Time, signals: [LatestSignal!]! }\n\nenum StringAggregation {\n \"Randomly select a value from the group.\"\n RAND\n \"Select the most frequently occurring value in the group.\"\n TOP\n \"Return a list of unique values in the group.\"\n UNIQUE\n \"Return value in group associated with the minimum time value.\"\n FIRST\n \"Return value in group associated with the maximum time value.\"\n LAST\n}\n\ninput StringArrayFilter { containsAny: [String!], containsAll: [String!], notContainsAny: [String!], notContainsAll: [String!], or: [StringArrayFilter!] }\n\ninput StringValueFilter {\n eq: String\n neq: String\n notIn: [String!]\n in: [String!]\n \"Matches strings that begin with the given prefix.\"\n startsWith: String\n or: [StringValueFilter!]\n}\n\ntype VINVC { vehicleTokenId: Int, vin: String, recordedBy: String, recordedAt: Time, countryCode: String, vehicleContractAddress: String, validFrom: Time, validTo: Time, rawVC: String! }\n" diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 338685b..1b0c2b9 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -175,6 +175,14 @@ type SegmentSignalRequest struct { Agg FloatAggregation `json:"agg"` } +// Request to compute one float-signal aggregation in a time-series query. +// Shape mirrors SegmentSignalRequest; used by the `signals` query's +// `signalRequests` argument. +type SignalAggregationRequest struct { + Name string `json:"name"` + Agg FloatAggregation `json:"agg"` +} + // Result of aggregating a float signal over an interval. Used by segments and daily activity summaries. // Same shape as one row of aggregated signal data (name, aggregation type, computed value). type SignalAggregationValue struct { @@ -185,6 +193,10 @@ type SignalAggregationValue struct { type SignalCollection struct { LastSeen *time.Time `json:"lastSeen,omitempty"` + // Flat list of latest values for the names passed in the request's + // `signalNames` argument, filtered by caller privileges. Populated only when + // `signalNames` is supplied. + Signals []*LatestSignal `json:"signals"` // Approximate location of the vehicle in WGS 84 coordinates. The raw value is replaced with // the center of the containing H3 cell of resolution 6. HDOP is not obscured at all. // Required Privileges: [VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION] diff --git a/internal/graph/model/signal_aggs.go b/internal/graph/model/signal_aggs.go index 8be8364..8ac08ec 100644 --- a/internal/graph/model/signal_aggs.go +++ b/internal/graph/model/signal_aggs.go @@ -12,6 +12,11 @@ import ( type SignalAggregations struct { Timestamp time.Time `json:"timestamp"` + // Signals holds the {name, agg, value} entries computed from the request's + // signalRequests argument. One entry per supplied request that produced a + // value in this bucket and that the caller has permission to see. + Signals []*SignalAggregationValue `json:"signals"` + // Alias to value ValueNumbers map[string]float64 `json:"-"` // Alias to value diff --git a/internal/graph/signal_requests.go b/internal/graph/signal_requests.go new file mode 100644 index 0000000..ae3279e --- /dev/null +++ b/internal/graph/signal_requests.go @@ -0,0 +1,122 @@ +package graph + +import ( + "context" + + "github.com/DIMO-Network/telemetry-api/internal/auth" + "github.com/DIMO-Network/telemetry-api/internal/graph/model" +) + +// reqAliasPrefix namespaces aliases derived from the signalRequests argument so +// they cannot collide with aliases built from the GraphQL selection set by +// aggregationArgsFromContext. +const reqAliasPrefix = "__req::" + +func reqAlias(req *model.SignalAggregationRequest) string { + return reqAliasPrefix + req.Name + "::" + string(req.Agg) +} + +// appendSignalRequestArgs appends a FloatSignalArgs entry for every privileged +// signalRequest, returning the filtered requests and their aliases in input +// order so the resolver can map query output back to the correct entry. +func appendSignalRequestArgs(aggArgs *model.AggregatedSignalArgs, requests []*model.SignalAggregationRequest, permissions []string) ([]*model.SignalAggregationRequest, []string) { + allowed := make([]*model.SignalAggregationRequest, 0, len(requests)) + aliases := make([]string, 0, len(requests)) + for _, req := range requests { + if req == nil { + continue + } + if !hasPrivilegesForSignal(req.Name, permissions) { + continue + } + alias := reqAlias(req) + aggArgs.FloatArgs = append(aggArgs.FloatArgs, model.FloatSignalArgs{ + Name: req.Name, + Agg: req.Agg, + Alias: alias, + }) + allowed = append(allowed, req) + aliases = append(aliases, alias) + } + return allowed, aliases +} + +// populateAggregationSignals fills each bucket's Signals slice from the aliased +// FloatArgs entries that came from signalRequests. Request order is preserved. +func populateAggregationSignals(buckets []*model.SignalAggregations, requests []*model.SignalAggregationRequest, aliases []string) { + if len(requests) == 0 { + return + } + for _, bucket := range buckets { + if bucket == nil { + continue + } + signals := make([]*model.SignalAggregationValue, 0, len(requests)) + for i, req := range requests { + v, ok := bucket.ValueNumbers[aliases[i]] + if !ok { + continue + } + signals = append(signals, &model.SignalAggregationValue{ + Name: req.Name, + Agg: string(req.Agg), + Value: v, + }) + } + bucket.Signals = signals + } +} + +// filterSignalNamesByPrivilege drops names the caller is not allowed to see and +// deduplicates while preserving input order. +func filterSignalNamesByPrivilege(names []string, permissions []string) []string { + if len(names) == 0 { + return nil + } + seen := make(map[string]struct{}, len(names)) + out := make([]string, 0, len(names)) + for _, n := range names { + if n == "" { + continue + } + if _, dup := seen[n]; dup { + continue + } + if !hasPrivilegesForSignal(n, permissions) { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + +// filterSnapshotByName picks entries from a snapshot result whose names match +// the requested set, preserving the order of the names slice. +func filterSnapshotByName(snapshot []*model.LatestSignal, names []string) []*model.LatestSignal { + if len(names) == 0 || len(snapshot) == 0 { + return []*model.LatestSignal{} + } + byName := make(map[string]*model.LatestSignal, len(snapshot)) + for _, sig := range snapshot { + if sig == nil { + continue + } + byName[sig.Name] = sig + } + out := make([]*model.LatestSignal, 0, len(names)) + for _, name := range names { + if sig, ok := byName[name]; ok { + out = append(out, sig) + } + } + return out +} + +func permissionsFromContext(ctx context.Context) []string { + claim, _ := ctx.Value(auth.TelemetryClaimContextKey{}).(*auth.TelemetryClaim) + if claim == nil { + return nil + } + return claim.Permissions +} diff --git a/schema/base.graphqls b/schema/base.graphqls index e86efeb..c4f2daa 100644 --- a/schema/base.graphqls +++ b/schema/base.graphqls @@ -33,14 +33,29 @@ type Query { interval: String! from: Time! to: Time! + """ + List of {name, agg} pairs specifying which float signals to aggregate. + Required — an empty list produces no signal data. Signal names come from + `availableSignals` or `dataSummary`. Aggregations: AVG, MED, MAX, MIN, + RAND, FIRST, LAST. + """ + signalRequests: [SignalAggregationRequest!]! filter: SignalFilter ): [SignalAggregations!] @requiresVehicleToken - @mcpTool(name: "get_signals_time_series", description: "Get aggregated signal time series for a vehicle over a date range. Returns signal values bucketed by the specified interval (e.g. '1h', '15m'). Use with signal field names and aggregation functions.", selection: "timestamp") - @mcpExample(description: "Hourly average speed over a time range", query: "query TimeSeries($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to) { timestamp speed(agg:AVG) } }") - signalsLatest(tokenId: Int!, filter: SignalFilter): SignalCollection + @mcpTool(name: "get_signals_time_series", description: "Get aggregated time series for a named list of float signals. Pass signalRequests as an array of {name, agg} objects (e.g. [{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]). Returns one bucket per interval with signals as [{name, agg, value}]. Signal names come from get_available_signals or get_data_summary. Aggregations: AVG, MED, MAX, MIN, RAND, FIRST, LAST.", selection: "timestamp signals { name agg value }") + @mcpExample(description: "Hourly average speed and last state of charge", query: "query TS($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to,signalRequests:[{name:\"speed\",agg:AVG},{name:\"powertrainTractionBatteryStateOfChargeCurrent\",agg:LAST}]) { timestamp signals { name agg value } } }") + signalsLatest( + tokenId: Int! + """ + List of signal names to return. Required — an empty list produces no + signal data. Signal names come from `availableSignals` or `dataSummary`. + """ + signalNames: [String!]! + filter: SignalFilter + ): SignalCollection @requiresVehicleToken - @mcpTool(name: "get_latest_signals", description: "Get the most recent signal values for a vehicle by token ID. Returns the last-seen timestamp for the vehicle.", selection: "lastSeen") - @mcpExample(description: "Latest speed and battery charge", query: "query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId) { lastSeen speed{timestamp value} powertrainTractionBatteryStateOfChargeCurrent{timestamp value} } }") + @mcpTool(name: "get_latest_signals", description: "Get the most recent value for a named list of signals. Pass signalNames as an array of strings (e.g. [\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]). Signal names come from get_available_signals or get_data_summary. Returns lastSeen plus a signals list, each entry with one of valueNumber, valueString, or valueLocation populated depending on signal type.", selection: "lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } }") + @mcpExample(description: "Latest speed and battery charge by name", query: "query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId,signalNames:[\"speed\",\"powertrainTractionBatteryStateOfChargeCurrent\"]) { lastSeen signals { name timestamp valueNumber } } }") availableSignals(tokenId: Int!, filter: SignalFilter): [String!] @requiresVehicleToken @mcpTool(name: "get_available_signals", description: "List queryable signal names that have stored data for a vehicle by token ID.", selection: "") @@ -60,6 +75,11 @@ type Query { type SignalAggregations { timestamp: Time! """ + Per-bucket list of {name, agg, value} entries, one per entry in the request's + `signalRequests` argument. Populated only when `signalRequests` is supplied. + """ + signals: [SignalAggregationValue!]! + """ Approximate location of the vehicle in WGS 84 coordinates. The aggregation is applied to the raw location values and the result is then replaced with the center of the containing H3 cell of resolution 6. HDOP is not obscured at all. @@ -77,6 +97,12 @@ type SignalAggregations { type SignalCollection { lastSeen: Time """ + Flat list of latest values for the names passed in the request's + `signalNames` argument, filtered by caller privileges. Populated only when + `signalNames` is supplied. + """ + signals: [LatestSignal!]! + """ Approximate location of the vehicle in WGS 84 coordinates. The raw value is replaced with the center of the containing H3 cell of resolution 6. HDOP is not obscured at all. Required Privileges: [VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION] @@ -209,6 +235,16 @@ type Location { hdop: Float! } +""" +Request to compute one float-signal aggregation in a time-series query. +Shape mirrors SegmentSignalRequest; used by the `signals` query's +`signalRequests` argument. +""" +input SignalAggregationRequest { + name: String! + agg: FloatAggregation! +} + """ Result of aggregating a float signal over an interval. Used by segments and daily activity summaries. Same shape as one row of aggregated signal data (name, aggregation type, computed value).