Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions internal/graph/base.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

244 changes: 227 additions & 17 deletions internal/graph/generated.go

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions internal/graph/mcp_tools_gen.go

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions internal/graph/model/models_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/graph/model/signal_aggs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions internal/graph/signal_requests.go
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 41 additions & 5 deletions schema/base.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")
Expand All @@ -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.
Expand 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]
Expand Down Expand Up @@ -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).
Expand Down
Loading