@butlr/butlr-mcp-server 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/cache/topology-cache.d.ts +6 -6
  3. package/dist/cache/topology-cache.js +6 -6
  4. package/dist/clients/queries/topology.d.ts +14 -0
  5. package/dist/clients/queries/topology.d.ts.map +1 -1
  6. package/dist/clients/queries/topology.js +48 -0
  7. package/dist/clients/queries/topology.js.map +1 -1
  8. package/dist/clients/reporting-client.d.ts +10 -0
  9. package/dist/clients/reporting-client.d.ts.map +1 -1
  10. package/dist/clients/reporting-client.js +11 -5
  11. package/dist/clients/reporting-client.js.map +1 -1
  12. package/dist/clients/types.d.ts +1 -1
  13. package/dist/clients/types.d.ts.map +1 -1
  14. package/dist/tools/butlr-list-topology.d.ts.map +1 -1
  15. package/dist/tools/butlr-list-topology.js +7 -53
  16. package/dist/tools/butlr-list-topology.js.map +1 -1
  17. package/dist/tools/butlr-search-assets.d.ts +1 -0
  18. package/dist/tools/butlr-search-assets.d.ts.map +1 -1
  19. package/dist/tools/butlr-search-assets.js +59 -15
  20. package/dist/tools/butlr-search-assets.js.map +1 -1
  21. package/dist/tools/butlr-traffic-flow.d.ts.map +1 -1
  22. package/dist/tools/butlr-traffic-flow.js +257 -59
  23. package/dist/tools/butlr-traffic-flow.js.map +1 -1
  24. package/dist/types/responses.d.ts +2 -1
  25. package/dist/types/responses.d.ts.map +1 -1
  26. package/dist/utils/asset-flattener.d.ts.map +1 -1
  27. package/dist/utils/asset-flattener.js +15 -3
  28. package/dist/utils/asset-flattener.js.map +1 -1
  29. package/dist/utils/graphql-helpers.d.ts +26 -1
  30. package/dist/utils/graphql-helpers.d.ts.map +1 -1
  31. package/dist/utils/graphql-helpers.js +30 -5
  32. package/dist/utils/graphql-helpers.js.map +1 -1
  33. package/dist/utils/occupancy-helpers.d.ts +72 -3
  34. package/dist/utils/occupancy-helpers.d.ts.map +1 -1
  35. package/dist/utils/occupancy-helpers.js +129 -18
  36. package/dist/utils/occupancy-helpers.js.map +1 -1
  37. package/dist/utils/time-range-validator.d.ts.map +1 -1
  38. package/dist/utils/time-range-validator.js +3 -32
  39. package/dist/utils/time-range-validator.js.map +1 -1
  40. package/dist/utils/time-resolver.d.ts +18 -0
  41. package/dist/utils/time-resolver.d.ts.map +1 -0
  42. package/dist/utils/time-resolver.js +44 -0
  43. package/dist/utils/time-resolver.js.map +1 -0
  44. package/dist/utils/topology-merge.d.ts +24 -0
  45. package/dist/utils/topology-merge.d.ts.map +1 -0
  46. package/dist/utils/topology-merge.js +57 -0
  47. package/dist/utils/topology-merge.js.map +1 -0
  48. package/llms.txt +1 -1
  49. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { apolloClient } from "../clients/graphql-client.js";
2
2
  import { z } from "zod";
3
- import { GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
3
+ import { GET_ALL_HIVES, GET_ALL_SENSORS, GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
4
4
  import { getCachedTopology, setCachedTopology, generateTopologyCacheKey, } from "../cache/topology-cache.js";
5
5
  import { flattenTopology } from "../utils/asset-flattener.js";
6
6
  import { searchAssets } from "../utils/fuzzy-match.js";
7
7
  import { buildAssetPath } from "../utils/path-builder.js";
8
- import { rethrowIfGraphQLError } from "../utils/graphql-helpers.js";
8
+ import { isProductionHive, isProductionSensor, rethrowIfGraphQLError, throwIfGraphQLErrors, } from "../utils/graphql-helpers.js";
9
+ import { mergeSensorsAndHivesIntoTopology } from "../utils/topology-merge.js";
9
10
  import { debug } from "../utils/debug.js";
10
11
  import { withToolErrorHandling } from "../errors/mcp-errors.js";
11
12
  const VALID_ASSET_TYPES = ["site", "building", "floor", "room", "zone", "sensor", "hive"];
@@ -85,15 +86,18 @@ export async function executeSearchAssets(args) {
85
86
  debug("search-assets", `Searching for "${args.query}"` +
86
87
  (args.asset_types ? ` in types: ${args.asset_types.join(",")}` : "") +
87
88
  ` (max: ${maxResults})`);
88
- // Use a generic cache key for full topology (we'll search across it).
89
- // `devicesMerged: false` because this tool intentionally caches the raw
90
- // `sites` tree without running mergeSensorsAndHivesIntoTopology — flattening
91
- // for fuzzy search doesn't need the per-floor sensors/hives arrays. The
92
- // distinct cache key prevents `butlr_list_topology` from reading this
93
- // device-incomplete shape and silently dropping device-level matches.
89
+ // `devicesMerged: true`: sensors and hives are merged onto their floors
90
+ // before flattening, because `flattenTopology` reads devices from
91
+ // `floor.sensors`/`floor.hives` and `GET_FULL_TOPOLOGY` selects neither.
92
+ // Without the merge this tool's corpus holds zero sensors and zero hives,
93
+ // while `VALID_ASSET_TYPES` advertises both and `butlr_hardware_snapshot`
94
+ // tells callers to find sensors here first.
95
+ //
96
+ // This is the same key `butlr_list_topology` writes, deliberately: both now
97
+ // hold the merged shape, so either can prime the other.
94
98
  const cacheKey = generateTopologyCacheKey(process.env.BUTLR_ORG_ID || "default", true, // include devices for comprehensive search
95
99
  true, // include zones
96
- false, // devicesMerged: search_assets does not merge sensors/hives
100
+ true, // devicesMerged: devices are nested onto their floors before flattening
97
101
  undefined);
98
102
  // Try to get cached topology
99
103
  let sites = [];
@@ -106,10 +110,20 @@ export async function executeSearchAssets(args) {
106
110
  // Fetch fresh topology
107
111
  debug("search-assets", "Fetching fresh topology for search");
108
112
  try {
109
- const result = await apolloClient.query({
110
- query: GET_FULL_TOPOLOGY,
111
- fetchPolicy: "network-only",
112
- });
113
+ const [result, sensorsResult, hivesResult] = await Promise.all([
114
+ apolloClient.query({
115
+ query: GET_FULL_TOPOLOGY,
116
+ fetchPolicy: "network-only",
117
+ }),
118
+ apolloClient.query({
119
+ query: GET_ALL_SENSORS,
120
+ fetchPolicy: "network-only",
121
+ }),
122
+ apolloClient.query({
123
+ query: GET_ALL_HIVES,
124
+ fetchPolicy: "network-only",
125
+ }),
126
+ ]);
113
127
  // Apollo can return both data and errors - only fail if we have no data
114
128
  if (!result.data || !result.data.sites || !result.data.sites.data) {
115
129
  // If we have error but no data, throw
@@ -118,12 +132,41 @@ export async function executeSearchAssets(args) {
118
132
  }
119
133
  throw new Error("Invalid response structure from API");
120
134
  }
121
- // Track whether the topology data is partial (errors alongside data)
122
- const partialData = !!result.error;
135
+ // A failed device fetch must surface as an error, not as
136
+ // `total_matches: 0` — and must never be cached under the key
137
+ // butlr_list_topology trusts.
138
+ throwIfGraphQLErrors(sensorsResult);
139
+ throwIfGraphQLErrors(hivesResult);
140
+ // Track whether the topology data is partial (errors alongside data).
141
+ // A device query that errored counts too: caching a tree whose floors
142
+ // carry empty sensor arrays would make every later cache hit report
143
+ // "no sensors in this org" as if it were the answer.
144
+ const partialData = !!result.error || !!sensorsResult.error || !!hivesResult.error;
123
145
  if (partialData) {
124
146
  debug("search-assets", "Warning: GraphQL errors present, data may be partial");
125
147
  }
126
148
  sites = result.data.sites.data;
149
+ // Same shape contract as butlr_list_topology, which reads the cache
150
+ // entry this tool writes: only an explicit array survives. `|| []`
151
+ // here would launder a serialisation regression into a device-empty
152
+ // tree, cached where that tool's own contract check never runs.
153
+ const rawSensors = sensorsResult.data?.sensors?.data;
154
+ if (!Array.isArray(rawSensors)) {
155
+ throw new Error("Unexpected response shape from sensors query (expected array, got " +
156
+ `${rawSensors === null ? "null" : typeof rawSensors}). Please retry; if persistent, the upstream API contract may have changed.`);
157
+ }
158
+ const rawHives = hivesResult.data?.hives?.data;
159
+ if (!Array.isArray(rawHives)) {
160
+ throw new Error("Unexpected response shape from hives query (expected array, got " +
161
+ `${rawHives === null ? "null" : typeof rawHives}). Please retry; if persistent, the upstream API contract may have changed.`);
162
+ }
163
+ // Test/mirror devices are excluded from the corpus, matching
164
+ // butlr_list_topology. A search hit on a mirror sensor would hand the
165
+ // caller an ID that every data tool then refuses.
166
+ const allSensors = rawSensors.filter(isProductionSensor);
167
+ const allHives = rawHives.filter(isProductionHive);
168
+ sites = mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives);
169
+ debug("search-assets", `Merged ${allSensors.length} sensors and ${allHives.length} hives into topology`);
127
170
  // Only cache complete topology data — partial results should be re-fetched
128
171
  if (!partialData) {
129
172
  setCachedTopology(cacheKey, { sites });
@@ -162,6 +205,7 @@ export async function executeSearchAssets(args) {
162
205
  type: match.asset.type,
163
206
  path: buildAssetPath(match.asset),
164
207
  match_score: match.score,
208
+ ...(match.asset.type === "sensor" && match.asset.mode ? { mode: match.asset.mode } : {}),
165
209
  site_id: match.asset.site_id,
166
210
  building_id: match.asset.building_id,
167
211
  floor_id: match.asset.floor_id,
@@ -1 +1 @@
1
- {"version":3,"file":"butlr-search-assets.js","sourceRoot":"","sources":["../../src/tools/butlr-search-assets.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"butlr-search-assets.js","sourceRoot":"","sources":["../../src/tools/butlr-search-assets.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAEnG,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,eAAe,EAAuB,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,YAAY,EAAwB,MAAM,yBAAyB,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,gCAAgC,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAEhE,MAAM,iBAAiB,GAAG,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAU,CAAC;AAEnG,gFAAgF;AAChF,MAAM,sBAAsB,GAAG;IAC7B,KAAK,EAAE,CAAC;SACL,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,EAAE,uBAAuB,CAAC;SAC/B,GAAG,CAAC,GAAG,EAAE,iCAAiC,CAAC;SAC3C,IAAI,EAAE;SACN,MAAM,CACL,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,IAAI,CAAC,EACxB,iEAAiE,CAClE;SACA,QAAQ,CAAC,0CAA0C,CAAC;IAEvD,WAAW,EAAE,CAAC;SACX,KAAK,CACJ,CAAC,CAAC,IAAI,CAAC,iBAAiB,EAAE;QACxB,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;YACf,OAAO,EAAE,8BAA8B,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;SACtE,CAAC;KACH,CAAC,CACH;SACA,GAAG,CAAC,CAAC,EAAE,oEAAoE,CAAC;SAC5E,GAAG,CAAC,iBAAiB,CAAC,MAAM,CAAC;SAC7B,QAAQ,EAAE;SACV,QAAQ,CAAC,0CAA0C,CAAC;IAEvD,WAAW,EAAE,CAAC;SACX,MAAM,EAAE;SACR,GAAG,CAAC,gCAAgC,CAAC;SACrC,GAAG,CAAC,CAAC,EAAE,gCAAgC,CAAC;SACxC,GAAG,CAAC,GAAG,EAAE,+BAA+B,CAAC;SACzC,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,qCAAqC,CAAC;CACnD,CAAC;AAEF,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC;KACpC,MAAM,CAAC,sBAAsB,CAAC;KAC9B,MAAM,EAAE;KACR,MAAM,CACL,CAAC,IAAI,EAAE,EAAE;IACP,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACzC,OAAO,MAAM,CAAC,IAAI,KAAK,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC;IACjD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC,EACD;IACE,OAAO,EAAE,uCAAuC;IAChD,IAAI,EAAE,CAAC,aAAa,CAAC;CACtB,CACF,CAAC;AAEJ,MAAM,yBAAyB,GAC7B,8VAA8V;IAC9V,kBAAkB;IAClB,gFAAgF;IAChF,iEAAiE;IACjE,wFAAwF;IACxF,4FAA4F;IAC5F,oBAAoB;IACpB,qDAAqD;IACrD,oFAAoF;IACpF,4DAA4D;IAC5D,yFAAyF;IACzF,gFAAgF;IAChF,0EAA0E;IAC1E,2EAA2E;IAC3E,gFAAgF;IAChF,gBAAgB;IAChB,+EAA+E;IAC/E,+DAA+D;IAC/D,gFAAgF;IAChF,6DAA6D;IAC7D,0FAA0F;IAC1F,oBAAoB;IACpB,0FAA0F;IAC1F,oFAAoF;IACpF,uGAAuG;IACvG,2KAA2K;IAC3K,wIAAwI;IACxI,oFAAoF,CAAC;AAwBvF;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,IAAsB;IAC9D,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,CAAC;IAEpC,KAAK,CACH,eAAe,EACf,kBAAkB,IAAI,CAAC,KAAK,GAAG;QAC7B,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,cAAc,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACpE,UAAU,UAAU,GAAG,CAC1B,CAAC;IAEF,wEAAwE;IACxE,kEAAkE;IAClE,yEAAyE;IACzE,0EAA0E;IAC1E,0EAA0E;IAC1E,4CAA4C;IAC5C,EAAE;IACF,4EAA4E;IAC5E,wDAAwD;IACxD,MAAM,QAAQ,GAAG,wBAAwB,CACvC,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,SAAS,EACrC,IAAI,EAAE,2CAA2C;IACjD,IAAI,EAAE,gBAAgB;IACtB,IAAI,EAAE,wEAAwE;IAC9E,SAAS,CACV,CAAC;IAEF,6BAA6B;IAC7B,IAAI,KAAK,GAAW,EAAE,CAAC;IACvB,MAAM,MAAM,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAE3C,IAAI,MAAM,IAAI,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/C,KAAK,CAAC,eAAe,EAAE,kCAAkC,CAAC,CAAC;QAC3D,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAe,CAAC;IACtC,CAAC;SAAM,CAAC;QACN,uBAAuB;QACvB,KAAK,CAAC,eAAe,EAAE,oCAAoC,CAAC,CAAC;QAE7D,IAAI,CAAC;YACH,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,WAAW,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;gBAC7D,YAAY,CAAC,KAAK,CAA2B;oBAC3C,KAAK,EAAE,iBAAiB;oBACxB,WAAW,EAAE,cAAc;iBAC5B,CAAC;gBACF,YAAY,CAAC,KAAK,CAAkC;oBAClD,KAAK,EAAE,eAAe;oBACtB,WAAW,EAAE,cAAc;iBAC5B,CAAC;gBACF,YAAY,CAAC,KAAK,CAA8B;oBAC9C,KAAK,EAAE,aAAa;oBACpB,WAAW,EAAE,cAAc;iBAC5B,CAAC;aACH,CAAC,CAAC;YAEH,wEAAwE;YACxE,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;gBAClE,sCAAsC;gBACtC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;oBACjB,MAAM,MAAM,CAAC,KAAK,CAAC;gBACrB,CAAC;gBACD,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;YACzD,CAAC;YAED,yDAAyD;YACzD,8DAA8D;YAC9D,8BAA8B;YAC9B,oBAAoB,CAAC,aAAa,CAAC,CAAC;YACpC,oBAAoB,CAAC,WAAW,CAAC,CAAC;YAElC,sEAAsE;YACtE,sEAAsE;YACtE,oEAAoE;YACpE,qDAAqD;YACrD,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,aAAa,CAAC,KAAK,IAAI,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC;YACnF,IAAI,WAAW,EAAE,CAAC;gBAChB,KAAK,CAAC,eAAe,EAAE,sDAAsD,CAAC,CAAC;YACjF,CAAC;YAED,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;YAE/B,oEAAoE;YACpE,mEAAmE;YACnE,oEAAoE;YACpE,gEAAgE;YAChE,MAAM,UAAU,GAAG,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;YACrD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,KAAK,CACb,oEAAoE;oBAClE,GAAG,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,UAAU,6EAA6E,CACnI,CAAC;YACJ,CAAC;YACD,MAAM,QAAQ,GAAG,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC;YAC/C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC7B,MAAM,IAAI,KAAK,CACb,kEAAkE;oBAChE,GAAG,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,QAAQ,6EAA6E,CAC/H,CAAC;YACJ,CAAC;YAED,6DAA6D;YAC7D,sEAAsE;YACtE,kDAAkD;YAClD,MAAM,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;YACzD,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;YACnD,KAAK,GAAG,gCAAgC,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;YAEtE,KAAK,CACH,eAAe,EACf,UAAU,UAAU,CAAC,MAAM,gBAAgB,QAAQ,CAAC,MAAM,sBAAsB,CACjF,CAAC;YAEF,2EAA2E;YAC3E,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,iBAAiB,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;gBACvC,KAAK,CAAC,eAAe,EAAE,wBAAwB,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;YACvE,CAAC;iBAAM,CAAC;gBACN,KAAK,CAAC,eAAe,EAAE,2CAA2C,CAAC,CAAC;YACtE,CAAC;QACH,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,qBAAqB,CAAC,KAAK,CAAC,CAAC;YAC7B,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,wCAAwC;IACxC,MAAM,SAAS,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;IAEzC,KAAK,CAAC,eAAe,EAAE,aAAa,SAAS,CAAC,MAAM,eAAe,CAAC,CAAC;IAErE,qCAAqC;IACrC,IAAI,gBAAgB,GAAG,SAAS,CAAC;IACjC,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpD,gBAAgB,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;QAEvF,KAAK,CACH,eAAe,EACf,eAAe,gBAAgB,CAAC,MAAM,qBAAqB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CACxF,CAAC;IACJ,CAAC;IAED,uBAAuB;IACvB,kEAAkE;IAClE,MAAM,OAAO,GAAG,YAAY,CAC1B,gBAAwD,EACxD,IAAI,CAAC,KAAK,EACV;QACE,WAAW,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,cAAc,CAAC;QACpD,QAAQ,EAAE,EAAE;QACZ,UAAU;KACX,CACF,CAAC;IAEF,KAAK,CAAC,eAAe,EAAE,SAAS,OAAO,CAAC,MAAM,UAAU,CAAC,CAAC;IAE1D,qCAAqC;IACrC,MAAM,OAAO,GAAmB,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACtD,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE;QAClB,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI;QACtB,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI;QACtB,IAAI,EAAE,cAAc,CAAC,KAAK,CAAC,KAAK,CAAC;QACjC,WAAW,EAAE,KAAK,CAAC,KAAK;QACxB,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACxF,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,OAA6B;QAClD,WAAW,EAAE,KAAK,CAAC,KAAK,CAAC,WAAiC;QAC1D,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,QAA8B;QACpD,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,OAA6B;KACnD,CAAC,CAAC,CAAC;IAEJ,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,OAAO,EAAE,OAAO;QAChB,aAAa,EAAE,OAAO,CAAC,MAAM;QAC7B,eAAe,EAAE,gBAAgB,CAAC,MAAM;QACxC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAiB;IACpD,MAAM,CAAC,YAAY,CACjB,qBAAqB,EACrB;QACE,KAAK,EAAE,qBAAqB;QAC5B,WAAW,EAAE,yBAAyB;QACtC,WAAW,EAAE,sBAAsB;QACnC,WAAW,EAAE;YACX,YAAY,EAAE,IAAI;YAClB,eAAe,EAAE,KAAK;YACtB,cAAc,EAAE,IAAI;YACpB,aAAa,EAAE,KAAK;SACrB;KACF,EACD,qBAAqB,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;QACnC,MAAM,SAAS,GAAG,sBAAsB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACrD,MAAM,MAAM,GAAG,MAAM,mBAAmB,CAAC,SAAS,CAAC,CAAC;QACpD,OAAO;YACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;SAC5E,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"butlr-traffic-flow.d.ts","sourceRoot":"","sources":["../../src/tools/butlr-traffic-flow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AA0CjE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;EAc/B,CAAC;AA+BJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAwBrE;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,eAAe,gCA6R7D;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAsB3D"}
1
+ {"version":3,"file":"butlr-traffic-flow.d.ts","sourceRoot":"","sources":["../../src/tools/butlr-traffic-flow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAyBxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AA4CjE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;EAc/B,CAAC;AAiCJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,qBAAqB,CAAC,CAAC;AA2CrE;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,eAAe,gCAif7D;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAsB3D"}
@@ -2,11 +2,13 @@ import { apolloClient } from "../clients/graphql-client.js";
2
2
  import { gql } from "@apollo/client";
3
3
  import { z } from "zod";
4
4
  import { ReportingRequestBuilder } from "../clients/reporting-client.js";
5
- import { GET_ALL_SENSORS, GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
6
5
  import { executeSearchAssets } from "./butlr-search-assets.js";
7
6
  import { getTimezoneForAsset, buildTimezoneMetadata, getLocalMidnight, } from "../utils/timezone-helpers.js";
8
7
  import { createValidationError, withToolErrorHandling } from "../errors/mcp-errors.js";
9
- import { rethrowIfGraphQLError, throwIfGraphQLErrors } from "../utils/graphql-helpers.js";
8
+ import { resolveTimeToIso } from "../utils/time-resolver.js";
9
+ import { rethrowIfGraphQLError, throwIfGraphQLErrors, isKnownTestSensor, } from "../utils/graphql-helpers.js";
10
+ import { fetchTopology, fetchSensorsByIds, fetchSensorsByRoomIds, resolveSensorContext, } from "../utils/occupancy-helpers.js";
11
+ import { detectAssetType } from "../utils/asset-helpers.js";
10
12
  import { debug } from "../utils/debug.js";
11
13
  const timeStringSchema = z.string().refine((val) => {
12
14
  const isoMatch = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(val);
@@ -23,7 +25,7 @@ const trafficFlowInputShape = {
23
25
  .min(1, "space_id_or_name cannot be empty")
24
26
  .max(200)
25
27
  .trim()
26
- .describe("Space ID or search term"),
28
+ .describe("Room ID (room_...), sensor ID (sensor_...) for per-access-point counts, or a name search term (matches rooms and traffic sensors)"),
27
29
  time_window: z
28
30
  .enum(["20m", "1h", "today", "custom"])
29
31
  .default("today")
@@ -52,6 +54,7 @@ export const TrafficFlowArgsSchema = z
52
54
  path: ["custom_start"],
53
55
  });
54
56
  const TRAFFIC_FLOW_DESCRIPTION = "Get entry and exit counts for spaces equipped with traffic-mode sensors (typically lobbies, building entrances, elevator banks). Returns total movements, net flow (entries - exits), and hourly breakdown in the space's local timezone. Designed for space activation analysis, security/compliance, and amenity demand forecasting.\n\n" +
57
+ "Accepts a room ID (counts aggregate across the room's traffic sensors), a single sensor ID (sensor_...) for per-access-point counts, or a name to search. Sensors do not need to be assigned to a room to be queried directly, and a name search matches sensors as well as rooms, so an access point does not have to be modeled as a room. (Name search covers devices assigned to a floor; a floor-less or not-yet-MAC-bound sensor is still queryable by its sensor_... ID.)\n\n" +
55
58
  "Primary Users:\n" +
56
59
  "- Facilities Manager: Monitor building entry/exit patterns, optimize security staffing, validate badge system accuracy\n" +
57
60
  "- Workplace Manager: Understand amenity traffic (café, gym, event spaces), measure activation of new spaces\n" +
@@ -65,7 +68,8 @@ const TRAFFIC_FLOW_DESCRIPTION = "Get entry and exit counts for spaces equipped
65
68
  '5. "Show me lobby traffic for the last 20 minutes"\n' +
66
69
  '6. "What\'s the net flow (entries - exits) for Floor 2 today?"\n' +
67
70
  '7. "How many people entered the event space during lunch hour (12-1pm)?"\n' +
68
- '8. "Compare today\'s building entrance traffic to typical Monday"\n\n' +
71
+ '8. "Compare today\'s building entrance traffic to typical Monday"\n' +
72
+ '9. "How many people came through the north elevator door today?" (a single sensor, by name or by sensor_... ID)\n\n' +
69
73
  "When to Use:\n" +
70
74
  "- Entry/exit counts for lobbies, building entrances, elevator banks, or amenities\n" +
71
75
  "- Understand peak traffic hours for security, cleaning, or HVAC planning\n" +
@@ -99,69 +103,145 @@ const GET_ROOM_SENSORS = gql `
99
103
  }
100
104
  }
101
105
  `;
106
+ /**
107
+ * Fetch a room and its floor/building context. Kept separate from the shared
108
+ * topology fetch so it can run in parallel with it.
109
+ */
110
+ async function fetchRoom(roomId) {
111
+ try {
112
+ const result = await apolloClient.query({
113
+ query: GET_ROOM_SENSORS,
114
+ variables: { roomId },
115
+ fetchPolicy: "network-only",
116
+ });
117
+ throwIfGraphQLErrors(result);
118
+ return result;
119
+ }
120
+ catch (error) {
121
+ rethrowIfGraphQLError(error);
122
+ throw error;
123
+ }
124
+ }
102
125
  /**
103
126
  * Execute traffic flow tool
104
127
  */
105
128
  export async function executeTrafficFlow(args) {
106
129
  let spaceId = args.space_id_or_name;
107
- // If not an ID, search for the space
108
- if (!spaceId.match(/^room_/)) {
130
+ // One prefix detector for both branches. `asset-helpers.ts` is the single
131
+ // place that encodes ID prefixes, so re-testing the string with a local
132
+ // regex here would be a second thing to keep in sync with it.
133
+ let assetType = detectAssetType(spaceId);
134
+ // If it is neither a room nor a sensor ID, treat it as a search term.
135
+ // Direct sensor queries skip room resolution entirely: the reporting API
136
+ // serves sensor-level traffic via the sensors filter, so a sensor does not
137
+ // need a room wrapper to be queryable.
138
+ if (assetType !== "sensor" && assetType !== "room") {
109
139
  debug("traffic-flow", `Searching for space: "${args.space_id_or_name}"`);
140
+ // Both types, so a natural-language query can reach the sensor path too.
141
+ // The customer ask behind sensor support was to stop modeling each door as
142
+ // a room; searching rooms only would leave the capability reachable solely
143
+ // by callers who already know the sensor ID.
110
144
  const searchResults = await executeSearchAssets({
111
145
  query: args.space_id_or_name,
112
- asset_types: ["room"], // Traffic is room-level
146
+ asset_types: ["room", "sensor"],
113
147
  max_results: 5,
114
148
  });
115
149
  if (searchResults.matches.length === 0) {
116
- throw new Error(`No rooms found matching "${args.space_id_or_name}". Try a different search term.`);
150
+ throw new Error(`No rooms or sensors found matching "${args.space_id_or_name}". Try a different search term, or pass a room ID (room_...) or sensor ID (sensor_...) directly. butlr_search_assets finds both by name or MAC; butlr_hardware_snapshot lists sensors with their health.`);
117
151
  }
118
- spaceId = searchResults.matches[0].id;
119
- debug("traffic-flow", `Using best match: ${searchResults.matches[0].name} (${spaceId})`);
152
+ // Take the highest-ranked candidate that can actually serve traffic: any
153
+ // room (validated downstream), or a traffic-mode sensor. Taking
154
+ // matches[0] unconditionally let a presence sensor whose name outranks
155
+ // the intended room dead-end the whole query with the right room sitting
156
+ // at index 1.
157
+ const viable = searchResults.matches.find((m) => m.type === "room" || (m.type === "sensor" && m.mode === "traffic"));
158
+ if (!viable) {
159
+ throw new Error(`"${args.space_id_or_name}" matched only presence-mode sensors, which have no entry/exit counts. Try butlr_get_current_occupancy for occupancy data, or search for a room or traffic-mode sensor.`);
160
+ }
161
+ spaceId = viable.id;
162
+ assetType = detectAssetType(spaceId);
163
+ debug("traffic-flow", `Using best viable match: ${viable.name} (${spaceId}, ${assetType})`);
120
164
  }
121
- // Query room details, topology for timezone, and all sensors
122
- let room = null;
165
+ const isSensorQuery = assetType === "sensor";
166
+ // Query asset details, topology for timezone, and sensors
167
+ let displayName = "";
123
168
  let roomPath = "";
124
169
  let timezone;
125
170
  let tzMetadata;
126
171
  let timezoneFallback = false;
127
172
  let trafficSensors = [];
128
- try {
129
- const [roomResult, topoResult, sensorsResult] = await Promise.all([
130
- apolloClient.query({
131
- query: GET_ROOM_SENSORS,
132
- variables: { roomId: spaceId },
133
- fetchPolicy: "network-only",
134
- }),
135
- apolloClient.query({
136
- query: GET_FULL_TOPOLOGY,
137
- fetchPolicy: "network-only",
138
- }),
139
- apolloClient.query({
140
- query: GET_ALL_SENSORS,
141
- fetchPolicy: "network-only",
142
- }),
173
+ let installationWarning;
174
+ if (isSensorQuery) {
175
+ // One sensor by ID, not the org's whole inventory: the `sensors` root
176
+ // field accepts an `ids` argument, and nothing on this branch needs the
177
+ // full list. A capped or paged `sensors` field would otherwise turn a real
178
+ // sensor past the cap into a bogus "not found".
179
+ const [topology, sensors] = await Promise.all([fetchTopology(), fetchSensorsByIds([spaceId])]);
180
+ const sensor = sensors.find((s) => s.id === spaceId);
181
+ if (!sensor) {
182
+ throw new Error(`Sensor ${spaceId} not found`);
183
+ }
184
+ // Resolved up front so the rejections below can consult real topology
185
+ // rather than pointing the caller at an ID that may not resolve.
186
+ const sensorContext = resolveSensorContext(sensor, topology);
187
+ // Only a KNOWN test device is refused. isProductionSensor is stricter (it
188
+ // also drops MAC-less placeholder rows) and is the right filter for the
189
+ // room path's aggregate below, but applying it to a caller's explicit
190
+ // request would tell the owner of a real, not yet MAC bound sensor that
191
+ // their device is fake and leave them no way to query it.
192
+ if (isKnownTestSensor(sensor)) {
193
+ throw new Error(`Sensor "${sensor.name}" (${spaceId}) is a test/mirror device, not a production sensor — it has no real traffic data.`);
194
+ }
195
+ if (sensor.mode !== "traffic") {
196
+ // Only name the room when it actually resolves. A dangling room_id (the
197
+ // room was deleted) would otherwise send the caller to a second dead
198
+ // end: butlr_get_current_occupancy accepts the room_ prefix, finds no
199
+ // room, and answers "no sensors configured".
200
+ const suggestion = sensorContext.room
201
+ ? `Try butlr_get_current_occupancy with its room (${sensorContext.room.id}) instead.`
202
+ : `Try butlr_get_asset_details or butlr_hardware_snapshot to inspect it.`;
203
+ throw new Error(`Sensor "${sensor.name}" is ${sensor.mode ? `a ${sensor.mode}-mode sensor` : "not a traffic-mode sensor"}, so it has no entry/exit counts. ${suggestion}`);
204
+ }
205
+ // An uninstalled sensor reports nothing, which otherwise reads as a
206
+ // confident zero. That is likelier here than on the room path: the caller
207
+ // picked one specific device rather than a room aggregating several.
208
+ if (sensor.installation_status === "UNINSTALLED") {
209
+ installationWarning = `Sensor "${sensor.name}" is marked UNINSTALLED in the platform. An uninstalled sensor reports no traffic, so a zero count is expected; any rows returned predate its removal. Check butlr_hardware_snapshot for its current status.`;
210
+ }
211
+ trafficSensors = [sensor];
212
+ displayName = sensorContext.name;
213
+ roomPath = sensorContext.path;
214
+ timezone = sensorContext.timezone;
215
+ tzMetadata = sensorContext.tzMetadata;
216
+ timezoneFallback = sensorContext.timezoneFallback;
217
+ debug("traffic-flow", `Direct sensor query: ${displayName} (${spaceId})`);
218
+ }
219
+ else {
220
+ // The room's own sensor rows via `sensors(room_ids:)` rather than the
221
+ // org-wide inventory. The rows arrive unfiltered and only KNOWN test
222
+ // devices (mirror/fake prefixes) are dropped: this list drives the
223
+ // refusal gate and sensor_count, never the totals (the reporting API
224
+ // aggregates by room_id server-side), so a provisioned-but-MAC-less
225
+ // sensor must not disqualify its room the way the stricter
226
+ // isProductionSensor aggregate filter would.
227
+ const [topology, roomResult, roomSensorRows] = await Promise.all([
228
+ fetchTopology(),
229
+ fetchRoom(spaceId),
230
+ fetchSensorsByRoomIds([spaceId]),
143
231
  ]);
144
- throwIfGraphQLErrors(roomResult);
145
- throwIfGraphQLErrors(topoResult);
146
- throwIfGraphQLErrors(sensorsResult);
147
- if (!roomResult.data?.room) {
232
+ if (!roomResult?.data?.room) {
148
233
  throw new Error(`Room ${spaceId} not found`);
149
234
  }
150
- room = roomResult.data.room;
235
+ const room = roomResult.data.room;
236
+ displayName = room.name;
151
237
  const floor = room.floor;
152
238
  const building = floor?.building;
153
239
  roomPath = building ? `${building.name} > ${floor.name} > ${room.name}` : room.name;
154
- // Get timezone for this room
155
- const sites = topoResult.data?.sites?.data || [];
156
- const buildings = sites.flatMap((s) => s.buildings || []);
157
- const floors = buildings.flatMap((b) => b.floors || []);
158
- const resolved = getTimezoneForAsset(spaceId, "room", floors, buildings, sites);
240
+ const resolved = getTimezoneForAsset(spaceId, "room", topology.floors, topology.buildings, topology.sites);
159
241
  timezone = resolved.timezone;
160
242
  timezoneFallback = resolved.isFallback;
161
243
  tzMetadata = buildTimezoneMetadata(timezone);
162
- // Analyze traffic sensors for this room
163
- const allSensors = sensorsResult.data?.sensors?.data || [];
164
- const roomSensors = allSensors.filter((s) => (s.room_id || s.roomID) === spaceId);
244
+ const roomSensors = roomSensorRows.filter((s) => !isKnownTestSensor(s));
165
245
  // Room-level traffic counts every traffic-mode sensor bound to the room.
166
246
  // See `resolveAssetContext` in occupancy-helpers.ts for the canonical
167
247
  // rationale: `is_entrance` is a semantic flag, not a routing one, and the
@@ -172,10 +252,6 @@ export async function executeTrafficFlow(args) {
172
252
  }
173
253
  debug("traffic-flow", `Found ${trafficSensors.length} traffic sensors for room`);
174
254
  }
175
- catch (error) {
176
- rethrowIfGraphQLError(error);
177
- throw error;
178
- }
179
255
  // Calculate time range
180
256
  const timeWindow = args.time_window || "today";
181
257
  let start;
@@ -220,36 +296,98 @@ export async function executeTrafficFlow(args) {
220
296
  : `today (${tzMetadata.timezone_abbr})`;
221
297
  debug("traffic-flow", `Today starts at ${start} (midnight ${usedUtcFallback ? "UTC fallback" : tzMetadata.timezone_abbr})`);
222
298
  }
223
- debug("traffic-flow", `Querying traffic from ${start} to ${stop}`);
299
+ // Resolve relative forms ("-20m", "now") to absolute timestamps: the
300
+ // ETL-backed reporting API requires RFC3339, and the response period
301
+ // metadata should carry real timestamps rather than relative strings.
302
+ start = resolveTimeToIso(start);
303
+ stop = resolveTimeToIso(stop);
304
+ // Pick window granularity from the range length. The ETL backend only
305
+ // returns fully-closed, end-labeled buckets that fit inside [start, stop]:
306
+ // a 1h window over a short recent range drops the in-progress hour
307
+ // entirely (its bucket doesn't exist until the hour closes). Querying 1m
308
+ // buckets for short ranges keeps edge loss to at most the current minute;
309
+ // results are rolled back up to hours below.
310
+ const durationMs = new Date(stop).getTime() - new Date(start).getTime();
311
+ const windowEvery = durationMs <= 2 * 60 * 60 * 1000 ? "1m" : "1h";
312
+ debug("traffic-flow", `Querying traffic from ${start} to ${stop} (window ${windowEvery})`);
313
+ // `fine` marks 1m-bucket points whose timestamps must be rolled up to the
314
+ // enclosing hour; native 1h bucket labels pass through untouched (they may
315
+ // sit on a non-UTC-aligned local grid, e.g. :30-offset timezones).
224
316
  let trafficData = [];
317
+ const queryAssetType = isSensorQuery ? "sensor" : "room";
225
318
  try {
226
319
  const response = await new ReportingRequestBuilder()
227
- .assets("room", [spaceId])
320
+ .assets(queryAssetType, [spaceId])
228
321
  .measurements(["traffic"])
229
322
  .timeRange(start, stop)
230
- .window("1h", "sum", timezone) // Sum traffic per hour, aligned to local timezone
323
+ .window(windowEvery, "sum", timezone) // Sum traffic per bucket, aligned to local timezone
231
324
  .execute();
232
325
  // Parse flat array response
233
326
  // Traffic returns TWO data points per hour per sensor: "in" and "out"
234
327
  if (!Array.isArray(response.data)) {
235
328
  throw new Error("Expected array response from traffic query");
236
329
  }
237
- trafficData = response.data;
330
+ trafficData = response.data.map((p) => ({
331
+ ...p,
332
+ fine: windowEvery === "1m",
333
+ }));
238
334
  debug("traffic-flow", `Received ${trafficData.length} data points from ${trafficSensors.length} sensors`);
335
+ // A 1h bucket only exists once its hour closes AND the ETL has written
336
+ // it, so a range ending at/near now silently misses the in-progress hour
337
+ // and possibly the just-closed one. Re-query the trailing 2 hours at 1m
338
+ // granularity and merge; hours already covered by a returned 1h bucket
339
+ // are deduplicated in the rollup below. The tail exists solely to
340
+ // recover those not-yet-materialized recent hours (materialization lag
341
+ // measured at up to ~1h), so it is skipped when the range's stop is old
342
+ // enough that every hourly bucket has long since landed.
343
+ if (windowEvery === "1h") {
344
+ const stopDate = new Date(stop);
345
+ const startDate = new Date(start);
346
+ const twoHoursMs = 2 * 60 * 60 * 1000;
347
+ const tailStartMs = Math.max(startDate.getTime(), stopDate.getTime() - twoHoursMs);
348
+ const recentStopMs = 3 * 60 * 60 * 1000;
349
+ if (tailStartMs < stopDate.getTime() && Date.now() - stopDate.getTime() < recentStopMs) {
350
+ try {
351
+ const tailResponse = await new ReportingRequestBuilder()
352
+ .assets(queryAssetType, [spaceId])
353
+ .measurements(["traffic"])
354
+ .timeRange(new Date(tailStartMs).toISOString(), stop)
355
+ .window("1m", "sum", timezone)
356
+ .execute();
357
+ if (Array.isArray(tailResponse.data)) {
358
+ trafficData = trafficData.concat(tailResponse.data.map((p) => ({ ...p, fine: true })));
359
+ debug("traffic-flow", `Merged ${tailResponse.data.length} tail data points (1m)`);
360
+ }
361
+ }
362
+ catch (tailError) {
363
+ // The closed-hour data is still valid without the tail
364
+ debug("traffic-flow", "Partial-hour tail query failed:", tailError);
365
+ }
366
+ }
367
+ }
239
368
  }
240
369
  catch (error) {
241
370
  rethrowIfGraphQLError(error);
242
371
  debug("traffic-flow", "Failed to get traffic data:", error);
243
372
  const msg = error instanceof Error ? error.message : String(error);
244
- throw new Error(`Failed to get traffic data for ${room.name}. ${msg}`);
373
+ throw new Error(`Failed to get traffic data for ${displayName}. ${msg}`);
245
374
  }
246
375
  // Parse traffic data: group by time, then by sensor, then aggregate
247
- // Store time separately to preserve full ISO timestamp
376
+ // Store time separately to preserve full ISO timestamp. The fine flag is
377
+ // part of the key: a 1m tail bucket ending exactly on an hour boundary
378
+ // carries the same label as that hour's native 1h bucket, and must not
379
+ // share (and overwrite) the native bucket's entry.
248
380
  const byHourSensor = new Map();
249
381
  for (const point of trafficData) {
250
- const key = `${point.time}:${point.sensor_id}`;
382
+ const key = `${point.time}:${point.sensor_id}:${point.fine}`;
251
383
  if (!byHourSensor.has(key)) {
252
- byHourSensor.set(key, { time: point.time, in: 0, out: 0 });
384
+ byHourSensor.set(key, {
385
+ time: point.time,
386
+ sensor_id: point.sensor_id,
387
+ fine: point.fine,
388
+ in: 0,
389
+ out: 0,
390
+ });
253
391
  }
254
392
  const counts = byHourSensor.get(key);
255
393
  if (point.field === "in") {
@@ -259,10 +397,48 @@ export async function executeTrafficFlow(args) {
259
397
  counts.out = point.value || 0;
260
398
  }
261
399
  }
400
+ // Roll a bucket timestamp up to its enclosing hour. Bucket timestamps are
401
+ // end-labeled (a 1m bucket "17:29" covers 17:28–17:29), so ceil to the
402
+ // hour to stay on the same end-labeled convention the API uses for 1h
403
+ // buckets.
404
+ const toHourEnd = (iso) => {
405
+ const d = new Date(iso);
406
+ if (d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0 || d.getUTCMilliseconds() !== 0) {
407
+ d.setUTCMinutes(0, 0, 0);
408
+ d.setUTCHours(d.getUTCHours() + 1);
409
+ }
410
+ return d.toISOString().replace(".000Z", "Z");
411
+ };
412
+ // Native (closed) 1h buckets are authoritative for the interval they
413
+ // cover, so fine-grained tail minutes falling inside any of them are
414
+ // dropped to avoid double counting. Coverage is checked on the bucket
415
+ // intervals themselves — an end-labeled 1h bucket T covers (T-1h, T] —
416
+ // rather than on labels, so it also holds for sites whose local hour grid
417
+ // is not UTC-aligned (e.g. :30-offset timezones). Coverage is tracked
418
+ // per sensor: bucket materialization is a per-series flush, so one
419
+ // sensor's landed hour must not suppress another sensor's tail minutes
420
+ // for that same hour.
421
+ const HOUR_MS = 60 * 60 * 1000;
422
+ const nativeEndsBySensor = new Map();
423
+ for (const [, data] of byHourSensor) {
424
+ if (!data.fine) {
425
+ const ends = nativeEndsBySensor.get(data.sensor_id) || [];
426
+ ends.push(new Date(data.time).getTime());
427
+ nativeEndsBySensor.set(data.sensor_id, ends);
428
+ }
429
+ }
430
+ const coveredByNative = (sensorId, iso) => {
431
+ const t = new Date(iso).getTime();
432
+ const ends = nativeEndsBySensor.get(sensorId);
433
+ return ends ? ends.some((end) => t <= end && t > end - HOUR_MS) : false;
434
+ };
262
435
  // Aggregate across sensors by hour (group by time only)
263
436
  const byHour = new Map();
264
437
  for (const [_key, data] of byHourSensor) {
265
- const time = data.time; // Use actual time from data, not split key
438
+ if (data.fine && coveredByNative(data.sensor_id, data.time)) {
439
+ continue;
440
+ }
441
+ const time = data.fine ? toHourEnd(data.time) : data.time;
266
442
  if (!byHour.has(time)) {
267
443
  byHour.set(time, { in: 0, out: 0 });
268
444
  }
@@ -294,13 +470,32 @@ export async function executeTrafficFlow(args) {
294
470
  }
295
471
  // Build enhanced summary
296
472
  const netFlowStr = netFlow >= 0 ? `+${netFlow}` : `${netFlow}`;
297
- const summary = `${room.name}: ${totalTraffic.toLocaleString()} movements ${periodDescription} (${totalEntries.toLocaleString()} entries, ${totalExits.toLocaleString()} exits, net flow: ${netFlowStr})`;
473
+ const summary = `${displayName}: ${totalTraffic.toLocaleString()} movements ${periodDescription} (${totalEntries.toLocaleString()} entries, ${totalExits.toLocaleString()} exits, net flow: ${netFlowStr})`;
474
+ // Compose the response warning. Two independent conditions can raise one and
475
+ // both matter, so they are joined rather than one silently shadowing the other.
476
+ const warnings = [];
477
+ if (installationWarning) {
478
+ warnings.push(installationWarning);
479
+ }
480
+ if (usedUtcFallback) {
481
+ // Window-aware copy. The midnight sentence is only true on the `today`
482
+ // branch. The unassigned-sensor path sets timezoneFallback for every
483
+ // room-less, floor-less sensor, so on a 20m/1h/custom window the old text
484
+ // fired routinely and sent the reader chasing a day-alignment problem that
485
+ // no part of the query involved.
486
+ // `timezone` holds whatever the fallback actually resolved to — UTC, or
487
+ // BUTLR_TIMEZONE when configured — so the copy names it rather than
488
+ // asserting UTC for a query that may have used something else.
489
+ warnings.push(timeWindow === "today"
490
+ ? `Could not determine local timezone for this space; timestamps use midnight in the fallback timezone (${timezone}). 'Today' may not align with the site's actual local day.`
491
+ : `Could not determine local timezone for this space; hourly bucket alignment falls back to ${timezone}, so buckets may not line up with the site's local hours. The queried range itself is unaffected.`);
492
+ }
298
493
  // Build response with timezone metadata and in/out breakdown
299
494
  const response = {
300
495
  space: {
301
- id: room.id,
302
- name: room.name,
303
- type: "room",
496
+ id: spaceId,
497
+ name: displayName,
498
+ type: isSensorQuery ? "sensor" : "room",
304
499
  path: roomPath,
305
500
  sensor_mode: "traffic",
306
501
  ...tzMetadata,
@@ -330,9 +525,12 @@ export async function executeTrafficFlow(args) {
330
525
  summary,
331
526
  timestamp: new Date().toISOString(),
332
527
  timezone_note: "All timestamps are UTC (ISO-8601). Use site_timezone to interpret in local time.",
333
- ...(usedUtcFallback && {
334
- warning: "Could not determine local timezone for this space; timestamps use UTC midnight as fallback. 'Today' may not align with the site's actual local day.",
528
+ // Traffic events take ~5-6 minutes to land in the reporting store, so a
529
+ // window ending at/near now structurally undercounts the trailing minutes
530
+ ...(Date.now() - new Date(stop).getTime() < 10 * 60 * 1000 && {
531
+ freshness_note: "Traffic data becomes available roughly 5-10 minutes after events occur. Counts for the most recent ~10 minutes may still be incomplete; re-query later for final numbers.",
335
532
  }),
533
+ ...(warnings.length > 0 && { warning: warnings.join(" ") }),
336
534
  };
337
535
  return response;
338
536
  }